diff --git a/app/client/aclui/ACLUsers.ts b/app/client/aclui/ACLUsers.ts new file mode 100644 index 00000000..c524b894 --- /dev/null +++ b/app/client/aclui/ACLUsers.ts @@ -0,0 +1,115 @@ +import {copyToClipboard} from 'app/client/lib/copyToClipboard'; +import {DocPageModel} from 'app/client/models/DocPageModel'; +import {urlState} from 'app/client/models/gristUrlState'; +import {createUserImage} from 'app/client/ui/UserImage'; +import * as um from 'app/client/ui/UserManager'; +import {basicButton, basicButtonLink} from 'app/client/ui2018/buttons'; +import {colors, testId} from 'app/client/ui2018/cssVars'; +import {icon} from 'app/client/ui2018/icons'; +import {menuCssClass} from 'app/client/ui2018/menus'; +import {FullUser} from 'app/common/LoginSessionAPI'; +import * as roles from 'app/common/roles'; +import {ANONYMOUS_USER_EMAIL, EVERYONE_EMAIL, getRealAccess, UserAccessData} from 'app/common/UserAPI'; +import {Disposable, dom, Observable, styled} from 'grainjs'; +import merge = require('lodash/merge'); +import {cssMenu, cssMenuWrap, defaultMenuOptions, IOpenController, setPopupToCreateDom} from 'popweasel'; + +const roleNames: {[role: string]: string} = { + [roles.OWNER]: 'Owner', + [roles.EDITOR]: 'Editor', + [roles.VIEWER]: 'Viewer', +}; + +function buildUserRow(user: UserAccessData, currentUser: FullUser|null, ctl: IOpenController) { + const isCurrentUser = Boolean(currentUser && user.id === currentUser.id); + return cssUserItem( + um.cssMemberImage( + createUserImage(user, 'large') + ), + um.cssMemberText( + um.cssMemberPrimary(user.name || dom('span', user.email), + cssRole('(', roleNames[user.access!] || user.access, ')', testId('acl-user-access')), + ), + user.name ? um.cssMemberSecondary(user.email) : null + ), + basicButton(cssUserButton.cls(''), icon('Copy'), 'Copy Email', + testId('acl-user-copy'), + dom.on('click', async (ev, elem) => { await copyToClipboard(user.email); ctl.close(); }), + ), + basicButtonLink(cssUserButton.cls(''), cssUserButton.cls('-disabled', isCurrentUser), + testId('acl-user-view-as'), + icon('FieldLink'), 'View As', { + href: urlState().makeUrl( + merge({}, urlState().state.get(), {params: {linkParameters: {aclAsUser: user.email}}})), + }), + testId('acl-user-item'), + ); +} + +function isSpecialEmail(email: string) { + return email === ANONYMOUS_USER_EMAIL || email === EVERYONE_EMAIL; +} + +export class ACLUsersPopup extends Disposable { + public readonly isInitialized = Observable.create(this, false); + private _usersInDoc: UserAccessData[] = []; + private _currentUser: FullUser|null = null; + + public async init(pageModel: DocPageModel) { + this._currentUser = pageModel.userOverride.get()?.user || pageModel.appModel.currentValidUser; + const doc = pageModel.currentDoc.get(); + if (doc) { + const permissionData = await pageModel.appModel.api.getDocAccess(doc.id); + this._usersInDoc = permissionData.users.map(user => ({ + ...user, + access: getRealAccess(user, permissionData), + })) + .filter(user => user.access && !isSpecialEmail(user.email)); + this.isInitialized.set(true); + } + } + + public attachPopup(elem: Element) { + setPopupToCreateDom(elem, (ctl) => cssMenuWrap(cssMenu( + dom.cls(menuCssClass), + cssUsers.cls(''), + dom.forEach(this._usersInDoc, user => buildUserRow(user, this._currentUser, ctl)), + (el) => { setTimeout(() => el.focus(), 0); }, + dom.onKeyDown({Escape: () => ctl.close()}), + )), + {...defaultMenuOptions, placement: 'bottom-end'} + ); + } +} + +const cssUsers = styled('div', ` + max-width: unset; +`); + +const cssUserItem = styled(um.cssMemberListItem, ` + width: auto; + padding: 8px 16px; + align-items: center; + &:hover { + background-color: ${colors.lightGrey}; + } +`); + +const cssRole = styled('span', ` + margin: 0 8px; + font-weight: normal; +`); + +const cssUserButton = styled('div', ` + margin: 0 8px; + border: none; + display: inline-flex; + white-space: nowrap; + gap: 4px; + &:hover { + background-color: ${colors.darkGrey}; + } + &-disabled { + visibility: hidden; + } +`); diff --git a/app/client/aclui/AccessRules.ts b/app/client/aclui/AccessRules.ts index c4612a92..3d267246 100644 --- a/app/client/aclui/AccessRules.ts +++ b/app/client/aclui/AccessRules.ts @@ -4,6 +4,7 @@ import {aclColumnList} from 'app/client/aclui/ACLColumnList'; import {aclFormulaEditor} from 'app/client/aclui/ACLFormulaEditor'; import {aclSelect} from 'app/client/aclui/ACLSelect'; +import {ACLUsersPopup} from 'app/client/aclui/ACLUsers'; import {PermissionKey, permissionsWidget} from 'app/client/aclui/PermissionsWidget'; import {GristDoc} from 'app/client/components/GristDoc'; import {reportError, UserError} from 'app/client/models/errors'; @@ -81,6 +82,8 @@ export class AccessRules extends Disposable { // Map of tableId to the list of columns for all tables in the document. private _aclResources: {[tableId: string]: string[]} = {}; + private _aclUsersPopup = ACLUsersPopup.create(this); + constructor(private _gristDoc: GristDoc) { super(); this._ruleStatus = Computed.create(this, (use) => { @@ -126,6 +129,7 @@ export class AccessRules extends Disposable { const tableData = this._gristDoc.docData.getTable(tableId)!; this.autoDispose(tableData.tableActionEmitter.addListener(this._onChange, this)); } + this.update().catch((e) => this._errorMessage.set(e.message)); } @@ -150,8 +154,12 @@ export class AccessRules extends Disposable { public async update() { this._errorMessage.set(''); const rules = this._ruleCollection; - await rules.update(this._gristDoc.docData, {log: console}); - this._aclResources = await this._gristDoc.docComm.getAclResources(); + [ , , this._aclResources] = await Promise.all([ + rules.update(this._gristDoc.docData, {log: console}), + this._aclUsersPopup.init(this._gristDoc.docPageModel), + this._gristDoc.docComm.getAclResources(), + ]); + this._tableRules.set( rules.getAllTableIds().map(tableId => TableRules.create(this._tableRules, tableId, this, rules.getAllColumnRuleSets(tableId), rules.getTableDefaultRuleSet(tableId))) @@ -291,6 +299,9 @@ export class AccessRules extends Disposable { ), ), bigBasicButton('Add User Attributes', dom.on('click', () => this._addUserAttributes())), + bigBasicButton('Users', cssDropdownIcon('Dropdown'), elem => this._aclUsersPopup.attachPopup(elem), + dom.style('visibility', use => use(this._aclUsersPopup.isInitialized) ? '' : 'hidden'), + ), ), cssConditionError(dom.text(this._errorMessage), {style: 'margin-left: 16px'}, testId('access-rules-error') diff --git a/app/client/models/UserManagerModel.ts b/app/client/models/UserManagerModel.ts index 00ec3fef..204e3618 100644 --- a/app/client/models/UserManagerModel.ts +++ b/app/client/models/UserManagerModel.ts @@ -2,6 +2,7 @@ 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'); @@ -261,9 +262,7 @@ export class UserManagerModelImpl extends Disposable implements UserManagerModel // 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 initInheritedAccess = roles.getWeakestRole(member.parentAccess, this.initData.maxInheritedRole || null); - const initialAccess = roles.getStrongestRole(member.access, initInheritedAccess); - const initialAccessBasicRole = roles.getEffectiveRole(initialAccess); + 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 { diff --git a/app/common/UserAPI.ts b/app/common/UserAPI.ts index a6a08a29..99a3ccb9 100644 --- a/app/common/UserAPI.ts +++ b/app/common/UserAPI.ts @@ -154,6 +154,14 @@ export interface UserAccessData { parentAccess?: roles.BasicRole|null; } +/** + * Combines access, parentAccess, and maxInheritedRole info into the resulting access role. + */ +export function getRealAccess(user: UserAccessData, permissionData: PermissionData): roles.Role|null { + const inheritedAccess = roles.getWeakestRole(user.parentAccess || null, permissionData.maxInheritedRole || null); + return roles.getStrongestRole(user.access, inheritedAccess); +} + export interface ActiveSessionInfo { user: FullUser & {helpScoutSignature?: string}; org: Organization|null;