mirror of
				https://github.com/gristlabs/grist-core.git
				synced 2025-06-13 20:53:59 +00:00 
			
		
		
		
	(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
This commit is contained in:
		
							parent
							
								
									f2f4fe0eca
								
							
						
					
					
						commit
						f7c9919120
					
				@ -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<boolean>;        // Indicates whether there are unsaved changes
 | 
			
		||||
  isOrg: boolean;                              // Indicates if the UserManager is for an org
 | 
			
		||||
  annotations: Observable<ShareAnnotations>;   // 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<void>;
 | 
			
		||||
  // Writes all unsaved changes to the server.
 | 
			
		||||
  save(userApi: UserAPI, resourceId: number|string): Promise<void>;
 | 
			
		||||
  // Adds a member to membersEdited
 | 
			
		||||
@ -107,8 +112,12 @@ export class UserManagerModelImpl extends Disposable implements UserManagerModel
 | 
			
		||||
 | 
			
		||||
  public membersEdited = this.autoDispose(obsArray<IEditableMember>(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<boolean> = this.autoDispose(computed<boolean>((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<PermissionData>,
 | 
			
		||||
      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<void> {
 | 
			
		||||
    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<void> {
 | 
			
		||||
@ -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<roles.BasicRole|null>;
 | 
			
		||||
 | 
			
		||||
    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
 | 
			
		||||
 | 
			
		||||
@ -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,
 | 
			
		||||
    });
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
@ -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),
 | 
			
		||||
  });
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
@ -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;
 | 
			
		||||
 | 
			
		||||
@ -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<PermissionData>;
 | 
			
		||||
  onSave?: () => Promise<unknown>;
 | 
			
		||||
  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<UserManagerModelImpl> {
 | 
			
		||||
  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<boolean> = 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<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,
 | 
			
		||||
    });
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
const cssUserManagerBody = styled('div', `
 | 
			
		||||
  display: flex;
 | 
			
		||||
  flex-direction: column;
 | 
			
		||||
 | 
			
		||||
							
								
								
									
										90
									
								
								app/common/ShareAnnotator.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										90
									
								
								app/common/ShareAnnotator.ts
									
									
									
									
									
										Normal file
									
								
							@ -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<string, ShareAnnotation>;  // 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: '<set>',
 | 
			
		||||
      });
 | 
			
		||||
      annotations.users.set(email, annotation);
 | 
			
		||||
    }
 | 
			
		||||
    return annotations;
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
@ -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;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
/**
 | 
			
		||||
 | 
			
		||||
@ -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<GristLoadConfig>,
 | 
			
		||||
    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<GristLoadConfig>, location: Locat
 | 
			
		||||
    }
 | 
			
		||||
    state.welcomeTour = hashMap.get('#') === 'repeat-welcome-tour';
 | 
			
		||||
    state.docTour = hashMap.get('#') === 'repeat-doc-tour';
 | 
			
		||||
    state.manageUsers = hashMap.get('#') === 'manage-users';
 | 
			
		||||
  }
 | 
			
		||||
  return state;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
@ -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);
 | 
			
		||||
 | 
			
		||||
		Loading…
	
		Reference in New Issue
	
	Block a user