mirror of
https://github.com/gristlabs/grist-core.git
synced 2026-03-02 04:09:24 +00:00
(core) allow non-owners to remove themselves from sites/workspaces/docs
Summary: For users who cannot otherwise change access to a resource, let them remove themselves. Implemented via the standard endpoints as a special exception that will process a request from a user that would otherwise be denied, if the only contents of that request are a removal of themselves. Users who can change access are still not permitted to change their own permissions or to remove themselves, as a precaution against orphaning resources. Test Plan: extended and updated tests Reviewers: cyprien Reviewed By: cyprien Subscribers: dsagal Differential Revision: https://phab.getgrist.com/D3367
This commit is contained in:
@@ -5,8 +5,8 @@ import {ShareAnnotations, ShareAnnotator} from 'app/common/ShareAnnotator';
|
||||
import {normalizeEmail} from 'app/common/emails';
|
||||
import {GristLoadConfig} from 'app/common/gristUrls';
|
||||
import * as roles from 'app/common/roles';
|
||||
import {ANONYMOUS_USER_EMAIL, EVERYONE_EMAIL, Organization, PermissionData, PermissionDelta,
|
||||
UserAPI, Workspace} from 'app/common/UserAPI';
|
||||
import {ANONYMOUS_USER_EMAIL, Document, EVERYONE_EMAIL, Organization,
|
||||
PermissionData, PermissionDelta, UserAPI, Workspace} from 'app/common/UserAPI';
|
||||
import {getRealAccess} from 'app/common/UserAPI';
|
||||
import {computed, Computed, Disposable, obsArray, ObsArray, observable, Observable} from 'grainjs';
|
||||
import some = require('lodash/some');
|
||||
@@ -22,8 +22,10 @@ export interface UserManagerModel {
|
||||
publicMember: IEditableMember|null; // Member whose access (VIEWER or null) represents that of
|
||||
// anon@ or everyone@ (depending on the settings and resource).
|
||||
isAnythingChanged: Computed<boolean>; // Indicates whether there are unsaved changes
|
||||
isSelfRemoved: Computed<boolean>; // Indicates whether current user is removed
|
||||
isOrg: boolean; // Indicates if the UserManager is for an org
|
||||
annotations: Observable<ShareAnnotations>; // More information about shares, keyed by email.
|
||||
isPersonal: boolean; // If user access info/control is restricted to self.
|
||||
|
||||
gristDoc: GristDoc|null; // Populated if there is an open document.
|
||||
|
||||
@@ -119,6 +121,8 @@ export class UserManagerModelImpl extends Disposable implements UserManagerModel
|
||||
|
||||
public annotations = this.autoDispose(observable({users: new Map()}));
|
||||
|
||||
public isPersonal = this.initData.personal || false;
|
||||
|
||||
public isOrg: boolean = this.resourceType === 'organization';
|
||||
|
||||
public gristDoc: GristDoc|null;
|
||||
@@ -133,6 +137,11 @@ export class UserManagerModelImpl extends Disposable implements UserManagerModel
|
||||
(this.publicMember ? isMemberChangedFn(this.publicMember) : false);
|
||||
}));
|
||||
|
||||
// Check if the current user is being removed.
|
||||
public readonly isSelfRemoved: Computed<boolean> = this.autoDispose(computed<boolean>((use) => {
|
||||
return some(use(this.membersEdited), m => m.isRemoved && m.email ===this._options.activeEmail);
|
||||
}));
|
||||
|
||||
private _shareAnnotator?: ShareAnnotator;
|
||||
|
||||
constructor(
|
||||
|
||||
@@ -105,8 +105,9 @@ export class AccountWidget extends Disposable {
|
||||
|
||||
// Show 'Organization Settings' when on a home page of a valid org.
|
||||
(!this._docPageModel && currentOrg && !currentOrg.owner ?
|
||||
menuItem(() => manageUsers(currentOrg), 'Manage Team', testId('dm-org-access'),
|
||||
dom.cls('disabled', !roles.canEditAccess(currentOrg.access))) :
|
||||
menuItem(() => manageUsers(currentOrg),
|
||||
roles.canEditAccess(currentOrg.access) ? 'Manage Team' : 'Access Details',
|
||||
testId('dm-org-access')) :
|
||||
// Don't show on doc pages, or for personal orgs.
|
||||
null),
|
||||
|
||||
|
||||
@@ -37,7 +37,7 @@ export class AppHeader extends Disposable {
|
||||
activeEmail: user ? user.email : null,
|
||||
resourceType: 'organization',
|
||||
resourceId: org.id,
|
||||
resource: org,
|
||||
resource: org
|
||||
});
|
||||
};
|
||||
|
||||
|
||||
@@ -432,6 +432,7 @@ export function makeDocOptionsMenu(home: HomeModel, doc: Document, renaming: Obs
|
||||
activeEmail: user ? user.email : null,
|
||||
resourceType: 'document',
|
||||
resourceId: doc.id,
|
||||
resource: doc,
|
||||
linkToCopy: urlState().makeUrl(docUrl(doc)),
|
||||
reload: () => api.getDocAccess(doc.id),
|
||||
appModel: home.app,
|
||||
@@ -463,10 +464,9 @@ export function makeDocOptionsMenu(home: HomeModel, doc: Document, renaming: Obs
|
||||
dom.cls('disabled', !roles.canEdit(orgAccess)),
|
||||
testId('pin-doc')
|
||||
),
|
||||
menuItem(manageUsers, "Manage Users",
|
||||
dom.cls('disabled', !roles.canEditAccess(doc.access)),
|
||||
menuItem(manageUsers, roles.canEditAccess(doc.access) ? "Manage Users" : "Access Details",
|
||||
testId('doc-access')
|
||||
),
|
||||
)
|
||||
];
|
||||
}
|
||||
|
||||
|
||||
@@ -204,7 +204,8 @@ function workspaceMenu(home: HomeModel, ws: Workspace, renaming: Observable<Work
|
||||
permissionData: api.getWorkspaceAccess(ws.id),
|
||||
activeEmail: user ? user.email : null,
|
||||
resourceType: 'workspace',
|
||||
resourceId: ws.id
|
||||
resourceId: ws.id,
|
||||
resource: ws,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -217,8 +218,8 @@ function workspaceMenu(home: HomeModel, ws: Workspace, renaming: Observable<Work
|
||||
upgradableMenuItem(needUpgrade, deleteWorkspace, "Delete",
|
||||
dom.cls('disabled', user => !roles.canEdit(ws.access)),
|
||||
testId('dm-delete-workspace')),
|
||||
upgradableMenuItem(needUpgrade, manageWorkspaceUsers, "Manage Users",
|
||||
dom.cls('disabled', !roles.canEditAccess(ws.access)),
|
||||
upgradableMenuItem(needUpgrade, manageWorkspaceUsers,
|
||||
roles.canEditAccess(ws.access) ? "Manage Users" : "Access Details",
|
||||
testId('dm-workspace-access')),
|
||||
upgradeText(needUpgrade),
|
||||
];
|
||||
|
||||
@@ -123,8 +123,9 @@ function shareButton(buttonText: string|null, menuCreateFunc: MenuCreateFunc,
|
||||
// Renders "Manage Users" menu item.
|
||||
function menuManageUsers(doc: DocInfo, pageModel: DocPageModel) {
|
||||
return [
|
||||
menuItem(() => manageUsers(doc, pageModel), 'Manage Users',
|
||||
dom.cls('disabled', !roles.canEditAccess(doc.access) || doc.isFork),
|
||||
menuItem(() => manageUsers(doc, pageModel),
|
||||
roles.canEditAccess(doc.access) ? 'Manage Users' : 'Access Details',
|
||||
dom.cls('disabled', doc.isFork),
|
||||
testId('tb-share-option')
|
||||
),
|
||||
menuDivider(),
|
||||
@@ -244,11 +245,14 @@ async function manageUsers(doc: DocInfo, docPageModel: DocPageModel) {
|
||||
activeEmail: user ? user.email : null,
|
||||
resourceType: 'document',
|
||||
resourceId: doc.id,
|
||||
resource: doc,
|
||||
docPageModel,
|
||||
appModel: docPageModel.appModel,
|
||||
linkToCopy: urlState().makeUrl(docUrl(doc)),
|
||||
// On save, re-fetch the document info, to toggle the "Public Access" icon if it changed.
|
||||
onSave: () => docPageModel.refreshCurrentDoc(doc),
|
||||
// Skip if personal, since personal cannot affect "Public Access", and the only
|
||||
// change possible is to remove the user (which would make refreshCurrentDoc fail)
|
||||
onSave: async (personal) => !personal && docPageModel.refreshCurrentDoc(doc),
|
||||
reload: () => api.getDocAccess(doc.id),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -35,7 +35,8 @@ import {colors, testId, vars} from 'app/client/ui2018/cssVars';
|
||||
import {icon} from 'app/client/ui2018/icons';
|
||||
import {cssLink} from 'app/client/ui2018/links';
|
||||
import {inputMenu, menu, menuItem, menuText} from 'app/client/ui2018/menus';
|
||||
import {cssModalBody, cssModalButtons, cssModalTitle, IModalControl, modal} from 'app/client/ui2018/modals';
|
||||
import {confirmModal, cssModalBody, cssModalButtons, cssModalTitle, IModalControl,
|
||||
modal} from 'app/client/ui2018/modals';
|
||||
|
||||
export interface IUserManagerOptions {
|
||||
permissionData: Promise<PermissionData>;
|
||||
@@ -47,7 +48,7 @@ export interface IUserManagerOptions {
|
||||
appModel?: AppModel; // If present, we offer access to a nested team-level dialog.
|
||||
linkToCopy?: string;
|
||||
reload?: () => Promise<PermissionData>;
|
||||
onSave?: () => Promise<unknown>;
|
||||
onSave?: (personal: boolean) => Promise<unknown>;
|
||||
prompt?: { // If set, user manager should open with this email filled in and ready to go.
|
||||
email: string;
|
||||
};
|
||||
@@ -71,22 +72,42 @@ export function showUserManagerModal(userApi: UserAPI, options: IUserManagerOpti
|
||||
|
||||
async function onConfirm(ctl: IModalControl) {
|
||||
const model = modelObs.get();
|
||||
if (model) {
|
||||
if (!model) {
|
||||
ctl.close();
|
||||
return;
|
||||
}
|
||||
const tryToSaveChanges = async () => {
|
||||
// Save changes to the server, reporting any errors to the app.
|
||||
try {
|
||||
if (model.isAnythingChanged.get()) {
|
||||
const isAnythingChanged = model.isAnythingChanged.get();
|
||||
if (isAnythingChanged) {
|
||||
await model.save(userApi, options.resourceId);
|
||||
}
|
||||
await options.onSave?.();
|
||||
await options.onSave?.(model.isPersonal);
|
||||
ctl.close();
|
||||
if (model.isPersonal && isAnythingChanged) {
|
||||
// the only thing an individual without ACL_EDIT rights can do is
|
||||
// remove themselves - so reload.
|
||||
window.location.reload();
|
||||
}
|
||||
} catch (err) {
|
||||
reportError(err);
|
||||
}
|
||||
};
|
||||
if (model.isSelfRemoved.get()) {
|
||||
const name = resourceName(model.resourceType);
|
||||
confirmModal(
|
||||
`You are about to remove your own access to this ${name}`,
|
||||
'Remove my access', tryToSaveChanges,
|
||||
'Once you have removed your own access, ' +
|
||||
'you will not be able to get it back without assistance ' +
|
||||
`from someone else with sufficient access to the ${name}.`);
|
||||
} else {
|
||||
ctl.close();
|
||||
tryToSaveChanges().catch(reportError);
|
||||
}
|
||||
}
|
||||
|
||||
const personal = !roles.canEditAccess(options.resource?.access || null);
|
||||
// Get the model and assign it to the observable. Report errors to the app.
|
||||
getModel(options)
|
||||
.then(model => modelObs.set(model))
|
||||
@@ -97,8 +118,9 @@ export function showUserManagerModal(userApi: UserAPI, options: IUserManagerOpti
|
||||
options.showAnimation ? dom.cls(cssAnimatedModal.className) : null,
|
||||
cssModalTitle(
|
||||
{ style: 'margin: 40px 64px 0 64px;' },
|
||||
renderTitle(options.resourceType, options.resource),
|
||||
(options.resourceType === 'document' ? makeCopyBtn(options.linkToCopy, cssCopyBtn.cls('-header')) : null),
|
||||
renderTitle(options.resourceType, options.resource, personal),
|
||||
((options.resourceType === 'document' && !personal) ?
|
||||
makeCopyBtn(options.linkToCopy, cssCopyBtn.cls('-header')) : null),
|
||||
testId('um-header')
|
||||
),
|
||||
|
||||
@@ -121,7 +143,7 @@ export function showUserManagerModal(userApi: UserAPI, options: IUserManagerOpti
|
||||
dom.on('click', () => ctl.close()),
|
||||
testId('um-cancel')
|
||||
),
|
||||
dom.maybe(use => use(modelObs)?.resourceType === 'document' && use(modelObs)?.gristDoc, () =>
|
||||
dom.maybe(use => use(modelObs)?.resourceType === 'document' && use(modelObs)?.gristDoc && !personal, () =>
|
||||
cssAccessLink({href: urlState().makeUrl({docPage: 'acl'})},
|
||||
dom.text(use => (use(modelObs) && use(use(modelObs)!.isAnythingChanged)) ? 'Save & ' : ''),
|
||||
'Open Access Rules',
|
||||
@@ -159,8 +181,8 @@ export class UserManager extends Disposable {
|
||||
const memberEmail = this.autoDispose(new MemberEmail(this._onAdd.bind(this),
|
||||
this._options.prompt));
|
||||
return [
|
||||
memberEmail.buildDom(),
|
||||
this._buildOptionsDom(),
|
||||
this._model.isPersonal ? null : memberEmail.buildDom(),
|
||||
this._model.isPersonal ? null : this._buildOptionsDom(),
|
||||
this._dom = shadowScroll(
|
||||
testId('um-members'),
|
||||
this._buildPublicAccessMember(),
|
||||
@@ -212,6 +234,7 @@ export class UserManager extends Disposable {
|
||||
// Build a single member row.
|
||||
private _buildMemberDom(member: IEditableMember) {
|
||||
const disableRemove = Computed.create(null, (use) =>
|
||||
this._model.isPersonal ? !member.origAccess :
|
||||
Boolean(this._model.isActiveUser(member) || use(member.inheritedAccess)));
|
||||
return dom('div',
|
||||
dom.autoDispose(disableRemove),
|
||||
@@ -632,9 +655,10 @@ const cssAnimatedModal = styled('div', `
|
||||
`);
|
||||
|
||||
// Render the UserManager title for `resourceType` (e.g. org as "team site").
|
||||
function renderTitle(resourceType: ResourceType, resource?: Resource) {
|
||||
function renderTitle(resourceType: ResourceType, resource?: Resource, personal?: boolean) {
|
||||
switch (resourceType) {
|
||||
case 'organization': {
|
||||
if (personal) { return 'Your role for this team site'; }
|
||||
return [
|
||||
'Manage members of team site',
|
||||
!resource ? null : cssOrgName(
|
||||
@@ -645,7 +669,12 @@ function renderTitle(resourceType: ResourceType, resource?: Resource) {
|
||||
];
|
||||
}
|
||||
default: {
|
||||
return `Invite people to ${resourceType}`;
|
||||
return personal ? `Your role for this ${resourceType}` : `Invite people to ${resourceType}`;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Rename organization to team site.
|
||||
function resourceName(resourceType: ResourceType): string {
|
||||
return resourceType === 'organization' ? 'team site' : resourceType;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user