mirror of
https://github.com/gristlabs/grist-core.git
synced 2024-10-27 20:44:07 +00:00
(core) Add a Users dropdown to AccessRules page.
Summary: The list of users allows copying users' emails to clipboard, and viewing the doc as that user. Test Plan: Added a basic test case Reviewers: paulfitz Reviewed By: paulfitz Differential Revision: https://phab.getgrist.com/D2711
This commit is contained in:
parent
586b6568af
commit
7a91d49ea1
115
app/client/aclui/ACLUsers.ts
Normal file
115
app/client/aclui/ACLUsers.ts
Normal file
@ -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;
|
||||||
|
}
|
||||||
|
`);
|
@ -4,6 +4,7 @@
|
|||||||
import {aclColumnList} from 'app/client/aclui/ACLColumnList';
|
import {aclColumnList} from 'app/client/aclui/ACLColumnList';
|
||||||
import {aclFormulaEditor} from 'app/client/aclui/ACLFormulaEditor';
|
import {aclFormulaEditor} from 'app/client/aclui/ACLFormulaEditor';
|
||||||
import {aclSelect} from 'app/client/aclui/ACLSelect';
|
import {aclSelect} from 'app/client/aclui/ACLSelect';
|
||||||
|
import {ACLUsersPopup} from 'app/client/aclui/ACLUsers';
|
||||||
import {PermissionKey, permissionsWidget} from 'app/client/aclui/PermissionsWidget';
|
import {PermissionKey, permissionsWidget} from 'app/client/aclui/PermissionsWidget';
|
||||||
import {GristDoc} from 'app/client/components/GristDoc';
|
import {GristDoc} from 'app/client/components/GristDoc';
|
||||||
import {reportError, UserError} from 'app/client/models/errors';
|
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.
|
// Map of tableId to the list of columns for all tables in the document.
|
||||||
private _aclResources: {[tableId: string]: string[]} = {};
|
private _aclResources: {[tableId: string]: string[]} = {};
|
||||||
|
|
||||||
|
private _aclUsersPopup = ACLUsersPopup.create(this);
|
||||||
|
|
||||||
constructor(private _gristDoc: GristDoc) {
|
constructor(private _gristDoc: GristDoc) {
|
||||||
super();
|
super();
|
||||||
this._ruleStatus = Computed.create(this, (use) => {
|
this._ruleStatus = Computed.create(this, (use) => {
|
||||||
@ -126,6 +129,7 @@ export class AccessRules extends Disposable {
|
|||||||
const tableData = this._gristDoc.docData.getTable(tableId)!;
|
const tableData = this._gristDoc.docData.getTable(tableId)!;
|
||||||
this.autoDispose(tableData.tableActionEmitter.addListener(this._onChange, this));
|
this.autoDispose(tableData.tableActionEmitter.addListener(this._onChange, this));
|
||||||
}
|
}
|
||||||
|
|
||||||
this.update().catch((e) => this._errorMessage.set(e.message));
|
this.update().catch((e) => this._errorMessage.set(e.message));
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -150,8 +154,12 @@ export class AccessRules extends Disposable {
|
|||||||
public async update() {
|
public async update() {
|
||||||
this._errorMessage.set('');
|
this._errorMessage.set('');
|
||||||
const rules = this._ruleCollection;
|
const rules = this._ruleCollection;
|
||||||
await rules.update(this._gristDoc.docData, {log: console});
|
[ , , this._aclResources] = await Promise.all([
|
||||||
this._aclResources = await this._gristDoc.docComm.getAclResources();
|
rules.update(this._gristDoc.docData, {log: console}),
|
||||||
|
this._aclUsersPopup.init(this._gristDoc.docPageModel),
|
||||||
|
this._gristDoc.docComm.getAclResources(),
|
||||||
|
]);
|
||||||
|
|
||||||
this._tableRules.set(
|
this._tableRules.set(
|
||||||
rules.getAllTableIds().map(tableId => TableRules.create(this._tableRules,
|
rules.getAllTableIds().map(tableId => TableRules.create(this._tableRules,
|
||||||
tableId, this, rules.getAllColumnRuleSets(tableId), rules.getTableDefaultRuleSet(tableId)))
|
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('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'},
|
cssConditionError(dom.text(this._errorMessage), {style: 'margin-left: 16px'},
|
||||||
testId('access-rules-error')
|
testId('access-rules-error')
|
||||||
|
@ -2,6 +2,7 @@ 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';
|
||||||
import {ANONYMOUS_USER_EMAIL, EVERYONE_EMAIL, PermissionData, PermissionDelta, UserAPI} from 'app/common/UserAPI';
|
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 {computed, Computed, Disposable, obsArray, ObsArray, observable, Observable} from 'grainjs';
|
||||||
import some = require('lodash/some');
|
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
|
// 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
|
// open UserManager by a less-privileged user, e.g. if access was just lowered, in which
|
||||||
// case any attempted changes will fail on saving.)
|
// case any attempted changes will fail on saving.)
|
||||||
const initInheritedAccess = roles.getWeakestRole(member.parentAccess, this.initData.maxInheritedRole || null);
|
const initialAccessBasicRole = roles.getEffectiveRole(getRealAccess(member, this.initData));
|
||||||
const initialAccess = roles.getStrongestRole(member.access, initInheritedAccess);
|
|
||||||
const initialAccessBasicRole = roles.getEffectiveRole(initialAccess);
|
|
||||||
// This pretends to be a computed to match the other case, but is really a constant.
|
// This pretends to be a computed to match the other case, but is really a constant.
|
||||||
inheritedAccess = Computed.create(this, (use) => initialAccessBasicRole);
|
inheritedAccess = Computed.create(this, (use) => initialAccessBasicRole);
|
||||||
} else {
|
} else {
|
||||||
|
@ -154,6 +154,14 @@ export interface UserAccessData {
|
|||||||
parentAccess?: roles.BasicRole|null;
|
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 {
|
export interface ActiveSessionInfo {
|
||||||
user: FullUser & {helpScoutSignature?: string};
|
user: FullUser & {helpScoutSignature?: string};
|
||||||
org: Organization|null;
|
org: Organization|null;
|
||||||
|
Loading…
Reference in New Issue
Block a user