gristlabs_grist-core/app/client/models/UserManagerModel.ts
Paul Fitzpatrick f7c9919120 (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
2021-10-25 14:29:19 -04:00

372 lines
16 KiB
TypeScript

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';
import {ANONYMOUS_USER_EMAIL, EVERYONE_EMAIL, PermissionData, PermissionDelta, UserAPI} from 'app/common/UserAPI';
import {getRealAccess} from 'app/common/UserAPI';
import {computed, Computed, Disposable, obsArray, ObsArray, observable, Observable} from 'grainjs';
import some = require('lodash/some');
export interface UserManagerModel {
initData: PermissionData; // PermissionData used to initialize the UserManager
resourceType: ResourceType; // String representing the access resource
userSelectOptions: IMemberSelectOption[]; // Select options for each user's role dropdown
orgUserSelectOptions: IOrgMemberSelectOption[]; // Select options for each user's role dropdown on the org
inheritSelectOptions: IMemberSelectOption[]; // Select options for the maxInheritedRole dropdown
maxInheritedRole: Observable<roles.BasicRole|null>; // Current unsaved maxInheritedRole setting
membersEdited: ObsArray<IEditableMember>; // Current unsaved editable array of members
publicMember: IEditableMember|null; // Member whose access (VIEWER or null) represents that of
// 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
add(email: string, role: roles.Role|null): void;
// Removes a member from membersEdited
remove(member: IEditableMember): void;
// Returns a boolean indicating if the member is the currently active user.
isActiveUser(member: IEditableMember): boolean;
// Returns the PermissionDelta reflecting the current unsaved changes in the model.
getDelta(): PermissionDelta;
}
export type ResourceType = 'organization'|'workspace'|'document';
export interface IEditableMember {
id: number; // Newly invited members do not have ids and are represented by -1
name: string;
email: string;
picture?: string|null;
access: Observable<roles.Role|null>;
parentAccess: roles.BasicRole|null;
inheritedAccess: Computed<roles.BasicRole|null>;
effectiveAccess: Computed<roles.Role|null>;
origAccess: roles.Role|null;
isNew: boolean;
isRemoved: boolean;
}
// An option for the select elements used in the UserManager.
export interface IMemberSelectOption {
value: roles.BasicRole|null;
label: string;
}
// An option for the organization select elements used in the UserManager.
export interface IOrgMemberSelectOption {
value: roles.NonGuestRole|null;
label: string;
}
interface IBuildMemberOptions {
id: number;
name: string;
email: string;
picture?: string|null;
access: roles.Role|null;
parentAccess: roles.BasicRole|null;
}
/**
*
*/
export class UserManagerModelImpl extends Disposable implements UserManagerModel {
// Select options for each individual user's role dropdown.
public readonly userSelectOptions: IMemberSelectOption[] = [
{ value: roles.OWNER, label: 'Owner' },
{ value: roles.EDITOR, label: 'Editor' },
{ value: roles.VIEWER, label: 'Viewer' }
];
// Select options for each individual user's role dropdown in the org.
public readonly orgUserSelectOptions: IOrgMemberSelectOption[] = [
{ value: roles.OWNER, label: 'Owner' },
{ value: roles.EDITOR, label: 'Editor' },
{ value: roles.VIEWER, label: 'Viewer' },
{ value: roles.MEMBER, label: 'No Default Access' },
];
// Select options for the resource's maxInheritedRole dropdown.
public readonly inheritSelectOptions: IMemberSelectOption[] = [
{ value: roles.OWNER, label: 'In Full' },
{ value: roles.EDITOR, label: 'View & Edit' },
{ value: roles.VIEWER, label: 'View Only' },
{ value: null, label: 'None' }
];
public maxInheritedRole: Observable<roles.BasicRole|null> =
observable(this.initData.maxInheritedRole || null);
// The public member's access settings reflect either those of anonymous users (when
// shouldSupportAnon() is true) or those of everyone@ (i.e. access granted to all users,
// supported for docs only). The member is null when public access is not supported.
public publicMember: IEditableMember|null = this._buildPublicMember();
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) => {
const isMemberChangedFn = (m: IEditableMember) => m.isNew || m.isRemoved ||
use(m.access) !== m.origAccess;
const isInheritanceChanged = !this.isOrg && use(this.maxInheritedRole) !== this.initData.maxInheritedRole;
return some(use(this.membersEdited), isMemberChangedFn) || isInheritanceChanged ||
(this.publicMember ? isMemberChangedFn(this.publicMember) : false);
}));
constructor(
public initData: PermissionData,
public resourceType: ResourceType,
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> {
if (this.resourceType === 'organization') {
await userApi.updateOrgPermissions(resourceId as number, this.getDelta());
} else if (this.resourceType === 'workspace') {
await userApi.updateWorkspacePermissions(resourceId as number, this.getDelta());
} else if (this.resourceType === 'document') {
await userApi.updateDocPermissions(resourceId as string, this.getDelta());
}
}
public add(email: string, role: roles.Role|null): void {
email = normalizeEmail(email);
const members = this.membersEdited.get();
const index = members.findIndex((m) => m.email === email);
const existing = index > -1 ? members[index] : null;
if (existing && existing.isRemoved) {
// The member is replaced with the isRemoved set to false to trigger an
// update to the membersEdited observable array.
this.membersEdited.splice(index, 1, {...existing, isRemoved: false});
} else if (existing) {
const effective = existing.effectiveAccess.get();
if (effective && effective !== roles.GUEST) {
// If the member is visible, throw to inform the user.
throw new Error("This user is already in the list");
}
// If the member exists but is not visible, update their access to make them visible.
// They should be treated as a new user - removing them should make them invisible again.
existing.access.set(role);
existing.isNew = true;
} else {
const newMember = this._buildEditableMember({
id: -1, // Use a placeholder for the unknown userId
email,
name: "",
access: role,
parentAccess: null
});
newMember.isNew = true;
this.membersEdited.push(newMember);
}
this.annotate();
}
public remove(member: IEditableMember): void {
const index = this.membersEdited.get().indexOf(member);
if (member.isNew) {
this.membersEdited.splice(index, 1);
} else {
// 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._options.activeEmail;
}
// 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();
if (this.initData.maxInheritedRole !== maxInheritedRole) {
delta.maxInheritedRole = maxInheritedRole;
}
}
const members = [...this.membersEdited.get()];
if (this.publicMember) {
members.push(this.publicMember);
}
// Loop through the members and update the delta.
for (const m of members) {
let access = m.access.get();
if (m === this.publicMember && access === roles.EDITOR &&
this._options.docPageModel?.gristDoc.get()?.hasGranularAccessRules()) {
access = roles.VIEWER;
if (!options?.silent) {
reportWarning('Public "Editor" access is incompatible with Access Rules. Reduced to "Viewer".');
}
}
if (!roles.isValidRole(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.
delta.users![m.email] = m.isRemoved ? null : access as roles.NonGuestRole;
}
}
return delta;
}
private _buildAllMembers(): IEditableMember[] {
// If the UI supports some public access, strip the supported public user (anon@ or
// everyone@). Otherwise, keep it, to allow the non-fancy way of adding/removing public access.
let users = this.initData.users;
const publicMember = this.publicMember;
if (publicMember) {
users = users.filter(m => m.email !== publicMember.email);
}
return users.map(m =>
this._buildEditableMember({
id: m.id,
email: m.email,
name: m.name,
picture: m.picture,
access: m.access,
parentAccess: m.parentAccess || null,
})
);
}
private _buildPublicMember(): IEditableMember|null {
// shouldSupportAnon() changes "public" access to "anonymous" access, and enables it for
// workspaces and org level. It's currently used for on-premise installs only.
// TODO Think through proper public sharing or workspaces/orgs, and get rid of
// shouldSupportAnon() exceptions.
const email =
shouldSupportAnon() ? ANONYMOUS_USER_EMAIL :
(this.resourceType === 'document') ? EVERYONE_EMAIL : null;
if (!email) { return null; }
const user = this.initData.users.find(m => m.email === email);
return this._buildEditableMember({
id: user ? user.id : -1,
email,
name: "",
access: user ? user.access : null,
parentAccess: user ? (user.parentAccess || null) : null,
});
}
private _buildEditableMember(member: IBuildMemberOptions): IEditableMember {
// Represents the member's access set specifically on the resource of interest.
const access = Observable.create(this, member.access);
let inheritedAccess: Computed<roles.BasicRole|null>;
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
// open UserManager by a less-privileged user, e.g. if access was just lowered, in which
// case any attempted changes will fail on saving.)
const initialAccessBasicRole = roles.getEffectiveRole(getRealAccess(member, this.initData));
// This pretends to be a computed to match the other case, but is really a constant.
inheritedAccess = Computed.create(this, (use) => initialAccessBasicRole);
} else {
// Gives the role inherited from parent taking the maxInheritedRole into account.
inheritedAccess = Computed.create(this, this.maxInheritedRole, (use, maxInherited) =>
roles.getWeakestRole(member.parentAccess, maxInherited));
}
// Gives the effective role of the member on the resource, taking everything into account.
const effectiveAccess = Computed.create(this, (use) =>
roles.getStrongestRole(use(access), use(inheritedAccess)));
effectiveAccess.onWrite((value) => {
// For UI simplicity, we use a single dropdown to represent the effective access level of
// the user AND to allow changing it. As a result, we do NOT allow using the dropdown to
// write/show values that provide less direct access than what the user already inherits.
// It is confusing to show and results in no change in the effective access.
const inherited = inheritedAccess.get();
const isAboveInherit = roles.getStrongestRole(value, inherited) !== inherited;
access.set(isAboveInherit ? value : null);
});
return {
id: member.id,
email: member.email,
name: member.name,
picture: member.picture,
access,
parentAccess: member.parentAccess || null,
inheritedAccess,
effectiveAccess,
origAccess: member.access,
isNew: false,
isRemoved: false,
};
}
}
export function getResourceParent(resource: ResourceType): ResourceType|null {
if (resource === 'workspace') {
return 'organization';
} else if (resource === 'document') {
return 'workspace';
} else {
return null;
}
}
// Check whether anon should be supported in the UI
export function shouldSupportAnon(): boolean {
const gristConfig: GristLoadConfig = (window as any).gristConfig || {};
return gristConfig.supportAnon || false;
}