mirror of
https://github.com/gristlabs/grist-core.git
synced 2024-10-27 20:44:07 +00:00
59699bf446
Summary: Adds links to manage team and go to billing account in the org menu (opened by clicking the dropdown in the top-left corner of Grist). Tweaks some wording of items in both AppHeader and AccountWidget, and adds a link to create a new team site to the Site Switcher in both menus. Also tweaks the UI of UserManager by adding an animation when the manager is opened from the doc access dialog. Test Plan: Browser tests. Reviewers: dsagal Reviewed By: dsagal Subscribers: dsagal Differential Revision: https://phab.getgrist.com/D3121
375 lines
16 KiB
TypeScript
375 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, Organization, PermissionData, PermissionDelta,
|
|
UserAPI, Workspace} 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 type Resource = 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';
|
|
|
|
// 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);
|
|
}));
|
|
|
|
private _shareAnnotator?: ShareAnnotator;
|
|
|
|
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;
|
|
}
|