mirror of
https://github.com/gristlabs/grist-core.git
synced 2025-06-13 20:53:59 +00:00
#1047 access signaling using workspace/doc icon
This PR signals access of each workspace and document using their icons. The color of icon is different for each access level: * shared with everyone or anonymous -> icon is green; * shared with other people than yourself (the owner) but not with everybody -> icon is yellow; * shared only with you (the owner)-> icon is gray.
This commit is contained in:
parent
a1b5358c86
commit
acc88fe36c
@ -137,7 +137,10 @@ function createLoadedDocMenu(owner: IDisposableOwner, home: HomeModel) {
|
|||||||
hasFeaturedTemplates ? t("More Examples and Templates") : t("Examples and Templates")
|
hasFeaturedTemplates ? t("More Examples and Templates") : t("Examples and Templates")
|
||||||
) :
|
) :
|
||||||
page === 'trash' ? t("Trash") :
|
page === 'trash' ? t("Trash") :
|
||||||
workspace && [css.docHeaderIcon('Folder'), workspaceName(home.app, workspace)]
|
workspace && [css.docHeaderIcon('Folder',
|
||||||
|
css.docHeaderIcon.cls(workspace.shareType ? `-${workspace.shareType}` : '')
|
||||||
|
),
|
||||||
|
workspaceName(home.app, workspace)]
|
||||||
),
|
),
|
||||||
testId('doc-header'),
|
testId('doc-header'),
|
||||||
)
|
)
|
||||||
@ -201,7 +204,9 @@ function buildAllDocsBlock(
|
|||||||
return css.docBlock(
|
return css.docBlock(
|
||||||
css.docBlockHeaderLink(
|
css.docBlockHeaderLink(
|
||||||
css.wsLeft(
|
css.wsLeft(
|
||||||
css.docHeaderIcon('Folder'),
|
css.docHeaderIcon('Folder',
|
||||||
|
css.docHeaderIcon.cls(ws.shareType ? `-${ws.shareType}` : '')
|
||||||
|
),
|
||||||
workspaceName(home.app, ws),
|
workspaceName(home.app, ws),
|
||||||
),
|
),
|
||||||
|
|
||||||
@ -278,7 +283,9 @@ function buildAllTemplates(home: HomeModel, templateWorkspaces: Observable<Works
|
|||||||
return css.templatesDocBlock(
|
return css.templatesDocBlock(
|
||||||
css.templateBlockHeader(
|
css.templateBlockHeader(
|
||||||
css.wsLeft(
|
css.wsLeft(
|
||||||
css.docHeaderIcon('Folder'),
|
css.docHeaderIcon('Folder',
|
||||||
|
css.docHeaderIcon.cls(workspace.shareType ? `-${workspace.shareType}` : '')
|
||||||
|
),
|
||||||
workspace.name,
|
workspace.name,
|
||||||
),
|
),
|
||||||
testId('templates-header'),
|
testId('templates-header'),
|
||||||
@ -403,7 +410,9 @@ function buildWorkspaceDocBlock(home: HomeModel, workspace: Workspace, flashDocI
|
|||||||
css.docLeft(
|
css.docLeft(
|
||||||
css.docName(doc.name, testId('doc-name')),
|
css.docName(doc.name, testId('doc-name')),
|
||||||
css.docPinIcon('PinSmall', dom.show(doc.isPinned)),
|
css.docPinIcon('PinSmall', dom.show(doc.isPinned)),
|
||||||
doc.public ? css.docPublicIcon('Public', testId('public')) : null,
|
doc.public ? css.docPublicIcon('Public',
|
||||||
|
css.docPublicIcon.cls(doc.shareType ? `-${doc.shareType}` : ''),
|
||||||
|
testId('public')) : null,
|
||||||
),
|
),
|
||||||
css.docRowUpdatedAt(
|
css.docRowUpdatedAt(
|
||||||
(doc.removedAt ?
|
(doc.removedAt ?
|
||||||
|
@ -1,5 +1,5 @@
|
|||||||
import {transientInput} from 'app/client/ui/transientInput';
|
import {transientInput} from 'app/client/ui/transientInput';
|
||||||
import {mediaSmall, theme, vars} from 'app/client/ui2018/cssVars';
|
import {colors, mediaSmall, theme, vars} from 'app/client/ui2018/cssVars';
|
||||||
import {icon} from 'app/client/ui2018/icons';
|
import {icon} from 'app/client/ui2018/icons';
|
||||||
import {styled} from 'grainjs';
|
import {styled} from 'grainjs';
|
||||||
import {bigBasicButton} from 'app/client/ui2018/buttons';
|
import {bigBasicButton} from 'app/client/ui2018/buttons';
|
||||||
@ -123,6 +123,12 @@ export const docHeaderIcon = styled(icon, `
|
|||||||
margin-right: 8px;
|
margin-right: 8px;
|
||||||
margin-top: -3px;
|
margin-top: -3px;
|
||||||
--icon-color: ${theme.lightText};
|
--icon-color: ${theme.lightText};
|
||||||
|
&-everyone {
|
||||||
|
--icon-color: ${colors.lightGreen};
|
||||||
|
}
|
||||||
|
&-others {
|
||||||
|
--icon-color: ${colors.warning};
|
||||||
|
}
|
||||||
`);
|
`);
|
||||||
|
|
||||||
export const pinnedDocsIcon = styled(docHeaderIcon, `
|
export const pinnedDocsIcon = styled(docHeaderIcon, `
|
||||||
@ -227,7 +233,13 @@ export const docPinIcon = styled(icon, `
|
|||||||
export const docPublicIcon = styled(icon, `
|
export const docPublicIcon = styled(icon, `
|
||||||
flex: none;
|
flex: none;
|
||||||
margin-left: auto;
|
margin-left: auto;
|
||||||
--icon-color: ${theme.accentIcon};
|
--icon-color: ${theme.lightText};
|
||||||
|
&-everyone {
|
||||||
|
--icon-color: ${colors.lightGreen};
|
||||||
|
}
|
||||||
|
&-others {
|
||||||
|
--icon-color: ${colors.warning};
|
||||||
|
}
|
||||||
`);
|
`);
|
||||||
|
|
||||||
export const docEditorInput = styled(transientInput, `
|
export const docEditorInput = styled(transientInput, `
|
||||||
|
@ -74,7 +74,10 @@ export function createHomeLeftPane(leftPanelOpen: Observable<boolean>, home: Hom
|
|||||||
dom.autoDispose(isTrivial),
|
dom.autoDispose(isTrivial),
|
||||||
dom.hide(isTrivial),
|
dom.hide(isTrivial),
|
||||||
cssPageEntry.cls('-selected', (use) => use(home.currentWSId) === ws.id),
|
cssPageEntry.cls('-selected', (use) => use(home.currentWSId) === ws.id),
|
||||||
cssPageLink(cssPageIcon('Folder'), cssLinkText(workspaceName(home.app, ws)),
|
cssPageLink(
|
||||||
|
cssPageIcon('Folder',
|
||||||
|
cssPageIcon.cls(ws.shareType ? `-${ws.shareType}` : '')
|
||||||
|
), cssLinkText(workspaceName(home.app, ws)),
|
||||||
dom.hide(isRenaming),
|
dom.hide(isRenaming),
|
||||||
urlState().setLinkUrl({ws: ws.id}),
|
urlState().setLinkUrl({ws: ws.id}),
|
||||||
// Don't show menu if workspace is personal and shared by another user; we could
|
// Don't show menu if workspace is personal and shared by another user; we could
|
||||||
@ -93,7 +96,10 @@ export function createHomeLeftPane(leftPanelOpen: Observable<boolean>, home: Hom
|
|||||||
),
|
),
|
||||||
cssPageEntry.cls('-renaming', isRenaming),
|
cssPageEntry.cls('-renaming', isRenaming),
|
||||||
dom.maybe(isRenaming, () =>
|
dom.maybe(isRenaming, () =>
|
||||||
cssPageLink(cssPageIcon('Folder'),
|
cssPageLink(
|
||||||
|
cssPageIcon('Folder',
|
||||||
|
cssPageIcon.cls(ws.shareType ? `-${ws.shareType}` : '')
|
||||||
|
),
|
||||||
cssEditorInput({
|
cssEditorInput({
|
||||||
initialValue: ws.name || '',
|
initialValue: ws.name || '',
|
||||||
save: async (val) => (val !== ws.name) ? home.renameWorkspace(ws.id, val) : undefined,
|
save: async (val) => (val !== ws.name) ? home.renameWorkspace(ws.id, val) : undefined,
|
||||||
|
@ -16,7 +16,7 @@
|
|||||||
import {beaconOpenMessage} from 'app/client/lib/helpScout';
|
import {beaconOpenMessage} from 'app/client/lib/helpScout';
|
||||||
import {makeT} from 'app/client/lib/localization';
|
import {makeT} from 'app/client/lib/localization';
|
||||||
import {AppModel} from 'app/client/models/AppModel';
|
import {AppModel} from 'app/client/models/AppModel';
|
||||||
import {testId, theme, vars} from 'app/client/ui2018/cssVars';
|
import {colors, testId, theme, vars} from 'app/client/ui2018/cssVars';
|
||||||
import {icon} from 'app/client/ui2018/icons';
|
import {icon} from 'app/client/ui2018/icons';
|
||||||
import {commonUrls, isFeatureEnabled} from 'app/common/gristUrls';
|
import {commonUrls, isFeatureEnabled} from 'app/common/gristUrls';
|
||||||
import {getGristConfig} from 'app/common/urlUtils';
|
import {getGristConfig} from 'app/common/urlUtils';
|
||||||
@ -155,6 +155,13 @@ export const cssLinkText = styled('span', `
|
|||||||
export const cssPageIcon = styled(icon, `
|
export const cssPageIcon = styled(icon, `
|
||||||
flex: none;
|
flex: none;
|
||||||
margin-right: var(--page-icon-margin, 8px);
|
margin-right: var(--page-icon-margin, 8px);
|
||||||
|
--icon-color: ${theme.lightText};
|
||||||
|
&-everyone {
|
||||||
|
--icon-color: ${colors.lightGreen};
|
||||||
|
}
|
||||||
|
&-others {
|
||||||
|
--icon-color: ${colors.warning};
|
||||||
|
}
|
||||||
.${cssTools.className}-collapsed & {
|
.${cssTools.className}-collapsed & {
|
||||||
margin-right: 0;
|
margin-right: 0;
|
||||||
}
|
}
|
||||||
|
@ -45,7 +45,9 @@ export function buildPinnedDoc(home: HomeModel, doc: Document, workspace: Worksp
|
|||||||
cssImage({src: doc.options.icon}) :
|
cssImage({src: doc.options.icon}) :
|
||||||
[docInitials(doc.name), pinnedDocThumbnail()]
|
[docInitials(doc.name), pinnedDocThumbnail()]
|
||||||
),
|
),
|
||||||
(doc.public && !isExample ? cssPublicIcon('PublicFilled', testId('public')) : null),
|
(!isExample ? cssPublicIcon('PublicFilled',
|
||||||
|
cssPublicIcon.cls(doc.shareType ? `-${doc.shareType}` : ''),
|
||||||
|
testId('public')) : null),
|
||||||
pinnedDocPreview.cls('-with-icon', Boolean(doc.options?.icon)),
|
pinnedDocPreview.cls('-with-icon', Boolean(doc.options?.icon)),
|
||||||
),
|
),
|
||||||
pinnedDocFooter(
|
pinnedDocFooter(
|
||||||
@ -274,5 +276,11 @@ const cssPublicIcon = styled(icon, `
|
|||||||
position: absolute;
|
position: absolute;
|
||||||
top: 16px;
|
top: 16px;
|
||||||
left: 16px;
|
left: 16px;
|
||||||
--icon-color: ${theme.accentIcon};
|
--icon-color: ${theme.lightText};
|
||||||
|
&-everyone {
|
||||||
|
--icon-color: ${colors.lightGreen};
|
||||||
|
}
|
||||||
|
&-others {
|
||||||
|
--icon-color: ${colors.warning};
|
||||||
|
}
|
||||||
`);
|
`);
|
||||||
|
@ -116,6 +116,7 @@ export interface Workspace extends WorkspaceProperties {
|
|||||||
org: Organization;
|
org: Organization;
|
||||||
orgDomain?: string;
|
orgDomain?: string;
|
||||||
access: roles.Role;
|
access: roles.Role;
|
||||||
|
shareType?: string;
|
||||||
owner?: FullUser; // Set when workspaces are in the "docs" pseudo-organization,
|
owner?: FullUser; // Set when workspaces are in the "docs" pseudo-organization,
|
||||||
// assembled from multiple personal organizations.
|
// assembled from multiple personal organizations.
|
||||||
// Not set when workspaces are all from the same organization.
|
// Not set when workspaces are all from the same organization.
|
||||||
@ -158,6 +159,7 @@ export interface Document extends DocumentProperties {
|
|||||||
id: string;
|
id: string;
|
||||||
workspace: Workspace;
|
workspace: Workspace;
|
||||||
access: roles.Role;
|
access: roles.Role;
|
||||||
|
shareType?: string;
|
||||||
trunkAccess?: roles.Role|null;
|
trunkAccess?: roles.Role|null;
|
||||||
forks?: Fork[];
|
forks?: Fork[];
|
||||||
}
|
}
|
||||||
|
@ -1016,7 +1016,7 @@ export class HomeDBManager extends EventEmitter {
|
|||||||
options: QueryOptions = {}): Promise<QueryResult<Workspace[]>> {
|
options: QueryOptions = {}): Promise<QueryResult<Workspace[]>> {
|
||||||
const query = this._orgWorkspaces(scope, orgKey, options);
|
const query = this._orgWorkspaces(scope, orgKey, options);
|
||||||
// Allow an empty result for the merged org for the anonymous user. The anonymous user
|
// Allow an empty result for the merged org for the anonymous user. The anonymous user
|
||||||
// has no home org or workspace. For all other sitations, expect at least one workspace.
|
// has no home org or workspace. For all other situations, expect at least one workspace.
|
||||||
const emptyAllowed = this.isMergedOrg(orgKey) && scope.userId === this.getAnonymousUserId();
|
const emptyAllowed = this.isMergedOrg(orgKey) && scope.userId === this.getAnonymousUserId();
|
||||||
const result = await this._verifyAclPermissions(query, { scope, emptyAllowed });
|
const result = await this._verifyAclPermissions(query, { scope, emptyAllowed });
|
||||||
// Return the workspaces, not the org(s).
|
// Return the workspaces, not the org(s).
|
||||||
@ -1027,6 +1027,39 @@ export class HomeDBManager extends EventEmitter {
|
|||||||
ws.owner = o.owner;
|
ws.owner = o.owner;
|
||||||
// Include the org's domain so that the UI can build doc URLs that include the org.
|
// Include the org's domain so that the UI can build doc URLs that include the org.
|
||||||
ws.orgDomain = o.domain;
|
ws.orgDomain = o.domain;
|
||||||
|
|
||||||
|
// Include shareType based on users permissions to set icon color
|
||||||
|
const {maxInheritedRole, users } = this.unwrapQueryResult(
|
||||||
|
await this.getWorkspaceAccess({userId: scope.userId}, ws.id, true)
|
||||||
|
);
|
||||||
|
const permissionDataUsers = users.filter((user) => {
|
||||||
|
// effectiveRole - Gives the effective role of the member on the resource, taking everything into account.
|
||||||
|
return !!roles.getStrongestRole(user.access,
|
||||||
|
roles.getWeakestRole(<roles.Role>user.parentAccess, <roles.Role>maxInheritedRole));
|
||||||
|
});
|
||||||
|
if (permissionDataUsers?.length > 1) {
|
||||||
|
ws.shareType = permissionDataUsers.find((user) => user.email === EVERYONE_EMAIL
|
||||||
|
|| user.email === ANONYMOUS_USER_EMAIL)
|
||||||
|
? 'everyone'
|
||||||
|
: permissionDataUsers.find((user) => user.id !== scope.userId)
|
||||||
|
? 'others'
|
||||||
|
: '';
|
||||||
|
|
||||||
|
for(const doc of ws.docs) {
|
||||||
|
const permissionDataUsersDoc = this.unwrapQueryResult(
|
||||||
|
await this.getDocAccess({userId: scope.userId, urlId: doc.urlId}, {
|
||||||
|
flatten: true, excludeUsersWithoutAccess: true, realAccess: true
|
||||||
|
})).users;
|
||||||
|
if (permissionDataUsersDoc && permissionDataUsersDoc.length > 1) {
|
||||||
|
doc.shareType = permissionDataUsersDoc.find((user) => user.email === EVERYONE_EMAIL
|
||||||
|
|| user.email === ANONYMOUS_USER_EMAIL)
|
||||||
|
? 'everyone'
|
||||||
|
: permissionDataUsersDoc.find((user) => user.id !== scope.userId)
|
||||||
|
? 'others'
|
||||||
|
: '';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// For org-specific requests, we still have the org's workspaces, plus the Samples workspace
|
// For org-specific requests, we still have the org's workspaces, plus the Samples workspace
|
||||||
@ -2421,7 +2454,8 @@ export class HomeDBManager extends EventEmitter {
|
|||||||
// Returns UserAccessData for all users with any permissions on the ORG, as well as the
|
// Returns UserAccessData for all users with any permissions on the ORG, as well as the
|
||||||
// maxInheritedRole set on the workspace. Note that information for all users in the org
|
// maxInheritedRole set on the workspace. Note that information for all users in the org
|
||||||
// is given to indicate which users have access to the org but not to this particular workspace.
|
// is given to indicate which users have access to the org but not to this particular workspace.
|
||||||
public async getWorkspaceAccess(scope: Scope, wsId: number): Promise<QueryResult<PermissionData>> {
|
public async getWorkspaceAccess(scope: Scope, wsId: number,
|
||||||
|
realAccess?: boolean): Promise<QueryResult<PermissionData>> {
|
||||||
// Run the query for the workspace and org in a transaction. This brings some isolation protection
|
// Run the query for the workspace and org in a transaction. This brings some isolation protection
|
||||||
// against changes to the workspace or org while we are querying.
|
// against changes to the workspace or org while we are querying.
|
||||||
const { workspace, org, queryFailure } = await this._connection.transaction(async manager => {
|
const { workspace, org, queryFailure } = await this._connection.transaction(async manager => {
|
||||||
@ -2467,7 +2501,7 @@ export class HomeDBManager extends EventEmitter {
|
|||||||
};
|
};
|
||||||
});
|
});
|
||||||
const maxInheritedRole = this._getMaxInheritedRole(workspace);
|
const maxInheritedRole = this._getMaxInheritedRole(workspace);
|
||||||
const personal = this._filterAccessData(scope, users, maxInheritedRole);
|
const personal = this._filterAccessData(scope, users, maxInheritedRole, realAccess);
|
||||||
return {
|
return {
|
||||||
status: 200,
|
status: 200,
|
||||||
data: {
|
data: {
|
||||||
@ -2498,6 +2532,7 @@ export class HomeDBManager extends EventEmitter {
|
|||||||
public async getDocAccess(scope: DocScope, options?: {
|
public async getDocAccess(scope: DocScope, options?: {
|
||||||
flatten?: boolean,
|
flatten?: boolean,
|
||||||
excludeUsersWithoutAccess?: boolean,
|
excludeUsersWithoutAccess?: boolean,
|
||||||
|
realAccess?: boolean
|
||||||
}): Promise<QueryResult<PermissionData>> {
|
}): Promise<QueryResult<PermissionData>> {
|
||||||
// Doc permissions of forks are based on the "trunk" document, so make sure
|
// Doc permissions of forks are based on the "trunk" document, so make sure
|
||||||
// we look up permissions of trunk if we are on a fork (we'll fix the permissions
|
// we look up permissions of trunk if we are on a fork (we'll fix the permissions
|
||||||
@ -2550,7 +2585,7 @@ export class HomeDBManager extends EventEmitter {
|
|||||||
maxInheritedRole = null;
|
maxInheritedRole = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const personal = this._filterAccessData(scope, users, maxInheritedRole, doc.id);
|
const personal = this._filterAccessData(scope, users, maxInheritedRole, options?.realAccess, doc.id);
|
||||||
|
|
||||||
// If we are on a fork, make any access changes needed. Assumes results
|
// If we are on a fork, make any access changes needed. Assumes results
|
||||||
// have been flattened.
|
// have been flattened.
|
||||||
@ -4867,6 +4902,7 @@ export class HomeDBManager extends EventEmitter {
|
|||||||
scope: Scope,
|
scope: Scope,
|
||||||
users: UserAccessData[],
|
users: UserAccessData[],
|
||||||
maxInheritedRole: roles.BasicRole|null,
|
maxInheritedRole: roles.BasicRole|null,
|
||||||
|
realAccess?: boolean,
|
||||||
docId?: string
|
docId?: string
|
||||||
): {personal: true, public: boolean}|undefined {
|
): {personal: true, public: boolean}|undefined {
|
||||||
if (scope.userId === this.getPreviewerUserId()) { return; }
|
if (scope.userId === this.getPreviewerUserId()) { return; }
|
||||||
@ -4877,15 +4913,15 @@ export class HomeDBManager extends EventEmitter {
|
|||||||
const thisUser = this.getAnonymousUserId() === scope.userId
|
const thisUser = this.getAnonymousUserId() === scope.userId
|
||||||
? null
|
? null
|
||||||
: users.find(user => user.id === scope.userId);
|
: users.find(user => user.id === scope.userId);
|
||||||
const realAccess = thisUser ? getRealAccess(thisUser, { maxInheritedRole, users }) : null;
|
const realAccessUser = thisUser ? getRealAccess(thisUser, { maxInheritedRole, users }) : null;
|
||||||
|
|
||||||
// If we are an owner, don't filter user information.
|
// If we are an owner, don't filter user information.
|
||||||
if (thisUser && realAccess === 'owners') { return; }
|
if ((thisUser && realAccessUser === 'owners') || realAccess) { return; }
|
||||||
|
|
||||||
// Limit user information returned to being about the current user.
|
// Limit user information returned to being about the current user.
|
||||||
users.length = 0;
|
users.length = 0;
|
||||||
if (thisUser) { users.push(thisUser); }
|
if (thisUser) { users.push(thisUser); }
|
||||||
return { personal: true, public: !realAccess };
|
return { personal: true, public: !realAccessUser };
|
||||||
}
|
}
|
||||||
|
|
||||||
private _getWorkspaceWithACLRules(scope: Scope, wsId: number, options: Partial<QueryOptions> = {}) {
|
private _getWorkspaceWithACLRules(scope: Scope, wsId: number, options: Partial<QueryOptions> = {}) {
|
||||||
|
Loading…
Reference in New Issue
Block a user