mirror of
https://github.com/gristlabs/grist-core.git
synced 2024-10-27 20:44:07 +00:00
f7c9919120
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
372 lines
16 KiB
TypeScript
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;
|
|
}
|