import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; import { Button, IconButton, Size, Type } from '@dash/components'; import { concat, intersection } from 'lodash'; import { action, computed, makeObservable, observable, runInAction } from 'mobx'; import { observer } from 'mobx-react'; import * as React from 'react'; import Select, { MultiValue } from 'react-select'; import * as RequestPromise from 'request-promise'; import { ClientUtils } from '../../ClientUtils'; import { Utils } from '../../Utils'; import { Doc, DocListCast, DocListCastAsync, HierarchyMapping, ReverseHierarchyMap } from '../../fields/Doc'; import { AclAdmin, AclPrivate, DocAcl, DocData } from '../../fields/DocSymbols'; import { FieldLoader } from '../../fields/FieldLoader'; import { Id } from '../../fields/FieldSymbols'; import { StrCast } from '../../fields/Types'; import { GetEffectiveAcl, SharingPermissions, TraceMobx, distributeAcls, normalizeEmail } from '../../fields/util'; import { DocServer } from '../DocServer'; import { MainViewModal } from '../views/MainViewModal'; import { DocumentView } from '../views/nodes/DocumentView'; import { TaskCompletionBox } from '../views/nodes/TaskCompletedBox'; import { GroupManager, UserOptions } from './GroupManager'; import { GroupMemberView } from './GroupMemberView'; import { SearchUtil } from './SearchUtil'; import './SharingManager.scss'; import { SnappingManager } from './SnappingManager'; import { undoable } from './UndoManager'; import { LinkManager } from './LinkManager'; export interface User { email: string; sharingDocumentId: string; linkDatabaseId: string; } /** * Interface for grouped options for the react-select component. */ interface GroupedOptions { label: string; options: UserOptions[]; } // const SharingKey = "sharingPermissions"; // const PublicKey = "all"; // const DefaultColor = "black"; // used to differentiate between individuals and groups when sharing const indType = '!indType/'; const groupType = '!groupType/'; const storage = 'data'; const dashStorage = 'data_dashboards'; /** * A user who also has a sharing doc. */ interface ValidatedUser { user: User; // database minimal info to identify / communicate with a user (email, sharing doc id) sharingDoc: Doc; // document to share/message another user linkDatabase: Doc; userColor: string; // stored on the sharinDoc, extracted for convenience? } @observer export class SharingManager extends React.Component { // eslint-disable-next-line no-use-before-define public static Instance: SharingManager; private shareDocumentButtonRef: React.RefObject = React.createRef(); // ref for the share button, used for the position of the popup private populating: boolean = false; // whether the list of users is populating or not @observable private isOpen = false; // whether the SharingManager modal is open or not @observable public users: ValidatedUser[] = []; // the list of users with sharing docs @observable private targetDoc: Doc | undefined = undefined; // the document being shared @observable private targetDocView: DocumentView | undefined = undefined; // the DocumentView of the document being shared // @observable private copied = false; @observable private dialogueBoxOpacity = 1; // for the modal @observable private selectedUsers: UserOptions[] | null = null; // users (individuals/groups) selected to share with @observable private permissions: SharingPermissions = SharingPermissions.Edit; // the permission with which to share with other users @observable private individualSort: 'ascending' | 'descending' | 'none' = 'none'; // sorting options for the list of individuals @observable private groupSort: 'ascending' | 'descending' | 'none' = 'none'; // sorting options for the list of groups // if both showUserOptions and showGroupOptions are false then both are displayed @observable private showUserOptions: boolean = false; // whether to show individuals as options when sharing (in the react-select component) @observable private showGroupOptions: boolean = false; // // whether to show groups as options when sharing (in the react-select component) @observable private upgradeNested: boolean = false; // whether child docs in a collection/dashboard should be changed to be less private - initially selected so default is upgrade all @observable private layoutDocAcls: boolean = false; // whether the layout doc or data doc's acls are to be used @observable private myDocAcls: boolean = false; // whether the My Docs checkbox is selected or not // private get linkVisible() { // return this.targetDoc ? this.targetDoc['acl_' + PublicKey] !== SharingPermissions.None : false; // } constructor(props: object) { super(props); makeObservable(this); SharingManager.Instance = this; DocumentView.ShareOpen = this.open; } /** * Populates the list of users. */ componentDidMount() { this.populateUsers(); } /** * Handles changes in the users selected in react-select */ @action handleUsersChange = (selectedOptions: MultiValue /* , actionMeta: ActionMeta */) => { this.selectedUsers = Array.from(selectedOptions); }; /** * Handles changes in the permission chosen to share with someone with */ handlePermissionsChange = undoable( action((event: React.ChangeEvent) => { this.permissions = event.currentTarget.value as SharingPermissions; }), 'permission change' ); /** * @returns the main interface of the SharingManager. */ @computed get sharingInterface() { if (!this.targetDoc) return null; TraceMobx(); const groupList = GroupManager.Instance?.allGroups || []; const sortedUsers = this.users .slice() .sort(this.sortUsers) .map(({ user: { email } }) => ({ label: email, value: indType + email })); const sortedGroups = groupList .slice() .sort(this.sortGroups) .map(({ title }) => ({ label: StrCast(title), value: groupType + StrCast(title) })); // the next block handles the users shown (individuals/groups/both) const options: GroupedOptions[] = []; if (GroupManager.Instance) { if ((this.showUserOptions && this.showGroupOptions) || (!this.showUserOptions && !this.showGroupOptions)) { options.push({ label: 'Individuals', options: sortedUsers }, { label: 'Groups', options: sortedGroups }); } else if (this.showUserOptions) options.push({ label: 'Individuals', options: sortedUsers }); else options.push({ label: 'Groups', options: sortedGroups }); } const users = this.individualSort === 'ascending' ? this.users.slice().sort(this.sortUsers) : this.individualSort === 'descending' ? this.users.slice().sort(this.sortUsers).reverse() : this.users; const groups = this.groupSort === 'ascending' ? groupList.slice().sort(this.sortGroups) : this.groupSort === 'descending' ? groupList.slice().sort(this.sortGroups).reverse() : groupList; let docs = DocumentView.Selected().length < 2 ? [this.targetDoc] : DocumentView.Selected().map(docView => docView.Document); if (this.myDocAcls) { const newDocs: Doc[] = []; SearchUtil.foreachRecursiveDoc(docs, (depth, doc) => newDocs.push(doc)); docs = newDocs.filter(doc => GetEffectiveAcl(doc) === AclAdmin); } const targetDoc = this.layoutDocAcls ? docs[0] : docs[0]?.[DocData]; // tslint:disable-next-line: no-unnecessary-callback-wrapper const effectiveAcls = docs.map(doc => GetEffectiveAcl(doc)); const admin = this.myDocAcls ? Boolean(docs.length) : effectiveAcls.every(acl => acl === AclAdmin); // users in common between all docs const commonKeys = intersection(docs).reduce((list, doc) => (doc?.[DocAcl] ? [...list, ...Object.keys(doc[DocAcl])] : list), [] as string[]); // the list of users shared with const userListContents = users // .filter(({ user }) => (docs.length > 1 ? commonKeys.includes(`acl_${normalizeEmail(user.email)}`) : docs[0]?.author !== user.email)) .filter(({ user }) => docs[0]?.author !== user.email) .map(({ user, linkDatabase, sharingDoc, userColor }) => { const userKey = `acl_${normalizeEmail(user.email)}`; const uniform = docs.every(doc => doc?.[DocAcl]?.[userKey] === docs[0]?.[DocAcl]?.[userKey]); // const permissions = uniform ? StrCast(targetDoc?.[userKey]) : '-multiple-'; let permissions = targetDoc[DocAcl][userKey] ? HierarchyMapping.get(targetDoc[DocAcl][userKey])?.name : StrCast(targetDoc[userKey]); permissions = uniform ? StrCast(targetDoc?.[userKey]) : '-multiple-'; return !permissions ? null : (
{user.email}
{admin || this.myDocAcls ? ( ) : (
{concat(ReverseHierarchyMap.get(permissions)?.image, ' ', permissions)}  
)}
); }); // checks if every doc has the same author const sameAuthor = docs.every(doc => doc?.author === docs[0]?.author); // the owner of the doc and the current user are placed at the top of the user list. const userKey = `acl_${normalizeEmail(ClientUtils.CurrentUserEmail())}`; const curUserPermission = StrCast(targetDoc[userKey]); // const curUserPermission = HierarchyMapping.get(effectiveAcls[0])!.name userListContents.unshift( sameAuthor ? (
{targetDoc?.author === ClientUtils.CurrentUserEmail() ? 'Me' : StrCast(targetDoc?.author)}
Owner
) : null, sameAuthor && targetDoc?.author !== ClientUtils.CurrentUserEmail() ? (
Me
{effectiveAcls.every(acl => acl === effectiveAcls[0]) ? concat(ReverseHierarchyMap.get(curUserPermission!)?.image, ' ', curUserPermission) : '-multiple-'}  
) : null ); // the list of groups shared with const groupListMap: (Doc | { title: string })[] = groups.filter(({ title }) => (docs.length > 1 ? commonKeys.includes(`acl_${normalizeEmail(StrCast(title))}`) : true)); groupListMap.unshift({ title: 'Guest' }); // , { title: "ALL" }); const groupListContents = groupListMap.map(group => { const groupKey = `acl_${StrCast(group.title)}`; const uniform = docs.every(doc => doc?.[DocAcl]?.[groupKey] === docs[0]?.[DocAcl]?.[groupKey]); const permissions = uniform ? StrCast(targetDoc?.[groupKey]) : '-multiple-'; return !permissions ? null : (
{StrCast(group.title)}
  {group instanceof Doc ? ( } size={Size.XSMALL} color={SnappingManager.userColor} onClick={action(() => { GroupManager.Instance.currentGroup = group; })} /> ) : null}
{admin || this.myDocAcls ? ( ) : (
{concat(ReverseHierarchyMap.get(permissions)?.image, ' ', permissions)}  
)}
); }); return (
{GroupManager.Instance?.currentGroup ? ( { GroupManager.Instance.currentGroup = undefined; })} /> ) : null}

