mirror of
https://github.com/gristlabs/grist-core.git
synced 2024-10-27 20:44:07 +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 {DocPageModel} from 'app/client/models/DocPageModel';
|
||||||
import {reportWarning} from 'app/client/models/errors';
|
import {reportWarning} from 'app/client/models/errors';
|
||||||
|
import {ShareAnnotations, ShareAnnotator} from 'app/common/ShareAnnotator';
|
||||||
import {normalizeEmail} from 'app/common/emails';
|
import {normalizeEmail} from 'app/common/emails';
|
||||||
import {GristLoadConfig} from 'app/common/gristUrls';
|
import {GristLoadConfig} from 'app/common/gristUrls';
|
||||||
import * as roles from 'app/common/roles';
|
import * as roles from 'app/common/roles';
|
||||||
@ -20,9 +22,12 @@ export interface UserManagerModel {
|
|||||||
// anon@ or everyone@ (depending on the settings and resource).
|
// anon@ or everyone@ (depending on the settings and resource).
|
||||||
isAnythingChanged: Computed<boolean>; // Indicates whether there are unsaved changes
|
isAnythingChanged: Computed<boolean>; // Indicates whether there are unsaved changes
|
||||||
isOrg: boolean; // Indicates if the UserManager is for an org
|
isOrg: boolean; // Indicates if the UserManager is for an org
|
||||||
|
annotations: Observable<ShareAnnotations>; // More information about shares, keyed by email.
|
||||||
|
|
||||||
// Resets all unsaved changes
|
// Resets all unsaved changes
|
||||||
reset(): void;
|
reset(): void;
|
||||||
|
// Recreate annotations, factoring in any changes on the back-end.
|
||||||
|
reloadAnnotations(): Promise<void>;
|
||||||
// Writes all unsaved changes to the server.
|
// Writes all unsaved changes to the server.
|
||||||
save(userApi: UserAPI, resourceId: number|string): Promise<void>;
|
save(userApi: UserAPI, resourceId: number|string): Promise<void>;
|
||||||
// Adds a member to membersEdited
|
// 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 membersEdited = this.autoDispose(obsArray<IEditableMember>(this._buildAllMembers()));
|
||||||
|
|
||||||
|
public annotations = this.autoDispose(observable({users: new Map()}));
|
||||||
|
|
||||||
public isOrg: boolean = this.resourceType === 'organization';
|
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
|
// 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.
|
// 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) => {
|
public readonly isAnythingChanged: Computed<boolean> = this.autoDispose(computed<boolean>((use) => {
|
||||||
@ -122,14 +131,37 @@ export class UserManagerModelImpl extends Disposable implements UserManagerModel
|
|||||||
constructor(
|
constructor(
|
||||||
public initData: PermissionData,
|
public initData: PermissionData,
|
||||||
public resourceType: ResourceType,
|
public resourceType: ResourceType,
|
||||||
private _activeUserEmail: string|null,
|
private _options: {
|
||||||
private _docPageModel?: DocPageModel,
|
activeEmail?: string|null,
|
||||||
|
reload?: () => Promise<PermissionData>,
|
||||||
|
docPageModel?: DocPageModel,
|
||||||
|
appModel?: AppModel,
|
||||||
|
}
|
||||||
) {
|
) {
|
||||||
super();
|
super();
|
||||||
|
if (this._options.appModel) {
|
||||||
|
const features = this._options.appModel.currentFeatures;
|
||||||
|
this._shareAnnotator = new ShareAnnotator(features, initData);
|
||||||
|
}
|
||||||
|
this.annotate();
|
||||||
}
|
}
|
||||||
|
|
||||||
public reset(): void {
|
public reset(): void {
|
||||||
this.membersEdited.set(this._buildAllMembers());
|
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> {
|
public async save(userApi: UserAPI, resourceId: number|string): Promise<void> {
|
||||||
@ -172,6 +204,7 @@ export class UserManagerModelImpl extends Disposable implements UserManagerModel
|
|||||||
newMember.isNew = true;
|
newMember.isNew = true;
|
||||||
this.membersEdited.push(newMember);
|
this.membersEdited.push(newMember);
|
||||||
}
|
}
|
||||||
|
this.annotate();
|
||||||
}
|
}
|
||||||
|
|
||||||
public remove(member: IEditableMember): void {
|
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.
|
// Keep it in the array with a flag, to simplify comparing "before" and "after" arrays.
|
||||||
this.membersEdited.splice(index, 1, {...member, isRemoved: true});
|
this.membersEdited.splice(index, 1, {...member, isRemoved: true});
|
||||||
}
|
}
|
||||||
|
this.annotate();
|
||||||
}
|
}
|
||||||
|
|
||||||
public isActiveUser(member: IEditableMember): boolean {
|
public isActiveUser(member: IEditableMember): boolean {
|
||||||
return member.email === this._activeUserEmail;
|
return member.email === this._options.activeEmail;
|
||||||
}
|
}
|
||||||
|
|
||||||
public getDelta(): PermissionDelta {
|
// Analyze the relation that users have to the resource or site.
|
||||||
// Construct the permission delta from the changed users/maxInheritedRole.
|
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: {} };
|
const delta: PermissionDelta = { users: {} };
|
||||||
if (this.resourceType !== 'organization') {
|
if (this.resourceType !== 'organization') {
|
||||||
const maxInheritedRole = this.maxInheritedRole.get();
|
const maxInheritedRole = this.maxInheritedRole.get();
|
||||||
@ -205,12 +248,17 @@ export class UserManagerModelImpl extends Disposable implements UserManagerModel
|
|||||||
for (const m of members) {
|
for (const m of members) {
|
||||||
let access = m.access.get();
|
let access = m.access.get();
|
||||||
if (m === this.publicMember && access === roles.EDITOR &&
|
if (m === this.publicMember && access === roles.EDITOR &&
|
||||||
this._docPageModel?.gristDoc.get()?.hasGranularAccessRules()) {
|
this._options.docPageModel?.gristDoc.get()?.hasGranularAccessRules()) {
|
||||||
access = roles.VIEWER;
|
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)) {
|
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) {
|
if (m.isNew || m.isRemoved || m.origAccess !== access) {
|
||||||
// Add users whose access changed.
|
// Add users whose access changed.
|
||||||
@ -264,7 +312,7 @@ export class UserManagerModelImpl extends Disposable implements UserManagerModel
|
|||||||
const access = Observable.create(this, member.access);
|
const access = Observable.create(this, member.access);
|
||||||
let inheritedAccess: Computed<roles.BasicRole|null>;
|
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
|
// 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
|
// 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
|
// 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',
|
resourceType: 'document',
|
||||||
resourceId: doc.id,
|
resourceId: doc.id,
|
||||||
linkToCopy: urlState().makeUrl(docUrl(doc)),
|
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',
|
resourceType: 'document',
|
||||||
resourceId: doc.id,
|
resourceId: doc.id,
|
||||||
docPageModel,
|
docPageModel,
|
||||||
|
appModel: docPageModel.appModel,
|
||||||
linkToCopy: urlState().makeUrl(docUrl(doc)),
|
linkToCopy: urlState().makeUrl(docUrl(doc)),
|
||||||
// On save, re-fetch the document info, to toggle the "Public Access" icon if it changed.
|
// On save, re-fetch the document info, to toggle the "Public Access" icon if it changed.
|
||||||
onSave: () => docPageModel.refreshCurrentDoc(doc),
|
onSave: () => docPageModel.refreshCurrentDoc(doc),
|
||||||
|
reload: () => api.getDocAccess(doc.id),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -13,13 +13,14 @@ import {cssMenuItem} from 'popweasel';
|
|||||||
// cssMemberText(
|
// cssMemberText(
|
||||||
// cssMemberPrimary(NAME),
|
// cssMemberPrimary(NAME),
|
||||||
// cssMemberSecondary(EMAIL),
|
// cssMemberSecondary(EMAIL),
|
||||||
|
// cssMemberType(DESCRIPTION),
|
||||||
// )
|
// )
|
||||||
// )
|
// )
|
||||||
|
|
||||||
export const cssMemberListItem = styled('div', `
|
export const cssMemberListItem = styled('div', `
|
||||||
display: flex;
|
display: flex;
|
||||||
width: 460px;
|
width: 460px;
|
||||||
height: 64px;
|
min-height: 64px;
|
||||||
margin: 0 auto;
|
margin: 0 auto;
|
||||||
padding: 12px 0;
|
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', `
|
export const cssMemberBtn = styled('div', `
|
||||||
width: 16px;
|
width: 16px;
|
||||||
height: 16px;
|
height: 16px;
|
||||||
|
@ -5,16 +5,18 @@
|
|||||||
*
|
*
|
||||||
* It can be instantiated by calling showUserManagerModal with the UserAPI and IUserManagerOptions.
|
* 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 {FullUser} from 'app/common/LoginSessionAPI';
|
||||||
import * as roles from 'app/common/roles';
|
import * as roles from 'app/common/roles';
|
||||||
import {tbind} from 'app/common/tbind';
|
|
||||||
import {PermissionData, UserAPI} from 'app/common/UserAPI';
|
import {PermissionData, UserAPI} from 'app/common/UserAPI';
|
||||||
import {computed, Computed, Disposable, observable, Observable} from 'grainjs';
|
import {computed, Computed, Disposable, observable, Observable} from 'grainjs';
|
||||||
import {dom, DomElementArg, styled} from 'grainjs';
|
import {dom, DomElementArg, styled} from 'grainjs';
|
||||||
|
import pick = require('lodash/pick');
|
||||||
import {cssMenuItem} from 'popweasel';
|
import {cssMenuItem} from 'popweasel';
|
||||||
|
|
||||||
import {copyToClipboard} from 'app/client/lib/copyToClipboard';
|
import {copyToClipboard} from 'app/client/lib/copyToClipboard';
|
||||||
import {setTestState} from 'app/client/lib/testState';
|
import {setTestState} from 'app/client/lib/testState';
|
||||||
|
import {AppModel} from 'app/client/models/AppModel';
|
||||||
import {DocPageModel} from 'app/client/models/DocPageModel';
|
import {DocPageModel} from 'app/client/models/DocPageModel';
|
||||||
import {reportError} from 'app/client/models/errors';
|
import {reportError} from 'app/client/models/errors';
|
||||||
import {urlState} from 'app/client/models/gristUrlState';
|
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 {showTransientTooltip} from 'app/client/ui/tooltips';
|
||||||
import {createUserImage, cssUserImage} from 'app/client/ui/UserImage';
|
import {createUserImage, cssUserImage} from 'app/client/ui/UserImage';
|
||||||
import {cssEmailInput, cssEmailInputContainer, cssMailIcon, cssMemberBtn, cssMemberImage, cssMemberListItem,
|
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 {basicButton, bigBasicButton, bigPrimaryButton} from 'app/client/ui2018/buttons';
|
||||||
import {colors, testId, vars} from 'app/client/ui2018/cssVars';
|
import {colors, testId, vars} from 'app/client/ui2018/cssVars';
|
||||||
import {icon} from 'app/client/ui2018/icons';
|
import {icon} from 'app/client/ui2018/icons';
|
||||||
@ -39,16 +42,21 @@ export interface IUserManagerOptions {
|
|||||||
resourceType: ResourceType;
|
resourceType: ResourceType;
|
||||||
resourceId: string|number;
|
resourceId: string|number;
|
||||||
docPageModel?: DocPageModel;
|
docPageModel?: DocPageModel;
|
||||||
|
appModel?: AppModel; // If present, we offer access to a nested team-level dialog.
|
||||||
linkToCopy?: string;
|
linkToCopy?: string;
|
||||||
|
reload?: () => Promise<PermissionData>;
|
||||||
onSave?: () => Promise<unknown>;
|
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
|
// Returns an instance of UserManagerModel given IUserManagerOptions. Makes the async call for the
|
||||||
// required properties of the options.
|
// required properties of the options.
|
||||||
async function getModel(options: IUserManagerOptions): Promise<UserManagerModelImpl> {
|
async function getModel(options: IUserManagerOptions): Promise<UserManagerModelImpl> {
|
||||||
const permissionData = await options.permissionData;
|
const permissionData = await options.permissionData;
|
||||||
return new UserManagerModelImpl(permissionData, options.resourceType, options.activeEmail,
|
return new UserManagerModelImpl(permissionData, options.resourceType,
|
||||||
options.docPageModel);
|
pick(options, ['activeEmail', 'reload', 'appModel', 'docPageModel']));
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -94,7 +102,9 @@ export function showUserManagerModal(userApi: UserAPI, options: IUserManagerOpti
|
|||||||
cssModalBody(
|
cssModalBody(
|
||||||
cssUserManagerBody(
|
cssUserManagerBody(
|
||||||
// TODO: Show a loading indicator before the model is loaded.
|
// 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(
|
cssModalButtons(
|
||||||
@ -129,16 +139,23 @@ export function showUserManagerModal(userApi: UserAPI, options: IUserManagerOpti
|
|||||||
* um.buildDom();
|
* um.buildDom();
|
||||||
*/
|
*/
|
||||||
export class UserManager extends Disposable {
|
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();
|
super();
|
||||||
}
|
}
|
||||||
|
|
||||||
public buildDom() {
|
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 [
|
return [
|
||||||
memberEmail.buildDom(),
|
memberEmail.buildDom(),
|
||||||
this._buildOptionsDom(),
|
this._buildOptionsDom(),
|
||||||
shadowScroll(
|
this._dom = shadowScroll(
|
||||||
testId('um-members'),
|
testId('um-members'),
|
||||||
this._buildPublicAccessMember(),
|
this._buildPublicAccessMember(),
|
||||||
dom.forEach(this._model.membersEdited, (member) => this._buildMemberDom(member)),
|
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 {
|
private _buildOptionsDom(): Element {
|
||||||
const publicMember = this._model.publicMember;
|
const publicMember = this._model.publicMember;
|
||||||
return cssOptionRow(
|
return cssOptionRow(
|
||||||
@ -186,13 +211,14 @@ export class UserManager extends Disposable {
|
|||||||
dom.autoDispose(disableRemove),
|
dom.autoDispose(disableRemove),
|
||||||
dom.maybe((use) => use(member.effectiveAccess) && use(member.effectiveAccess) !== roles.GUEST, () =>
|
dom.maybe((use) => use(member.effectiveAccess) && use(member.effectiveAccess) !== roles.GUEST, () =>
|
||||||
cssMemberListItem(
|
cssMemberListItem(
|
||||||
cssMemberListItem.cls('-removed', (use) => member.isRemoved),
|
cssMemberListItem.cls('-removed', member.isRemoved),
|
||||||
cssMemberImage(
|
cssMemberImage(
|
||||||
createUserImage(getFullUser(member), 'large')
|
createUserImage(getFullUser(member), 'large')
|
||||||
),
|
),
|
||||||
cssMemberText(
|
cssMemberText(
|
||||||
cssMemberPrimary(member.name || dom('span', member.email, testId('um-email'))),
|
cssMemberPrimary(member.name || dom('span', member.email, dom.cls('member-email'), testId('um-email'))),
|
||||||
member.name ? cssMemberSecondary(member.email, testId('um-email')) : null
|
member.name ? cssMemberSecondary(member.email, dom.cls('member-email'), testId('um-email')) : null,
|
||||||
|
this._buildAnnotationDom(member),
|
||||||
),
|
),
|
||||||
member.isRemoved ? null : this._memberRoleSelector(member.effectiveAccess,
|
member.isRemoved ? null : this._memberRoleSelector(member.effectiveAccess,
|
||||||
member.inheritedAccess, this._model.isActiveUser(member)),
|
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() {
|
private _buildPublicAccessMember() {
|
||||||
const publicMember = this._model.publicMember;
|
const publicMember = this._model.publicMember;
|
||||||
if (!publicMember) { return null; }
|
if (!publicMember) { return null; }
|
||||||
@ -227,7 +296,7 @@ export class UserManager extends Disposable {
|
|||||||
cssPublicMemberIcon('PublicFilled'),
|
cssPublicMemberIcon('PublicFilled'),
|
||||||
cssMemberText(
|
cssMemberText(
|
||||||
cssMemberPrimary('Public Access'),
|
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,
|
this._memberRoleSelector(publicMember.effectiveAccess, publicMember.inheritedAccess, false,
|
||||||
// Only show the Editor and Viewer options for the role of the "Public Access" member.
|
// 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;
|
private _emailElem: HTMLInputElement;
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
private _onAdd: (email: string, role: roles.NonGuestRole) => void
|
private _onAdd: (email: string, role: roles.NonGuestRole) => void,
|
||||||
|
private _prompt?: {email: string},
|
||||||
) {
|
) {
|
||||||
super();
|
super();
|
||||||
|
if (_prompt) {
|
||||||
|
this.email.set(_prompt.email);
|
||||||
|
}
|
||||||
// Reset custom validity that we sometimes set.
|
// Reset custom validity that we sometimes set.
|
||||||
this.email.addListener(() => this._emailElem.setCustomValidity(""));
|
this.email.addListener(() => this._emailElem.setCustomValidity(""));
|
||||||
}
|
}
|
||||||
|
|
||||||
public buildDom(): Element {
|
public buildDom(): Element {
|
||||||
const enableAdd: Computed<boolean> = computed((use) => Boolean(use(this.email) && use(this._isValid)));
|
const enableAdd: Computed<boolean> = computed((use) => Boolean(use(this.email) && use(this._isValid)));
|
||||||
return cssEmailInputContainer(
|
const result = cssEmailInputContainer(
|
||||||
dom.autoDispose(enableAdd),
|
dom.autoDispose(enableAdd),
|
||||||
cssMailIcon('Mail'),
|
cssMailIcon('Mail'),
|
||||||
this._emailElem = cssEmailInput(this.email, {onInput: true, isValid: this._isValid},
|
this._emailElem = cssEmailInput(this.email, {onInput: true, isValid: this._isValid},
|
||||||
@ -366,6 +439,10 @@ export class MemberEmail extends Disposable {
|
|||||||
cssEmailInputContainer.cls('-green', enableAdd),
|
cssEmailInputContainer.cls('-green', enableAdd),
|
||||||
testId('um-member-new')
|
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.
|
// 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'});
|
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', `
|
const cssUserManagerBody = styled('div', `
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
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.
|
// 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.
|
// If parent has non-inheritable access, this should be null.
|
||||||
parentAccess?: roles.BasicRole|null;
|
parentAccess?: roles.BasicRole|null;
|
||||||
|
orgAccess?: roles.BasicRole|null;
|
||||||
anonymous?: boolean; // If set to true, the user is the anonymous user.
|
anonymous?: boolean; // If set to true, the user is the anonymous user.
|
||||||
|
isMember?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
@ -65,6 +65,7 @@ export interface IGristUrlState {
|
|||||||
welcome?: WelcomePage;
|
welcome?: WelcomePage;
|
||||||
welcomeTour?: boolean;
|
welcomeTour?: boolean;
|
||||||
docTour?: boolean;
|
docTour?: boolean;
|
||||||
|
manageUsers?: boolean;
|
||||||
params?: {
|
params?: {
|
||||||
billingPlan?: string;
|
billingPlan?: string;
|
||||||
billingTask?: BillingTask;
|
billingTask?: BillingTask;
|
||||||
@ -214,6 +215,8 @@ export function encodeUrl(gristConfig: Partial<GristLoadConfig>,
|
|||||||
url.hash = 'repeat-welcome-tour';
|
url.hash = 'repeat-welcome-tour';
|
||||||
} else if (state.docTour) {
|
} else if (state.docTour) {
|
||||||
url.hash = 'repeat-doc-tour';
|
url.hash = 'repeat-doc-tour';
|
||||||
|
} else if (state.manageUsers) {
|
||||||
|
url.hash = 'manage-users';
|
||||||
} else {
|
} else {
|
||||||
url.hash = '';
|
url.hash = '';
|
||||||
}
|
}
|
||||||
@ -315,6 +318,7 @@ export function decodeUrl(gristConfig: Partial<GristLoadConfig>, location: Locat
|
|||||||
}
|
}
|
||||||
state.welcomeTour = hashMap.get('#') === 'repeat-welcome-tour';
|
state.welcomeTour = hashMap.get('#') === 'repeat-welcome-tour';
|
||||||
state.docTour = hashMap.get('#') === 'repeat-doc-tour';
|
state.docTour = hashMap.get('#') === 'repeat-doc-tour';
|
||||||
|
state.manageUsers = hashMap.get('#') === 'manage-users';
|
||||||
}
|
}
|
||||||
return state;
|
return state;
|
||||||
}
|
}
|
||||||
|
@ -2054,18 +2054,24 @@ export class HomeDBManager extends EventEmitter {
|
|||||||
const wsMap = getMemberUserRoles(doc.workspace, this.defaultBasicGroupNames);
|
const wsMap = getMemberUserRoles(doc.workspace, this.defaultBasicGroupNames);
|
||||||
// The orgMap gives the org access inherited by each user.
|
// The orgMap gives the org access inherited by each user.
|
||||||
const orgMap = getMemberUserRoles(doc.workspace.org, this.defaultBasicGroupNames);
|
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);
|
const wsMaxInheritedRole = this._getMaxInheritedRole(doc.workspace);
|
||||||
// Iterate through the org since all users will be in the org.
|
// Iterate through the org since all users will be in the org.
|
||||||
let users: UserAccessData[] = getResourceUsers([doc, doc.workspace, doc.workspace.org]).map(u => {
|
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
|
// 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.
|
// resource access levels must be tempered by the maxInheritedRole values of their children.
|
||||||
const inheritFromOrg = roles.getWeakestRole(orgMap[u.id] || null, wsMaxInheritedRole);
|
const inheritFromOrg = roles.getWeakestRole(orgMap[u.id] || null, wsMaxInheritedRole);
|
||||||
|
const orgAccess = orgMapWithMembership[u.id] || null;
|
||||||
return {
|
return {
|
||||||
...this.makeFullUser(u),
|
...this.makeFullUser(u),
|
||||||
access: docMap[u.id] || null,
|
access: docMap[u.id] || null,
|
||||||
parentAccess: roles.getEffectiveRole(
|
parentAccess: roles.getEffectiveRole(
|
||||||
roles.getStrongestRole(wsMap[u.id] || null, inheritFromOrg)
|
roles.getStrongestRole(wsMap[u.id] || null, inheritFromOrg)
|
||||||
)
|
),
|
||||||
|
isMember: orgAccess !== 'guests' && orgAccess !== null,
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
let maxInheritedRole = this._getMaxInheritedRole(doc);
|
let maxInheritedRole = this._getMaxInheritedRole(doc);
|
||||||
|
Loading…
Reference in New Issue
Block a user