From f7c9919120e2a5c075e3bb7941a968671727eba8 Mon Sep 17 00:00:00 2001 From: Paul Fitzpatrick Date: Mon, 25 Oct 2021 12:54:04 -0400 Subject: [PATCH] (core) annotate shares listed in UserManager for documents Summary: This gives more guidance to users when editing document shares in the UserManager dialog. * For a document on a team site, any shares with team members are marked `Team member`. * Shares that count as external collaborators are marked for documents on a team or personal site as `collaborator` (personal site) or `outside collaborator` (team site). * Collaborators are marked `1 of 2`, `2 of 2`, and then `limit exceeded`. * On a team site, links are offered for each collaborator to add them to the team. The links lead to a prefilled dialog for managing team membership which can be confirmed immediately, allowing the user to continue without interruption. * On a personal site, for the last collaborator and beyond, a link is added for creating a team. This isn't seamless since creating a team involves billing etc. There's a small unrelated tweak in tests to remove a confusing import from `test/browser` in `test/server`. One thing I didn't get to is checking if owner of doc is owner of site. If they aren't, they may try to add a member and be denied at that point - it would be more polite to change messaging earlier for them. Test Plan: added and updated tests Reviewers: georgegevoian Reviewed By: georgegevoian Differential Revision: https://phab.getgrist.com/D3083 --- app/client/models/UserManagerModel.ts | 66 ++++++++++++-- app/client/ui/DocMenu.ts | 2 + app/client/ui/ShareMenu.ts | 2 + app/client/ui/UserItem.ts | 29 +++++- app/client/ui/UserManager.ts | 124 +++++++++++++++++++++++--- app/common/ShareAnnotator.ts | 90 +++++++++++++++++++ app/common/UserAPI.ts | 2 + app/common/gristUrls.ts | 4 + app/gen-server/lib/HomeDBManager.ts | 8 +- 9 files changed, 302 insertions(+), 25 deletions(-) create mode 100644 app/common/ShareAnnotator.ts diff --git a/app/client/models/UserManagerModel.ts b/app/client/models/UserManagerModel.ts index 1dee2e17..dfe1f90a 100644 --- a/app/client/models/UserManagerModel.ts +++ b/app/client/models/UserManagerModel.ts @@ -1,5 +1,7 @@ +import {AppModel} from 'app/client/models/AppModel'; import {DocPageModel} from 'app/client/models/DocPageModel'; import {reportWarning} from 'app/client/models/errors'; +import {ShareAnnotations, ShareAnnotator} from 'app/common/ShareAnnotator'; import {normalizeEmail} from 'app/common/emails'; import {GristLoadConfig} from 'app/common/gristUrls'; import * as roles from 'app/common/roles'; @@ -20,9 +22,12 @@ export interface UserManagerModel { // anon@ or everyone@ (depending on the settings and resource). isAnythingChanged: Computed; // Indicates whether there are unsaved changes isOrg: boolean; // Indicates if the UserManager is for an org + annotations: Observable; // More information about shares, keyed by email. // Resets all unsaved changes reset(): void; + // Recreate annotations, factoring in any changes on the back-end. + reloadAnnotations(): Promise; // Writes all unsaved changes to the server. save(userApi: UserAPI, resourceId: number|string): Promise; // Adds a member to membersEdited @@ -107,8 +112,12 @@ export class UserManagerModelImpl extends Disposable implements UserManagerModel public membersEdited = this.autoDispose(obsArray(this._buildAllMembers())); + public annotations = this.autoDispose(observable({users: new Map()})); + public isOrg: boolean = this.resourceType === 'organization'; + private _shareAnnotator?: ShareAnnotator; + // Checks if any members were added/removed/changed, if the max inherited role changed or if the // anonymous access setting changed to enable the confirm button to write changes to the server. public readonly isAnythingChanged: Computed = this.autoDispose(computed((use) => { @@ -122,14 +131,37 @@ export class UserManagerModelImpl extends Disposable implements UserManagerModel constructor( public initData: PermissionData, public resourceType: ResourceType, - private _activeUserEmail: string|null, - private _docPageModel?: DocPageModel, + private _options: { + activeEmail?: string|null, + reload?: () => Promise, + docPageModel?: DocPageModel, + appModel?: AppModel, + } ) { super(); + if (this._options.appModel) { + const features = this._options.appModel.currentFeatures; + this._shareAnnotator = new ShareAnnotator(features, initData); + } + this.annotate(); } public reset(): void { this.membersEdited.set(this._buildAllMembers()); + this.annotate(); + } + + public async reloadAnnotations(): Promise { + if (!this._options.reload || !this._shareAnnotator) { return; } + const data = await this._options.reload(); + // Update the permission data backing the annotations. We don't update the full model + // itself - that would be nice, but tricky since the user may have made changes to it. + // But at least we can easily update annotations. This is good for the potentially + // common flow of opening a doc, starting to add a user, following the suggestion of + // adding that user as a member of the site, then returning to finish off adding + // them to the doc. + this._shareAnnotator.updateState(data); + this.annotate(); } public async save(userApi: UserAPI, resourceId: number|string): Promise { @@ -172,6 +204,7 @@ export class UserManagerModelImpl extends Disposable implements UserManagerModel newMember.isNew = true; this.membersEdited.push(newMember); } + this.annotate(); } public remove(member: IEditableMember): void { @@ -182,14 +215,24 @@ export class UserManagerModelImpl extends Disposable implements UserManagerModel // Keep it in the array with a flag, to simplify comparing "before" and "after" arrays. this.membersEdited.splice(index, 1, {...member, isRemoved: true}); } + this.annotate(); } public isActiveUser(member: IEditableMember): boolean { - return member.email === this._activeUserEmail; + return member.email === this._options.activeEmail; } - public getDelta(): PermissionDelta { - // Construct the permission delta from the changed users/maxInheritedRole. + // Analyze the relation that users have to the resource or site. + public annotate() { + // Only attempt for documents for now. + // TODO: extend to workspaces. + if (!this._shareAnnotator) { return; } + this.annotations.set(this._shareAnnotator.annotateChanges(this.getDelta({silent: true}))); + } + + // Construct the permission delta from the changed users/maxInheritedRole. + // Give warnings or errors as appropriate (these are suppressed if silent is set). + public getDelta(options?: {silent: boolean}): PermissionDelta { const delta: PermissionDelta = { users: {} }; if (this.resourceType !== 'organization') { const maxInheritedRole = this.maxInheritedRole.get(); @@ -205,12 +248,17 @@ export class UserManagerModelImpl extends Disposable implements UserManagerModel for (const m of members) { let access = m.access.get(); if (m === this.publicMember && access === roles.EDITOR && - this._docPageModel?.gristDoc.get()?.hasGranularAccessRules()) { + this._options.docPageModel?.gristDoc.get()?.hasGranularAccessRules()) { access = roles.VIEWER; - reportWarning('Public "Editor" access is incompatible with Access Rules. Reduced to "Viewer".'); + if (!options?.silent) { + reportWarning('Public "Editor" access is incompatible with Access Rules. Reduced to "Viewer".'); + } } if (!roles.isValidRole(access)) { - throw new Error(`Cannot update user to invalid role ${access}`); + if (!options?.silent) { + throw new Error(`Cannot update user to invalid role ${access}`); + } + continue; } if (m.isNew || m.isRemoved || m.origAccess !== access) { // Add users whose access changed. @@ -264,7 +312,7 @@ export class UserManagerModelImpl extends Disposable implements UserManagerModel const access = Observable.create(this, member.access); let inheritedAccess: Computed; - if (member.email === this._activeUserEmail) { + if (member.email === this._options.activeEmail) { // Note that we currently prevent the active user's role from changing to prevent users from // locking themselves out of resources. We ensure that by setting inheritedAccess to the // active user's initial access level, which is OWNER normally. (It's sometimes possible to diff --git a/app/client/ui/DocMenu.ts b/app/client/ui/DocMenu.ts index fe9b8a1b..30d4119a 100644 --- a/app/client/ui/DocMenu.ts +++ b/app/client/ui/DocMenu.ts @@ -431,6 +431,8 @@ export function makeDocOptionsMenu(home: HomeModel, doc: Document, renaming: Obs resourceType: 'document', resourceId: doc.id, linkToCopy: urlState().makeUrl(docUrl(doc)), + reload: () => api.getDocAccess(doc.id), + appModel: home.app, }); } diff --git a/app/client/ui/ShareMenu.ts b/app/client/ui/ShareMenu.ts index 57cd2bc2..f1c00242 100644 --- a/app/client/ui/ShareMenu.ts +++ b/app/client/ui/ShareMenu.ts @@ -245,9 +245,11 @@ async function manageUsers(doc: DocInfo, docPageModel: DocPageModel) { resourceType: 'document', resourceId: doc.id, docPageModel, + appModel: docPageModel.appModel, linkToCopy: urlState().makeUrl(docUrl(doc)), // On save, re-fetch the document info, to toggle the "Public Access" icon if it changed. onSave: () => docPageModel.refreshCurrentDoc(doc), + reload: () => api.getDocAccess(doc.id), }); } diff --git a/app/client/ui/UserItem.ts b/app/client/ui/UserItem.ts index f5c85297..0170f809 100644 --- a/app/client/ui/UserItem.ts +++ b/app/client/ui/UserItem.ts @@ -13,13 +13,14 @@ import {cssMenuItem} from 'popweasel'; // cssMemberText( // cssMemberPrimary(NAME), // cssMemberSecondary(EMAIL), +// cssMemberType(DESCRIPTION), // ) // ) export const cssMemberListItem = styled('div', ` display: flex; width: 460px; - height: 64px; + min-height: 64px; margin: 0 auto; padding: 12px 0; `); @@ -74,6 +75,32 @@ export const cssMemberSecondary = styled('span', ` } `); +export const cssMemberType = 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 cssMemberTypeProblem = styled('span', ` + color: ${colors.error}; + /* 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; diff --git a/app/client/ui/UserManager.ts b/app/client/ui/UserManager.ts index a696782c..1afab767 100644 --- a/app/client/ui/UserManager.ts +++ b/app/client/ui/UserManager.ts @@ -5,16 +5,18 @@ * * It can be instantiated by calling showUserManagerModal with the UserAPI and IUserManagerOptions. */ +import {commonUrls} from 'app/common/gristUrls'; import {FullUser} from 'app/common/LoginSessionAPI'; import * as roles from 'app/common/roles'; -import {tbind} from 'app/common/tbind'; import {PermissionData, UserAPI} from 'app/common/UserAPI'; import {computed, Computed, Disposable, observable, Observable} from 'grainjs'; import {dom, DomElementArg, styled} from 'grainjs'; +import pick = require('lodash/pick'); import {cssMenuItem} from 'popweasel'; import {copyToClipboard} from 'app/client/lib/copyToClipboard'; import {setTestState} from 'app/client/lib/testState'; +import {AppModel} from 'app/client/models/AppModel'; import {DocPageModel} from 'app/client/models/DocPageModel'; import {reportError} from 'app/client/models/errors'; import {urlState} from 'app/client/models/gristUrlState'; @@ -25,7 +27,8 @@ import {shadowScroll} from 'app/client/ui/shadowScroll'; import {showTransientTooltip} from 'app/client/ui/tooltips'; import {createUserImage, cssUserImage} from 'app/client/ui/UserImage'; import {cssEmailInput, cssEmailInputContainer, cssMailIcon, cssMemberBtn, cssMemberImage, cssMemberListItem, - cssMemberPrimary, cssMemberSecondary, cssMemberText, cssRemoveIcon} from 'app/client/ui/UserItem'; + cssMemberPrimary, cssMemberSecondary, cssMemberText, cssMemberType, cssMemberTypeProblem, + cssRemoveIcon} from 'app/client/ui/UserItem'; import {basicButton, bigBasicButton, bigPrimaryButton} from 'app/client/ui2018/buttons'; import {colors, testId, vars} from 'app/client/ui2018/cssVars'; import {icon} from 'app/client/ui2018/icons'; @@ -39,16 +42,21 @@ export interface IUserManagerOptions { resourceType: ResourceType; resourceId: string|number; docPageModel?: DocPageModel; + appModel?: AppModel; // If present, we offer access to a nested team-level dialog. linkToCopy?: string; + reload?: () => Promise; onSave?: () => Promise; + prompt?: { // If set, user manager should open with this email filled in and ready to go. + email: string; + }; } // Returns an instance of UserManagerModel given IUserManagerOptions. Makes the async call for the // required properties of the options. async function getModel(options: IUserManagerOptions): Promise { const permissionData = await options.permissionData; - return new UserManagerModelImpl(permissionData, options.resourceType, options.activeEmail, - options.docPageModel); + return new UserManagerModelImpl(permissionData, options.resourceType, + pick(options, ['activeEmail', 'reload', 'appModel', 'docPageModel'])); } /** @@ -94,7 +102,9 @@ export function showUserManagerModal(userApi: UserAPI, options: IUserManagerOpti cssModalBody( cssUserManagerBody( // TODO: Show a loading indicator before the model is loaded. - dom.maybe(modelObs, model => new UserManager(model, options.linkToCopy).buildDom()), + dom.maybe(modelObs, model => new UserManager( + model, pick(options, 'linkToCopy', 'docPageModel', 'appModel', 'prompt') + ).buildDom()), ), ), cssModalButtons( @@ -129,16 +139,23 @@ export function showUserManagerModal(userApi: UserAPI, options: IUserManagerOpti * um.buildDom(); */ export class UserManager extends Disposable { - constructor(private _model: UserManagerModel, private _linkToCopy: string|undefined) { + private _dom: HTMLDivElement; + constructor(private _model: UserManagerModel, private _options: { + linkToCopy?: string, + docPageModel?: DocPageModel, + appModel?: AppModel, + prompt?: {email: string} + }) { super(); } public buildDom() { - const memberEmail = this.autoDispose(new MemberEmail(tbind(this._model.add, this._model))); + const memberEmail = this.autoDispose(new MemberEmail(this._onAdd.bind(this), + this._options.prompt)); return [ memberEmail.buildDom(), this._buildOptionsDom(), - shadowScroll( + this._dom = shadowScroll( testId('um-members'), this._buildPublicAccessMember(), dom.forEach(this._model.membersEdited, (member) => this._buildMemberDom(member)), @@ -146,6 +163,14 @@ export class UserManager extends Disposable { ]; } + 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(); + } + private _buildOptionsDom(): Element { const publicMember = this._model.publicMember; return cssOptionRow( @@ -186,13 +211,14 @@ export class UserManager extends Disposable { dom.autoDispose(disableRemove), dom.maybe((use) => use(member.effectiveAccess) && use(member.effectiveAccess) !== roles.GUEST, () => cssMemberListItem( - cssMemberListItem.cls('-removed', (use) => member.isRemoved), + cssMemberListItem.cls('-removed', member.isRemoved), cssMemberImage( createUserImage(getFullUser(member), 'large') ), cssMemberText( - cssMemberPrimary(member.name || dom('span', member.email, testId('um-email'))), - member.name ? cssMemberSecondary(member.email, testId('um-email')) : null + 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), ), member.isRemoved ? null : this._memberRoleSelector(member.effectiveAccess, member.inheritedAccess, this._model.isActiveUser(member)), @@ -218,6 +244,49 @@ export class UserManager extends Disposable { ); } + // 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; + }); + } + private _buildPublicAccessMember() { const publicMember = this._model.publicMember; if (!publicMember) { return null; } @@ -227,7 +296,7 @@ export class UserManager extends Disposable { cssPublicMemberIcon('PublicFilled'), cssMemberText( cssMemberPrimary('Public Access'), - cssMemberSecondary('Anyone with link ', makeCopyBtn(this._linkToCopy)), + cssMemberSecondary('Anyone with link ', makeCopyBtn(this._options.linkToCopy)), ), this._memberRoleSelector(publicMember.effectiveAccess, publicMember.inheritedAccess, false, // Only show the Editor and Viewer options for the role of the "Public Access" member. @@ -325,16 +394,20 @@ export class MemberEmail extends Disposable { private _emailElem: HTMLInputElement; constructor( - private _onAdd: (email: string, role: roles.NonGuestRole) => void + private _onAdd: (email: string, role: roles.NonGuestRole) => void, + private _prompt?: {email: string}, ) { super(); + if (_prompt) { + this.email.set(_prompt.email); + } // Reset custom validity that we sometimes set. this.email.addListener(() => this._emailElem.setCustomValidity("")); } public buildDom(): Element { const enableAdd: Computed = computed((use) => Boolean(use(this.email) && use(this._isValid))); - return cssEmailInputContainer( + const result = cssEmailInputContainer( dom.autoDispose(enableAdd), cssMailIcon('Mail'), this._emailElem = cssEmailInput(this.email, {onInput: true, isValid: this._isValid}, @@ -366,6 +439,10 @@ export class MemberEmail extends Disposable { cssEmailInputContainer.cls('-green', enableAdd), testId('um-member-new') ); + if (this._prompt) { + this._emailElem.dispatchEvent(new Event('input', { bubbles: true })); + } + return result; } // Add the currently entered email if valid, or trigger a validation message if not. @@ -417,6 +494,25 @@ async function copyLink(elem: HTMLElement, link: string) { showTransientTooltip(elem, 'Link copied to clipboard', {key: 'copy-doc-link'}); } +async function manageTeam(appModel: AppModel, + onSave?: () => Promise, + 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, + }); + } +} + const cssUserManagerBody = styled('div', ` display: flex; flex-direction: column; diff --git a/app/common/ShareAnnotator.ts b/app/common/ShareAnnotator.ts new file mode 100644 index 00000000..cf13289f --- /dev/null +++ b/app/common/ShareAnnotator.ts @@ -0,0 +1,90 @@ +import { normalizeEmail } from 'app/common/emails'; +import { Features } from 'app/common/Features'; +import { PermissionData, PermissionDelta } from 'app/common/UserAPI'; + +/** + * Mark that the share is share number #at of a maximum of #top. The #at values + * start at 1. + */ +export interface ShareLimitAnnotation { + at: number; + top?: number; +} + +/** + * Some facts about a share. + */ +export interface ShareAnnotation { + isMember?: boolean; // Is the share for a team member. + isSupport?: boolean; // Is the share for a support user. + collaboratorLimit?: ShareLimitAnnotation; // Does the share count towards a collaborator limit. +} + +/** + * Facts about all shares for a resource. + */ +export interface ShareAnnotations { + hasTeam?: boolean; // Is the resource in a team site? + users: Map; // Annotations keyed by normalized user email. +} + +/** + * Helper for annotating users mentioned in a proposed change of shares, given the + * current shares in place. + */ +export class ShareAnnotator { + constructor(private _features: Features, private _state: PermissionData) { + } + + public updateState(state: PermissionData) { + this._state = state; + } + + public annotateChanges(change: PermissionDelta): ShareAnnotations { + const features = this._features; + const annotations: ShareAnnotations = { + hasTeam: features.maxWorkspacesPerOrg !== 1, + users: new Map(), + }; + if (features.maxSharesPerDocPerRole || features.maxSharesPerWorkspace) { + // For simplicity, don't try to annotate if limits not used at the time of writing + // are in place. + return annotations; + } + const top = features.maxSharesPerDoc; + let at = 0; + const makeAnnotation = (user: {email: string, isMember?: boolean, access: string|null}) => { + const annotation: ShareAnnotation = { + isMember: user.isMember, + }; + if (user.email === 'support@getgrist.com') { + return { isSupport: true }; + } + if (!annotation.isMember && user.access) { + at++; + annotation.collaboratorLimit = { + at, + top + }; + } + return annotation; + }; + const removed = new Set( + Object.entries(change?.users||{}).filter(([, v]) => v === null) + .map(([k, ]) => normalizeEmail(k))); + for (const user of this._state.users) { + if (removed.has(user.email)) { continue; } + annotations.users.set(user.email, makeAnnotation(user)); + } + const tweaks = new Set( + Object.entries(change?.users||{}).filter(([, v]) => v !== null) + .map(([k, ]) => normalizeEmail(k))); + for (const email of tweaks) { + const annotation = annotations.users.get(email) || makeAnnotation({ + email, isMember: false, access: '', + }); + annotations.users.set(email, annotation); + } + return annotations; + } +} diff --git a/app/common/UserAPI.ts b/app/common/UserAPI.ts index ed95b6b4..2428fe9a 100644 --- a/app/common/UserAPI.ts +++ b/app/common/UserAPI.ts @@ -168,7 +168,9 @@ export interface UserAccessData { // access to the resource. Lack of access to the parent resource is represented by a null value. // If parent has non-inheritable access, this should be null. parentAccess?: roles.BasicRole|null; + orgAccess?: roles.BasicRole|null; anonymous?: boolean; // If set to true, the user is the anonymous user. + isMember?: boolean; } /** diff --git a/app/common/gristUrls.ts b/app/common/gristUrls.ts index b0f38170..b05b0ce0 100644 --- a/app/common/gristUrls.ts +++ b/app/common/gristUrls.ts @@ -65,6 +65,7 @@ export interface IGristUrlState { welcome?: WelcomePage; welcomeTour?: boolean; docTour?: boolean; + manageUsers?: boolean; params?: { billingPlan?: string; billingTask?: BillingTask; @@ -214,6 +215,8 @@ export function encodeUrl(gristConfig: Partial, url.hash = 'repeat-welcome-tour'; } else if (state.docTour) { url.hash = 'repeat-doc-tour'; + } else if (state.manageUsers) { + url.hash = 'manage-users'; } else { url.hash = ''; } @@ -315,6 +318,7 @@ export function decodeUrl(gristConfig: Partial, location: Locat } state.welcomeTour = hashMap.get('#') === 'repeat-welcome-tour'; state.docTour = hashMap.get('#') === 'repeat-doc-tour'; + state.manageUsers = hashMap.get('#') === 'manage-users'; } return state; } diff --git a/app/gen-server/lib/HomeDBManager.ts b/app/gen-server/lib/HomeDBManager.ts index 0eea5930..1b0fa9bb 100644 --- a/app/gen-server/lib/HomeDBManager.ts +++ b/app/gen-server/lib/HomeDBManager.ts @@ -2054,18 +2054,24 @@ export class HomeDBManager extends EventEmitter { const wsMap = getMemberUserRoles(doc.workspace, this.defaultBasicGroupNames); // The orgMap gives the org access inherited by each user. const orgMap = getMemberUserRoles(doc.workspace.org, this.defaultBasicGroupNames); + // The orgMapWithMembership gives the full access to the org for each user, including + // the "members" level, which grants no default inheritable access but allows the user + // to be added freely to workspaces and documents. + const orgMapWithMembership = getMemberUserRoles(doc.workspace.org, this.defaultGroupNames); const wsMaxInheritedRole = this._getMaxInheritedRole(doc.workspace); // Iterate through the org since all users will be in the org. let users: UserAccessData[] = getResourceUsers([doc, doc.workspace, doc.workspace.org]).map(u => { // Merge the strongest roles from the resource and parent resources. Note that the parent // resource access levels must be tempered by the maxInheritedRole values of their children. const inheritFromOrg = roles.getWeakestRole(orgMap[u.id] || null, wsMaxInheritedRole); + const orgAccess = orgMapWithMembership[u.id] || null; return { ...this.makeFullUser(u), access: docMap[u.id] || null, parentAccess: roles.getEffectiveRole( roles.getStrongestRole(wsMap[u.id] || null, inheritFromOrg) - ) + ), + isMember: orgAccess !== 'guests' && orgAccess !== null, }; }); let maxInheritedRole = this._getMaxInheritedRole(doc);