window.open('https://brown-dash.github.io/Dash-Documentation/features/collaboration/', '_blank')}> window.open('https://brown-dash.github.io/Dash-Documentation/features/collaboration/', '_blank')} />
Share {this.focusOn(docs.length < 2 ? StrCast(targetDoc?.title, 'this document') : '-multiple-')}

{admin ? (
{this.sharingOptions(true)}
{ this.showUserOptions = !this.showUserOptions; })} />{' '} { this.showGroupOptions = !this.showGroupOptions; })} />{' '}
{Doc.noviceMode ? null : (
{ this.upgradeNested = !this.upgradeNested; })} checked={this.upgradeNested} />{' '} { this.layoutDocAcls = !this.layoutDocAcls; })} checked={this.layoutDocAcls} />{' '}
)}
) : (
{ this.layoutDocAcls = !this.layoutDocAcls; })} checked={this.layoutDocAcls} />{' '}
)}
{ this.individualSort = this.individualSort === 'ascending' ? 'descending' : this.individualSort === 'descending' ? 'none' : 'ascending'; })}>
Individuals } size={Size.XSMALL} color={StrCast(Doc.UserDoc().userColor)} />
{userListContents}
{ this.groupSort = this.groupSort === 'ascending' ? 'descending' : this.groupSort === 'descending' ? 'none' : 'ascending'; })}>
Groups } size={Size.XSMALL} color={StrCast(Doc.UserDoc().userColor)} onClick={action(() => GroupManager.Instance.open())} /> } size={Size.XSMALL} color={StrCast(Doc.UserDoc().userColor)} />
{groupListContents}
); } /** * Shares the document with a user. */ setInternalSharing = undoable((recipient: ValidatedUser, permission: string, targetDoc: Doc | undefined) => { const { user, sharingDoc } = recipient; const target = targetDoc || this.targetDoc!; const acl = `acl_${normalizeEmail(user.email)}`; const docs = DocumentView.Selected().length < 2 ? [target] : DocumentView.Selected().map(docView => docView.Document); docs.map(doc => (this.layoutDocAcls || doc.dockingConfig ? doc : Doc.GetProto(doc))).forEach(doc => { distributeAcls(acl, permission as SharingPermissions, doc, undefined, this.upgradeNested ? true : undefined); if (permission !== SharingPermissions.None) { Doc.AddDocToList(sharingDoc, doc.dockingConfig ? dashStorage : storage, doc); } else GetEffectiveAcl(doc, user.email) === AclPrivate && Doc.RemoveDocFromList(sharingDoc, ((doc.createdFrom as Doc) || doc).dockingConfig ? dashStorage : storage, (doc.createdFrom as Doc) || doc); }); }, 'set Doc permissions'); /** * Sets the permission on the target for the group. * @param group * @param permission */ setInternalGroupSharing = undoable((group: Doc | { title: string }, permission: string, targetDoc?: Doc) => { const target = targetDoc || this.targetDoc!; const acl = `acl_${normalizeEmail(StrCast(group.title))}`; const docs = DocumentView.Selected().length < 2 ? [target] : DocumentView.Selected().map(docView => docView.Document); docs.map(doc => (this.layoutDocAcls || doc.dockingConfig ? doc : Doc.GetProto(doc))).forEach(doc => { distributeAcls(acl, permission as SharingPermissions, doc, undefined, this.upgradeNested ? true : undefined); if (group instanceof Doc) { Doc.AddDocToList(group, 'docsShared', doc); this.users .filter(({ user: { email } }) => JSON.parse(StrCast(group.members)).includes(email)) .forEach(({ user, sharingDoc }) => { if (permission !== SharingPermissions.None) Doc.AddDocToList(sharingDoc, doc.dockingConfig ? dashStorage : storage, doc); // add the doc to the sharingDoc if it hasn't already been added else GetEffectiveAcl(doc, user.email) === AclPrivate && Doc.RemoveDocFromList(sharingDoc, ((doc.createdFrom as Doc) || doc).dockingConfig ? dashStorage : storage, (doc.createdFrom as Doc) || doc); // remove the doc from the list if it already exists }); } }); }, 'set group permissions'); /** * Populates the list of validated users (this.users) by adding registered users which have a sharingDocument. */ populateUsers = async () => { if (!this.populating && Doc.UserDoc()[Id] !== Utils.GuestID()) { this.populating = true; const userList = await RequestPromise.get(ClientUtils.prepend('/getUsers')); const raw = (JSON.parse(userList) as User[]).filter(user => user.email !== 'guest' && user.email !== ClientUtils.CurrentUserEmail()); runInAction(() => { FieldLoader.ServerLoadStatus.message = 'users'; }); const docs = await DocServer.GetRefFields(raw.reduce((list, user) => [...list, user.sharingDocumentId, user.linkDatabaseId], [] as string[])); raw.map( action((newUser: User) => { const sharingDoc = docs.get(newUser.sharingDocumentId); const linkDatabase = docs.get(newUser.linkDatabaseId); if (sharingDoc instanceof Doc && linkDatabase instanceof Doc) { if (!this.users.find(user => user.user.email === newUser.email)) { this.users.push({ user: newUser, sharingDoc, linkDatabase, userColor: StrCast(sharingDoc.userColor) }); LinkManager.Instance.addLinkDB(linkDatabase); } } }) ); this.populating = false; } }; // eslint-disable-next-line react/sort-comp public close = action(() => { this.isOpen = false; this.selectedUsers = null; // resets the list of users and selected users (in the react-select component) TaskCompletionBox.taskCompleted = false; setTimeout( action(() => { // this.copied = false; this.targetDoc = undefined; }), 500 ); this.layoutDocAcls = false; }); // eslint-disable-next-line react/no-unused-class-component-methods public open = (target?: DocumentView, targetDoc?: Doc) => { this.populateUsers(); runInAction(() => { this.targetDocView = target; this.targetDoc = targetDoc || target?.Document; this.isOpen = this.targetDoc !== undefined; this.permissions = SharingPermissions.Augment; this.upgradeNested = true; }); }; /** * Shares the documents shared with a group with a new user who has been added to that group. * @param group * @param emailId */ // eslint-disable-next-line react/no-unused-class-component-methods shareWithAddedMember = (group: Doc, emailId: string, retry: boolean = true) => { const user = this.users.find(({ user: { email } }) => email === emailId)!; if (group.docsShared) { if (!user) retry && this.populateUsers().then(() => this.shareWithAddedMember(group, emailId, false)); else { DocListCastAsync(user.sharingDoc[storage]).then(userdocs => DocListCastAsync(group.docsShared).then(dl => { const filtered = dl?.filter(doc => !doc.dockingConfig && !userdocs?.includes(doc)); filtered && userdocs?.push(...filtered); }) ); DocListCastAsync(user.sharingDoc[dashStorage]).then(userdocs => DocListCastAsync(group.docsShared).then(dl => { const filtered = dl?.filter(doc => doc.dockingConfig && !userdocs?.includes(doc)); filtered && userdocs?.push(...filtered); }) ); } } }; /** * Called from the properties sidebar to change permissions of a user. */ // eslint-disable-next-line react/no-unused-class-component-methods shareFromPropertiesSidebar = undoable((shareWith: string, permission: SharingPermissions, docs: Doc[], layout: boolean) => { if (layout) this.layoutDocAcls = true; if (shareWith !== 'Guest') { const user = this.users.find(({ user: { email } }) => email === (shareWith === 'Me' ? ClientUtils.CurrentUserEmail() : shareWith)); docs.forEach(doc => { if (user) this.setInternalSharing(user, permission, doc); else this.setInternalGroupSharing(GroupManager.Instance.getGroup(shareWith)!, permission, doc, undefined, true); }); } else { docs.forEach(doc => { if (GetEffectiveAcl(doc) === AclAdmin) { distributeAcls(`acl_${shareWith}`, permission, doc, undefined); } }); } this.layoutDocAcls = false; }, 'sidebar set permissions'); /** * Removes the documents shared with a user through a group when the user is removed from the group. * @param group * @param emailId */ // eslint-disable-next-line react/no-unused-class-component-methods removeMember = (group: Doc, emailId: string) => { const user: ValidatedUser = this.users.find(({ user: { email } }) => email === emailId)!; if (group.docsShared && user) { DocListCastAsync(user.sharingDoc[storage]).then(userdocs => DocListCastAsync(group.docsShared).then(dl => { const remaining = userdocs?.filter(doc => !dl?.includes(doc)) || []; userdocs?.splice(0, userdocs.length, ...remaining); }) ); DocListCastAsync(user.sharingDoc[dashStorage]).then(userdocs => DocListCastAsync(group.docsShared).then(dl => { const remaining = userdocs?.filter(doc => !dl?.includes(doc)) || []; userdocs?.splice(0, userdocs.length, ...remaining); }) ); } }; /** * Removes a group's permissions from documents that have been shared with it. * @param group */ // eslint-disable-next-line react/no-unused-class-component-methods removeGroup = (group: Doc) => { if (group.docsShared) { DocListCast(group.docsShared).forEach(doc => { const acl = `acl_${StrCast(group.title)}`; distributeAcls(acl, SharingPermissions.None, doc); const members: string[] = JSON.parse(StrCast(group.members)); const users: ValidatedUser[] = this.users.filter(({ user: { email } }) => members.includes(email)); users.forEach(({ sharingDoc }) => Doc.RemoveDocFromList(sharingDoc, storage, doc)); }); } }; // private setExternalSharing = (permission: string) => { // const targetDoc = this.targetDoc; // if (!targetDoc) { // return; // } // targetDoc['acl_' + PublicKey] = permission; // }s /** * Copies the Public sharing url to the user's clipboard. */ private copyURL = () => { ClientUtils.CopyText(ClientUtils.shareUrl(this.targetDoc![Id])); }; private focusOn = (contents: string) => { const title = this.targetDoc ? StrCast(this.targetDoc.title) : ''; const docs = DocumentView.Selected().length > 1 ? DocumentView.Selected().map(docView => docView.props.Document) : [this.targetDoc]; return ( { if (this.targetDoc && this.targetDocView && docs.length === 1) { DocumentView.showDocument(this.targetDoc, { willZoomCentered: true }); } }} onPointerEnter={action(() => { if (docs.length) { docs.forEach(doc => doc && Doc.BrushDoc(doc)); this.dialogueBoxOpacity = 0.1; } })} onPointerLeave={action(() => { if (docs.length) { docs.forEach(doc => doc && Doc.UnBrushDoc(doc)); this.dialogueBoxOpacity = 1; } })}> {contents} ); }; /** * Calls the relevant method for sharing, displays the popup, and resets the relevant variables. */ share = undoable( action(() => { if (this.selectedUsers) { this.selectedUsers.forEach(user => { if (user.value.includes(indType)) { this.setInternalSharing(this.users.find(u => u.user.email === user.label)!, this.permissions, undefined); } else { this.setInternalGroupSharing(GroupManager.Instance.getGroup(user.label)!, this.permissions); } }); if (this.shareDocumentButtonRef.current) { const { left, width, top, height } = this.shareDocumentButtonRef.current.getBoundingClientRect(); TaskCompletionBox.popupX = left - 1.5 * width; TaskCompletionBox.popupY = top - 1.5 * height; TaskCompletionBox.textDisplayed = 'Document shared!'; TaskCompletionBox.taskCompleted = true; setTimeout( action(() => { TaskCompletionBox.taskCompleted = false; }), 2000 ); } this.layoutDocAcls = false; this.selectedUsers = null; } }), 'share Doc' ); /** * Sorting algorithm to sort users. */ sortUsers = (u1: ValidatedUser, u2: ValidatedUser) => { const { email: e1 } = u1.user; const { email: e2 } = u2.user; return e1 < e2 ? -1 : e1 === e2 ? 0 : 1; }; /** * Sorting algorithm to sort groups. */ sortGroups = (group1: Doc, group2: Doc) => { const g1 = StrCast(group1.title); const g2 = StrCast(group2.title); return g1 < g2 ? -1 : g1 === g2 ? 0 : 1; }; /** * Returns the SharingPermissions (Admin, Can Edit etc) access that's used to share */ private sharingOptions(uniform: boolean, showGuestOptions?: boolean) { const dropdownValues: string[] = showGuestOptions ? [SharingPermissions.None, SharingPermissions.View] : Object.values(SharingPermissions); if (!uniform) dropdownValues.unshift('-multiple-'); return dropdownValues.map(permission => ( )); } render() { return ; } }