mirror of
https://github.com/gristlabs/grist-core.git
synced 2024-10-27 20:44:07 +00:00
(core) Polish Access Details
Summary: Instead of showing a blank dialog for users whose access is limited (e.g. public members), we now show the user's role and a mention of whether their access is public. Test Plan: Browser tests. Reviewers: paulfitz Reviewed By: paulfitz Differential Revision: https://phab.getgrist.com/D3431
This commit is contained in:
parent
bad4c68569
commit
a6063f570a
@ -5,18 +5,19 @@ 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';
|
||||||
import {ANONYMOUS_USER_EMAIL, Document, EVERYONE_EMAIL, Organization,
|
import {ANONYMOUS_USER_EMAIL, Document, EVERYONE_EMAIL, FullUser, getRealAccess, Organization,
|
||||||
PermissionData, PermissionDelta, UserAPI, Workspace} from 'app/common/UserAPI';
|
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 {computed, Computed, Disposable, obsArray, ObsArray, observable, Observable} from 'grainjs';
|
||||||
import some = require('lodash/some');
|
import some = require('lodash/some');
|
||||||
|
|
||||||
export interface UserManagerModel {
|
export interface UserManagerModel {
|
||||||
initData: PermissionData; // PermissionData used to initialize the UserManager
|
initData: PermissionData; // PermissionData used to initialize the UserManager
|
||||||
resourceType: ResourceType; // String representing the access resource
|
resource: Resource|null; // The access resource.
|
||||||
|
resourceType: ResourceType; // String representing the access resource type.
|
||||||
userSelectOptions: IMemberSelectOption[]; // Select options for each user's role dropdown
|
userSelectOptions: IMemberSelectOption[]; // Select options for each user's role dropdown
|
||||||
orgUserSelectOptions: IOrgMemberSelectOption[]; // Select options for each user's role dropdown on the org
|
orgUserSelectOptions: IOrgMemberSelectOption[]; // Select options for each user's role dropdown on the org
|
||||||
inheritSelectOptions: IMemberSelectOption[]; // Select options for the maxInheritedRole dropdown
|
inheritSelectOptions: IMemberSelectOption[]; // Select options for the maxInheritedRole dropdown
|
||||||
|
publicUserSelectOptions: IMemberSelectOption[]; // Select options for the public member's role dropdown
|
||||||
maxInheritedRole: Observable<roles.BasicRole|null>; // Current unsaved maxInheritedRole setting
|
maxInheritedRole: Observable<roles.BasicRole|null>; // Current unsaved maxInheritedRole setting
|
||||||
membersEdited: ObsArray<IEditableMember>; // Current unsaved editable array of members
|
membersEdited: ObsArray<IEditableMember>; // Current unsaved editable array of members
|
||||||
publicMember: IEditableMember|null; // Member whose access (VIEWER or null) represents that of
|
publicMember: IEditableMember|null; // Member whose access (VIEWER or null) represents that of
|
||||||
@ -26,7 +27,9 @@ export interface UserManagerModel {
|
|||||||
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.
|
annotations: Observable<ShareAnnotations>; // More information about shares, keyed by email.
|
||||||
isPersonal: boolean; // If user access info/control is restricted to self.
|
isPersonal: boolean; // If user access info/control is restricted to self.
|
||||||
|
isPublicMember: boolean; // Indicates if current user is a public member.
|
||||||
|
|
||||||
|
activeUser: FullUser|null; // Populated if current user is logged in.
|
||||||
gristDoc: GristDoc|null; // Populated if there is an open document.
|
gristDoc: GristDoc|null; // Populated if there is an open document.
|
||||||
|
|
||||||
// Resets all unsaved changes
|
// Resets all unsaved changes
|
||||||
@ -108,6 +111,15 @@ export class UserManagerModelImpl extends Disposable implements UserManagerModel
|
|||||||
{ value: roles.VIEWER, label: 'View Only' },
|
{ value: roles.VIEWER, label: 'View Only' },
|
||||||
{ value: null, label: 'None' }
|
{ value: null, label: 'None' }
|
||||||
];
|
];
|
||||||
|
// Select options for the public member's role dropdown.
|
||||||
|
public readonly publicUserSelectOptions: IMemberSelectOption[] = [
|
||||||
|
{ value: roles.EDITOR, label: 'Editor' },
|
||||||
|
{ value: roles.VIEWER, label: 'Viewer' },
|
||||||
|
];
|
||||||
|
|
||||||
|
public activeUser: FullUser|null = this._options.activeUser ?? null;
|
||||||
|
|
||||||
|
public resource: Resource|null = this._options.resource ?? null;
|
||||||
|
|
||||||
public maxInheritedRole: Observable<roles.BasicRole|null> =
|
public maxInheritedRole: Observable<roles.BasicRole|null> =
|
||||||
observable(this.initData.maxInheritedRole || null);
|
observable(this.initData.maxInheritedRole || null);
|
||||||
@ -121,11 +133,13 @@ export class UserManagerModelImpl extends Disposable implements UserManagerModel
|
|||||||
|
|
||||||
public annotations = this.autoDispose(observable({users: new Map()}));
|
public annotations = this.autoDispose(observable({users: new Map()}));
|
||||||
|
|
||||||
public isPersonal = this.initData.personal || false;
|
public isPersonal = this.initData.personal ?? false;
|
||||||
|
|
||||||
|
public isPublicMember = this.initData.public ?? false;
|
||||||
|
|
||||||
public isOrg: boolean = this.resourceType === 'organization';
|
public isOrg: boolean = this.resourceType === 'organization';
|
||||||
|
|
||||||
public gristDoc: GristDoc|null;
|
public gristDoc: GristDoc|null = this._options.docPageModel?.gristDoc.get() ?? null;
|
||||||
|
|
||||||
// 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.
|
||||||
@ -139,7 +153,7 @@ export class UserManagerModelImpl extends Disposable implements UserManagerModel
|
|||||||
|
|
||||||
// Check if the current user is being removed.
|
// Check if the current user is being removed.
|
||||||
public readonly isSelfRemoved: Computed<boolean> = this.autoDispose(computed<boolean>((use) => {
|
public readonly isSelfRemoved: Computed<boolean> = this.autoDispose(computed<boolean>((use) => {
|
||||||
return some(use(this.membersEdited), m => m.isRemoved && m.email ===this._options.activeEmail);
|
return some(use(this.membersEdited), m => m.isRemoved && m.email === this.activeUser?.email);
|
||||||
}));
|
}));
|
||||||
|
|
||||||
private _shareAnnotator?: ShareAnnotator;
|
private _shareAnnotator?: ShareAnnotator;
|
||||||
@ -148,14 +162,14 @@ export class UserManagerModelImpl extends Disposable implements UserManagerModel
|
|||||||
public initData: PermissionData,
|
public initData: PermissionData,
|
||||||
public resourceType: ResourceType,
|
public resourceType: ResourceType,
|
||||||
private _options: {
|
private _options: {
|
||||||
activeEmail?: string|null,
|
activeUser?: FullUser|null,
|
||||||
reload?: () => Promise<PermissionData>,
|
reload?: () => Promise<PermissionData>,
|
||||||
docPageModel?: DocPageModel,
|
docPageModel?: DocPageModel,
|
||||||
appModel?: AppModel,
|
appModel?: AppModel,
|
||||||
|
resource?: Resource,
|
||||||
}
|
}
|
||||||
) {
|
) {
|
||||||
super();
|
super();
|
||||||
this.gristDoc = this._options.docPageModel?.gristDoc.get() ?? null;
|
|
||||||
if (this._options.appModel) {
|
if (this._options.appModel) {
|
||||||
const features = this._options.appModel.currentFeatures;
|
const features = this._options.appModel.currentFeatures;
|
||||||
this._shareAnnotator = new ShareAnnotator(features, initData);
|
this._shareAnnotator = new ShareAnnotator(features, initData);
|
||||||
@ -236,7 +250,7 @@ export class UserManagerModelImpl extends Disposable implements UserManagerModel
|
|||||||
}
|
}
|
||||||
|
|
||||||
public isActiveUser(member: IEditableMember): boolean {
|
public isActiveUser(member: IEditableMember): boolean {
|
||||||
return member.email === this._options.activeEmail;
|
return member.email === this.activeUser?.email;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Analyze the relation that users have to the resource or site.
|
// Analyze the relation that users have to the resource or site.
|
||||||
@ -322,7 +336,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._options.activeEmail) {
|
if (member.email === this.activeUser?.email) {
|
||||||
// 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
|
||||||
|
@ -53,7 +53,7 @@ export class AccountWidget extends Disposable {
|
|||||||
const api = this._appModel.api;
|
const api = this._appModel.api;
|
||||||
(await loadUserManager()).showUserManagerModal(api, {
|
(await loadUserManager()).showUserManagerModal(api, {
|
||||||
permissionData: api.getOrgAccess(org.id),
|
permissionData: api.getOrgAccess(org.id),
|
||||||
activeEmail: user ? user.email : null,
|
activeUser: user,
|
||||||
resourceType: 'organization',
|
resourceType: 'organization',
|
||||||
resourceId: org.id,
|
resourceId: org.id,
|
||||||
resource: org,
|
resource: org,
|
||||||
|
@ -34,7 +34,7 @@ export class AppHeader extends Disposable {
|
|||||||
const api = this._appModel.api;
|
const api = this._appModel.api;
|
||||||
(await loadUserManager()).showUserManagerModal(api, {
|
(await loadUserManager()).showUserManagerModal(api, {
|
||||||
permissionData: api.getOrgAccess(org.id),
|
permissionData: api.getOrgAccess(org.id),
|
||||||
activeEmail: user ? user.email : null,
|
activeUser: user,
|
||||||
resourceType: 'organization',
|
resourceType: 'organization',
|
||||||
resourceId: org.id,
|
resourceId: org.id,
|
||||||
resource: org
|
resource: org
|
||||||
|
@ -429,7 +429,7 @@ export function makeDocOptionsMenu(home: HomeModel, doc: Document, renaming: Obs
|
|||||||
const user = home.app.currentUser;
|
const user = home.app.currentUser;
|
||||||
(await loadUserManager()).showUserManagerModal(api, {
|
(await loadUserManager()).showUserManagerModal(api, {
|
||||||
permissionData: api.getDocAccess(doc.id),
|
permissionData: api.getDocAccess(doc.id),
|
||||||
activeEmail: user ? user.email : null,
|
activeUser: user,
|
||||||
resourceType: 'document',
|
resourceType: 'document',
|
||||||
resourceId: doc.id,
|
resourceId: doc.id,
|
||||||
resource: doc,
|
resource: doc,
|
||||||
|
@ -202,7 +202,7 @@ function workspaceMenu(home: HomeModel, ws: Workspace, renaming: Observable<Work
|
|||||||
const user = home.app.currentUser;
|
const user = home.app.currentUser;
|
||||||
(await loadUserManager()).showUserManagerModal(api, {
|
(await loadUserManager()).showUserManagerModal(api, {
|
||||||
permissionData: api.getWorkspaceAccess(ws.id),
|
permissionData: api.getWorkspaceAccess(ws.id),
|
||||||
activeEmail: user ? user.email : null,
|
activeUser: user,
|
||||||
resourceType: 'workspace',
|
resourceType: 'workspace',
|
||||||
resourceId: ws.id,
|
resourceId: ws.id,
|
||||||
resource: ws,
|
resource: ws,
|
||||||
|
@ -242,7 +242,7 @@ async function manageUsers(doc: DocInfo, docPageModel: DocPageModel) {
|
|||||||
const user = appModel.currentValidUser;
|
const user = appModel.currentValidUser;
|
||||||
(await loadUserManager()).showUserManagerModal(api, {
|
(await loadUserManager()).showUserManagerModal(api, {
|
||||||
permissionData: api.getDocAccess(doc.id),
|
permissionData: api.getDocAccess(doc.id),
|
||||||
activeEmail: user ? user.email : null,
|
activeUser: user,
|
||||||
resourceType: 'document',
|
resourceType: 'document',
|
||||||
resourceId: doc.id,
|
resourceId: doc.id,
|
||||||
resource: doc,
|
resource: doc,
|
||||||
|
@ -6,7 +6,7 @@ export type Size = 'small' | 'medium' | 'large';
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Returns a DOM element showing a circular icon with a user's picture, or the user's initials if
|
* Returns a DOM element showing a circular icon with a user's picture, or the user's initials if
|
||||||
* picture is missing. Also vares the color of the circle when using initials.
|
* picture is missing. Also varies the color of the circle when using initials.
|
||||||
*/
|
*/
|
||||||
export function createUserImage(user: FullUser|null, size: Size, ...args: DomElementArg[]): HTMLElement {
|
export function createUserImage(user: FullUser|null, size: Size, ...args: DomElementArg[]): HTMLElement {
|
||||||
let initials: string;
|
let initials: string;
|
||||||
|
@ -31,16 +31,17 @@ import {cssEmailInput, cssEmailInputContainer, cssMailIcon, cssMemberBtn, cssMem
|
|||||||
cssMemberPrimary, cssMemberSecondary, cssMemberText, cssMemberType, cssMemberTypeProblem,
|
cssMemberPrimary, cssMemberSecondary, cssMemberText, cssMemberType, cssMemberTypeProblem,
|
||||||
cssRemoveIcon} from 'app/client/ui/UserItem';
|
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, mediaXSmall, testId, vars} from 'app/client/ui2018/cssVars';
|
||||||
import {icon} from 'app/client/ui2018/icons';
|
import {icon} from 'app/client/ui2018/icons';
|
||||||
import {cssLink} from 'app/client/ui2018/links';
|
import {cssLink} from 'app/client/ui2018/links';
|
||||||
|
import {loadingSpinner} from 'app/client/ui2018/loaders';
|
||||||
import {inputMenu, menu, menuItem, menuText} from 'app/client/ui2018/menus';
|
import {inputMenu, menu, menuItem, menuText} from 'app/client/ui2018/menus';
|
||||||
import {confirmModal, cssModalBody, cssModalButtons, cssModalTitle, IModalControl,
|
import {confirmModal, cssModalBody, cssModalButtons, cssModalTitle, IModalControl,
|
||||||
modal} from 'app/client/ui2018/modals';
|
modal} from 'app/client/ui2018/modals';
|
||||||
|
|
||||||
export interface IUserManagerOptions {
|
export interface IUserManagerOptions {
|
||||||
permissionData: Promise<PermissionData>;
|
permissionData: Promise<PermissionData>;
|
||||||
activeEmail: string|null;
|
activeUser: FullUser|null;
|
||||||
resourceType: ResourceType;
|
resourceType: ResourceType;
|
||||||
resourceId: string|number;
|
resourceId: string|number;
|
||||||
resource?: Resource;
|
resource?: Resource;
|
||||||
@ -59,8 +60,10 @@ export interface IUserManagerOptions {
|
|||||||
// 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,
|
return new UserManagerModelImpl(
|
||||||
pick(options, ['activeEmail', 'reload', 'appModel', 'docPageModel']));
|
permissionData, options.resourceType,
|
||||||
|
pick(options, ['activeUser', 'reload', 'appModel', 'docPageModel', 'resource'])
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -107,55 +110,73 @@ export function showUserManagerModal(userApi: UserAPI, options: IUserManagerOpti
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const personal = !roles.canEditAccess(options.resource?.access || null);
|
|
||||||
// Get the model and assign it to the observable. Report errors to the app.
|
// Get the model and assign it to the observable. Report errors to the app.
|
||||||
getModel(options)
|
getModel(options)
|
||||||
.then(model => modelObs.set(model))
|
.then(model => modelObs.set(model))
|
||||||
.catch(reportError);
|
.catch(reportError);
|
||||||
modal(ctl => [
|
|
||||||
|
return buildUserManagerModal(modelObs, onConfirm, options);
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildUserManagerModal(
|
||||||
|
modelObs: Observable<UserManagerModel|null>,
|
||||||
|
onConfirm: (ctl: IModalControl) => Promise<void>,
|
||||||
|
options: IUserManagerOptions
|
||||||
|
) {
|
||||||
|
return modal(ctl => [
|
||||||
// We set the padding to 0 since the body scroll shadows extend to the edge of the modal.
|
// We set the padding to 0 since the body scroll shadows extend to the edge of the modal.
|
||||||
{ style: 'padding: 0;' },
|
{ style: 'padding: 0;' },
|
||||||
options.showAnimation ? dom.cls(cssAnimatedModal.className) : null,
|
options.showAnimation ? dom.cls(cssAnimatedModal.className) : null,
|
||||||
cssModalTitle(
|
dom.maybe(modelObs, model => cssTitle(
|
||||||
{ style: 'margin: 40px 64px 0 64px;' },
|
renderTitle(options.resourceType, options.resource, model.isPersonal),
|
||||||
renderTitle(options.resourceType, options.resource, personal),
|
(options.resourceType === 'document' && (!model.isPersonal || model.isPublicMember)
|
||||||
((options.resourceType === 'document' && !personal) ?
|
? makeCopyBtn(options.linkToCopy, cssCopyBtn.cls('-header'))
|
||||||
makeCopyBtn(options.linkToCopy, cssCopyBtn.cls('-header')) : null),
|
: null
|
||||||
testId('um-header')
|
),
|
||||||
),
|
testId('um-header'),
|
||||||
|
)),
|
||||||
|
dom.domComputed(modelObs, model => {
|
||||||
|
if (!model) { return cssSpinner(loadingSpinner()); }
|
||||||
|
|
||||||
cssModalBody(
|
const cssBody = model.isPersonal ? cssAccessDetailsBody : cssUserManagerBody;
|
||||||
cssUserManagerBody(
|
return [
|
||||||
// TODO: Show a loading indicator before the model is loaded.
|
cssModalBody(
|
||||||
dom.maybe(modelObs, model => new UserManager(
|
cssBody(
|
||||||
model, pick(options, 'linkToCopy', 'docPageModel', 'appModel', 'prompt')
|
new UserManager(
|
||||||
).buildDom()),
|
model, pick(options, 'linkToCopy', 'docPageModel', 'appModel', 'prompt', 'resource')
|
||||||
),
|
).buildDom()
|
||||||
),
|
),
|
||||||
cssModalButtons(
|
),
|
||||||
{ style: 'margin: 32px 64px; display: flex;' },
|
cssModalButtons(
|
||||||
bigPrimaryButton('Confirm',
|
{ style: 'margin: 32px 64px; display: flex;' },
|
||||||
dom.boolAttr('disabled', (use) => !use(modelObs) || !use(use(modelObs)!.isAnythingChanged)),
|
(model.isPublicMember ? null :
|
||||||
dom.on('click', () => onConfirm(ctl)),
|
bigPrimaryButton('Confirm',
|
||||||
testId('um-confirm')
|
dom.boolAttr('disabled', (use) => !use(model.isAnythingChanged)),
|
||||||
),
|
dom.on('click', () => onConfirm(ctl)),
|
||||||
bigBasicButton('Cancel',
|
testId('um-confirm')
|
||||||
dom.on('click', () => ctl.close()),
|
)
|
||||||
testId('um-cancel')
|
),
|
||||||
),
|
bigBasicButton(
|
||||||
dom.maybe(use => use(modelObs)?.resourceType === 'document' && use(modelObs)?.gristDoc && !personal, () =>
|
model.isPublicMember ? 'Close' : 'Cancel',
|
||||||
cssAccessLink({href: urlState().makeUrl({docPage: 'acl'})},
|
dom.on('click', () => ctl.close()),
|
||||||
dom.text(use => (use(modelObs) && use(use(modelObs)!.isAnythingChanged)) ? 'Save & ' : ''),
|
testId('um-cancel')
|
||||||
'Open Access Rules',
|
),
|
||||||
dom.on('click', (ev) => {
|
(model.resourceType === 'document' && model.gristDoc && !model.isPersonal
|
||||||
ev.preventDefault();
|
? cssAccessLink({href: urlState().makeUrl({docPage: 'acl'})},
|
||||||
return onConfirm(ctl).then(() => urlState().pushUrl({docPage: 'acl'}));
|
dom.text(use => use(model.isAnythingChanged) ? 'Save & ' : ''),
|
||||||
}),
|
'Open Access Rules',
|
||||||
testId('um-open-access-rules')
|
dom.on('click', (ev) => {
|
||||||
|
ev.preventDefault();
|
||||||
|
return onConfirm(ctl).then(() => urlState().pushUrl({docPage: 'acl'}));
|
||||||
|
}),
|
||||||
|
testId('um-open-access-rules')
|
||||||
|
)
|
||||||
|
: null
|
||||||
|
),
|
||||||
|
testId('um-buttons'),
|
||||||
)
|
)
|
||||||
),
|
];
|
||||||
testId('um-buttons'),
|
})
|
||||||
)
|
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -172,7 +193,8 @@ export class UserManager extends Disposable {
|
|||||||
linkToCopy?: string,
|
linkToCopy?: string,
|
||||||
docPageModel?: DocPageModel,
|
docPageModel?: DocPageModel,
|
||||||
appModel?: AppModel,
|
appModel?: AppModel,
|
||||||
prompt?: {email: string}
|
prompt?: {email: string},
|
||||||
|
resource?: Resource,
|
||||||
}) {
|
}) {
|
||||||
super();
|
super();
|
||||||
}
|
}
|
||||||
@ -180,9 +202,17 @@ export class UserManager extends Disposable {
|
|||||||
public buildDom() {
|
public buildDom() {
|
||||||
const memberEmail = this.autoDispose(new MemberEmail(this._onAdd.bind(this),
|
const memberEmail = this.autoDispose(new MemberEmail(this._onAdd.bind(this),
|
||||||
this._options.prompt));
|
this._options.prompt));
|
||||||
|
if (this._model.isPublicMember) {
|
||||||
|
return this._buildSelfPublicAccessDom();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this._model.isPersonal) {
|
||||||
|
return this._buildSelfAccessDom();
|
||||||
|
}
|
||||||
|
|
||||||
return [
|
return [
|
||||||
this._model.isPersonal ? null : memberEmail.buildDom(),
|
memberEmail.buildDom(),
|
||||||
this._model.isPersonal ? null : this._buildOptionsDom(),
|
this._buildOptionsDom(),
|
||||||
this._dom = shadowScroll(
|
this._dom = shadowScroll(
|
||||||
testId('um-members'),
|
testId('um-members'),
|
||||||
this._buildPublicAccessMember(),
|
this._buildPublicAccessMember(),
|
||||||
@ -245,9 +275,21 @@ export class UserManager extends Disposable {
|
|||||||
createUserImage(getFullUser(member), 'large')
|
createUserImage(getFullUser(member), 'large')
|
||||||
),
|
),
|
||||||
cssMemberText(
|
cssMemberText(
|
||||||
cssMemberPrimary(member.name || dom('span', member.email, dom.cls('member-email'), testId('um-email'))),
|
cssMemberPrimary(
|
||||||
member.name ? cssMemberSecondary(member.email, dom.cls('member-email'), testId('um-email')) : null,
|
member.name || member.email,
|
||||||
this._buildAnnotationDom(member),
|
member.email ? dom.cls('member-email') : null,
|
||||||
|
testId('um-member-name'),
|
||||||
|
),
|
||||||
|
!member.name ? null : cssMemberSecondary(
|
||||||
|
member.email, dom.cls('member-email'), testId('um-member-email')
|
||||||
|
),
|
||||||
|
dom('span',
|
||||||
|
(this._model.isPersonal
|
||||||
|
? this._buildSelfAnnotationDom(member)
|
||||||
|
: this._buildAnnotationDom(member)
|
||||||
|
),
|
||||||
|
testId('um-member-annotation'),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
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)),
|
||||||
@ -273,7 +315,7 @@ export class UserManager extends Disposable {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Build an annotation for a single member.
|
// Build an annotation for a single member in the Manage Users dialog.
|
||||||
private _buildAnnotationDom(member: IEditableMember) {
|
private _buildAnnotationDom(member: IEditableMember) {
|
||||||
return dom.domComputed(this._model.annotations, (annotations) => {
|
return dom.domComputed(this._model.annotations, (annotations) => {
|
||||||
const annotation = annotations.users.get(member.email);
|
const annotation = annotations.users.get(member.email);
|
||||||
@ -316,6 +358,24 @@ export class UserManager extends Disposable {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Build an annotation for the current user in the Access Details dialog.
|
||||||
|
private _buildSelfAnnotationDom(user: IEditableMember) {
|
||||||
|
return dom.domComputed(this._model.annotations, (annotations) => {
|
||||||
|
const annotation = annotations.users.get(user.email);
|
||||||
|
if (!annotation) { return null; }
|
||||||
|
|
||||||
|
if (annotation.isSupport) {
|
||||||
|
return cssMemberType('Grist support');
|
||||||
|
} else if (annotation.isMember && annotations.hasTeam) {
|
||||||
|
return cssMemberType('Team member');
|
||||||
|
} else if (annotations.hasTeam) {
|
||||||
|
return cssMemberType('Outside collaborator');
|
||||||
|
} else {
|
||||||
|
return cssMemberType('Collaborator');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
private _buildPublicAccessMember() {
|
private _buildPublicAccessMember() {
|
||||||
const publicMember = this._model.publicMember;
|
const publicMember = this._model.publicMember;
|
||||||
if (!publicMember) { return null; }
|
if (!publicMember) { return null; }
|
||||||
@ -328,8 +388,7 @@ export class UserManager extends Disposable {
|
|||||||
cssMemberSecondary('Anyone with link ', makeCopyBtn(this._options.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.
|
this._model.publicUserSelectOptions
|
||||||
this._model.userSelectOptions.filter(opt => [roles.EDITOR, roles.VIEWER].includes(opt.value!))
|
|
||||||
),
|
),
|
||||||
cssMemberBtn(
|
cssMemberBtn(
|
||||||
cssRemoveIcon('Remove', testId('um-member-delete')),
|
cssRemoveIcon('Remove', testId('um-member-delete')),
|
||||||
@ -341,6 +400,48 @@ export class UserManager extends Disposable {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private _buildSelfPublicAccessDom() {
|
||||||
|
const accessValue = this._options.resource?.access;
|
||||||
|
const accessLabel = this._model.publicUserSelectOptions
|
||||||
|
.find(opt => opt.value === accessValue)?.label;
|
||||||
|
const activeUser = this._model.activeUser;
|
||||||
|
const name = activeUser?.name ?? 'Anonymous';
|
||||||
|
|
||||||
|
return dom('div',
|
||||||
|
cssMemberListItem(
|
||||||
|
(!activeUser
|
||||||
|
? cssPublicMemberIcon('PublicFilled')
|
||||||
|
: cssMemberImage(createUserImage(activeUser, 'large'))
|
||||||
|
),
|
||||||
|
cssMemberText(
|
||||||
|
cssMemberPrimary(name, testId('um-member-name')),
|
||||||
|
activeUser?.email ? cssMemberSecondary(activeUser.email) : null,
|
||||||
|
cssMemberPublicAccess(
|
||||||
|
dom('span', 'Public access', testId('um-member-annotation')),
|
||||||
|
cssPublicAccessIcon('PublicFilled'),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
cssRoleBtn(
|
||||||
|
accessLabel ?? 'Guest',
|
||||||
|
cssCollapseIcon('Collapse'),
|
||||||
|
dom.cls('disabled'),
|
||||||
|
testId('um-member-role'),
|
||||||
|
),
|
||||||
|
testId('um-member'),
|
||||||
|
),
|
||||||
|
testId('um-members'),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private _buildSelfAccessDom() {
|
||||||
|
return dom('div',
|
||||||
|
dom.domComputed(this._model.membersEdited, members =>
|
||||||
|
members[0] ? this._buildMemberDom(members[0]) : null
|
||||||
|
),
|
||||||
|
testId('um-members'),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
// Returns a div containing a button that opens a menu to choose between roles.
|
// Returns a div containing a button that opens a menu to choose between roles.
|
||||||
private _memberRoleSelector(
|
private _memberRoleSelector(
|
||||||
role: Observable<string|null>,
|
role: Observable<string|null>,
|
||||||
@ -351,7 +452,8 @@ export class UserManager extends Disposable {
|
|||||||
const allRoles = allRolesOverride ||
|
const allRoles = allRolesOverride ||
|
||||||
(this._model.isOrg ? this._model.orgUserSelectOptions : this._model.userSelectOptions);
|
(this._model.isOrg ? this._model.orgUserSelectOptions : this._model.userSelectOptions);
|
||||||
return cssRoleBtn(
|
return cssRoleBtn(
|
||||||
menu(() => [
|
// Don't include the menu if we're only showing access details for the current user.
|
||||||
|
this._model.isPersonal ? null : menu(() => [
|
||||||
dom.forEach(allRoles, _role =>
|
dom.forEach(allRoles, _role =>
|
||||||
// The active user should be prevented from changing their own role.
|
// The active user should be prevented from changing their own role.
|
||||||
menuItem(() => isActiveUser || role.set(_role.value), _role.label,
|
menuItem(() => isActiveUser || role.set(_role.value), _role.label,
|
||||||
@ -384,6 +486,7 @@ export class UserManager extends Disposable {
|
|||||||
return activeRole ? activeRole.label : "Guest";
|
return activeRole ? activeRole.label : "Guest";
|
||||||
}),
|
}),
|
||||||
cssCollapseIcon('Collapse'),
|
cssCollapseIcon('Collapse'),
|
||||||
|
this._model.isPersonal ? dom.cls('disabled') : null,
|
||||||
testId('um-member-role')
|
testId('um-member-role')
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@ -535,7 +638,7 @@ async function manageTeam(appModel: AppModel,
|
|||||||
const api = appModel.api;
|
const api = appModel.api;
|
||||||
showUserManagerModal(api, {
|
showUserManagerModal(api, {
|
||||||
permissionData: api.getOrgAccess(currentOrg.id),
|
permissionData: api.getOrgAccess(currentOrg.id),
|
||||||
activeEmail: user ? user.email : null,
|
activeUser: user,
|
||||||
resourceType: 'organization',
|
resourceType: 'organization',
|
||||||
resourceId: currentOrg.id,
|
resourceId: currentOrg.id,
|
||||||
resource: currentOrg,
|
resource: currentOrg,
|
||||||
@ -546,13 +649,23 @@ async function manageTeam(appModel: AppModel,
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const cssUserManagerBody = styled('div', `
|
const cssAccessDetailsBody = styled('div', `
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
width: 600px;
|
width: 600px;
|
||||||
|
font-size: ${vars.mediumFontSize};
|
||||||
|
`);
|
||||||
|
|
||||||
|
const cssUserManagerBody = styled(cssAccessDetailsBody, `
|
||||||
height: 374px;
|
height: 374px;
|
||||||
border-bottom: 1px solid ${colors.darkGrey};
|
border-bottom: 1px solid ${colors.darkGrey};
|
||||||
font-size: ${vars.mediumFontSize};
|
`);
|
||||||
|
|
||||||
|
const cssSpinner = styled('div', `
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
margin: 32px;
|
||||||
`);
|
`);
|
||||||
|
|
||||||
const cssCopyBtn = styled(basicButton, `
|
const cssCopyBtn = styled(basicButton, `
|
||||||
@ -583,9 +696,13 @@ const cssOptionBtn = styled('span', `
|
|||||||
`);
|
`);
|
||||||
|
|
||||||
const cssPublicMemberIcon = styled(icon, `
|
const cssPublicMemberIcon = styled(icon, `
|
||||||
width: 32px;
|
width: 40px;
|
||||||
height: 32px;
|
height: 40px;
|
||||||
margin: 4px 8px;
|
margin: 0 4px;
|
||||||
|
--icon-color: ${colors.lightGreen};
|
||||||
|
`);
|
||||||
|
|
||||||
|
const cssPublicAccessIcon = styled(icon, `
|
||||||
--icon-color: ${colors.lightGreen};
|
--icon-color: ${colors.lightGreen};
|
||||||
`);
|
`);
|
||||||
|
|
||||||
@ -602,6 +719,7 @@ const cssRoleBtn = styled('div', `
|
|||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
|
|
||||||
&.disabled {
|
&.disabled {
|
||||||
|
opacity: 0.5;
|
||||||
cursor: default;
|
cursor: default;
|
||||||
}
|
}
|
||||||
`);
|
`);
|
||||||
@ -654,6 +772,22 @@ const cssAnimatedModal = styled('div', `
|
|||||||
position: relative;
|
position: relative;
|
||||||
`);
|
`);
|
||||||
|
|
||||||
|
const cssTitle = styled(cssModalTitle, `
|
||||||
|
margin: 40px 64px 0 64px;
|
||||||
|
|
||||||
|
@media ${mediaXSmall} {
|
||||||
|
& {
|
||||||
|
margin: 16px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`);
|
||||||
|
|
||||||
|
const cssMemberPublicAccess = styled(cssMemberSecondary, `
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
`);
|
||||||
|
|
||||||
// Render the UserManager title for `resourceType` (e.g. org as "team site").
|
// Render the UserManager title for `resourceType` (e.g. org as "team site").
|
||||||
function renderTitle(resourceType: ResourceType, resource?: Resource, personal?: boolean) {
|
function renderTitle(resourceType: ResourceType, resource?: Resource, personal?: boolean) {
|
||||||
switch (resourceType) {
|
switch (resourceType) {
|
||||||
|
@ -152,7 +152,10 @@ export interface PermissionDelta {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export interface PermissionData {
|
export interface PermissionData {
|
||||||
personal?: boolean;
|
// True if permission data is restricted to current user.
|
||||||
|
personal?: true;
|
||||||
|
// True if current user is a public member.
|
||||||
|
public?: boolean;
|
||||||
maxInheritedRole?: roles.BasicRole|null;
|
maxInheritedRole?: roles.BasicRole|null;
|
||||||
users: UserAccessData[];
|
users: UserAccessData[];
|
||||||
}
|
}
|
||||||
|
@ -4271,19 +4271,29 @@ export class HomeDBManager extends EventEmitter {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
private _filterAccessData(scope: Scope, users: UserAccessData[],
|
private _filterAccessData(
|
||||||
maxInheritedRole: roles.BasicRole|null, docId?: string): { personal: true}|undefined {
|
scope: Scope,
|
||||||
|
users: UserAccessData[],
|
||||||
|
maxInheritedRole: roles.BasicRole|null,
|
||||||
|
docId?: string
|
||||||
|
): {personal: true, public: boolean}|undefined {
|
||||||
if (scope.userId === this.getPreviewerUserId()) { return; }
|
if (scope.userId === this.getPreviewerUserId()) { return; }
|
||||||
// Unless we have special access to the resource, or are an owner,
|
|
||||||
// limit user information returned to being about the current user.
|
// If we have special access to the resource, don't filter user information.
|
||||||
const thisUser = users.find(user => user.id === scope.userId);
|
if (scope.specialPermit?.docId === docId && docId) { return; }
|
||||||
if ((scope.specialPermit?.docId !== docId || !docId) &&
|
|
||||||
(!thisUser || getRealAccess(thisUser, { maxInheritedRole, users }) !== 'owners')) {
|
const thisUser = this.getAnonymousUserId() === scope.userId
|
||||||
// If not an owner, don't return information about other users.
|
? null
|
||||||
users.length = 0;
|
: users.find(user => user.id === scope.userId);
|
||||||
if (thisUser) { users.push(thisUser); }
|
const realAccess = thisUser ? getRealAccess(thisUser, { maxInheritedRole, users }) : null;
|
||||||
return { personal: true };
|
|
||||||
}
|
// If we are an owner, don't filter user information.
|
||||||
|
if (thisUser && realAccess === 'owners') { return; }
|
||||||
|
|
||||||
|
// Limit user information returned to being about the current user.
|
||||||
|
users.length = 0;
|
||||||
|
if (thisUser) { users.push(thisUser); }
|
||||||
|
return { personal: true, public: !realAccess };
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Loading…
Reference in New Issue
Block a user