#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:
Florentina Petcu 2024-06-14 15:42:14 +03:00
parent a1b5358c86
commit acc88fe36c
7 changed files with 98 additions and 18 deletions

View File

@ -137,7 +137,10 @@ function createLoadedDocMenu(owner: IDisposableOwner, home: HomeModel) {
hasFeaturedTemplates ? t("More Examples and Templates") : t("Examples and Templates")
) :
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'),
)
@ -201,7 +204,9 @@ function buildAllDocsBlock(
return css.docBlock(
css.docBlockHeaderLink(
css.wsLeft(
css.docHeaderIcon('Folder'),
css.docHeaderIcon('Folder',
css.docHeaderIcon.cls(ws.shareType ? `-${ws.shareType}` : '')
),
workspaceName(home.app, ws),
),
@ -278,7 +283,9 @@ function buildAllTemplates(home: HomeModel, templateWorkspaces: Observable<Works
return css.templatesDocBlock(
css.templateBlockHeader(
css.wsLeft(
css.docHeaderIcon('Folder'),
css.docHeaderIcon('Folder',
css.docHeaderIcon.cls(workspace.shareType ? `-${workspace.shareType}` : '')
),
workspace.name,
),
testId('templates-header'),
@ -403,7 +410,9 @@ function buildWorkspaceDocBlock(home: HomeModel, workspace: Workspace, flashDocI
css.docLeft(
css.docName(doc.name, testId('doc-name')),
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(
(doc.removedAt ?

View File

@ -1,5 +1,5 @@
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 {styled} from 'grainjs';
import {bigBasicButton} from 'app/client/ui2018/buttons';
@ -123,6 +123,12 @@ export const docHeaderIcon = styled(icon, `
margin-right: 8px;
margin-top: -3px;
--icon-color: ${theme.lightText};
&-everyone {
--icon-color: ${colors.lightGreen};
}
&-others {
--icon-color: ${colors.warning};
}
`);
export const pinnedDocsIcon = styled(docHeaderIcon, `
@ -227,7 +233,13 @@ export const docPinIcon = styled(icon, `
export const docPublicIcon = styled(icon, `
flex: none;
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, `

View File

@ -74,7 +74,10 @@ export function createHomeLeftPane(leftPanelOpen: Observable<boolean>, home: Hom
dom.autoDispose(isTrivial),
dom.hide(isTrivial),
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),
urlState().setLinkUrl({ws: ws.id}),
// 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),
dom.maybe(isRenaming, () =>
cssPageLink(cssPageIcon('Folder'),
cssPageLink(
cssPageIcon('Folder',
cssPageIcon.cls(ws.shareType ? `-${ws.shareType}` : '')
),
cssEditorInput({
initialValue: ws.name || '',
save: async (val) => (val !== ws.name) ? home.renameWorkspace(ws.id, val) : undefined,

View File

@ -16,7 +16,7 @@
import {beaconOpenMessage} from 'app/client/lib/helpScout';
import {makeT} from 'app/client/lib/localization';
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 {commonUrls, isFeatureEnabled} from 'app/common/gristUrls';
import {getGristConfig} from 'app/common/urlUtils';
@ -155,6 +155,13 @@ export const cssLinkText = styled('span', `
export const cssPageIcon = styled(icon, `
flex: none;
margin-right: var(--page-icon-margin, 8px);
--icon-color: ${theme.lightText};
&-everyone {
--icon-color: ${colors.lightGreen};
}
&-others {
--icon-color: ${colors.warning};
}
.${cssTools.className}-collapsed & {
margin-right: 0;
}

View File

@ -45,7 +45,9 @@ export function buildPinnedDoc(home: HomeModel, doc: Document, workspace: Worksp
cssImage({src: doc.options.icon}) :
[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)),
),
pinnedDocFooter(
@ -274,5 +276,11 @@ const cssPublicIcon = styled(icon, `
position: absolute;
top: 16px;
left: 16px;
--icon-color: ${theme.accentIcon};
--icon-color: ${theme.lightText};
&-everyone {
--icon-color: ${colors.lightGreen};
}
&-others {
--icon-color: ${colors.warning};
}
`);

View File

@ -116,6 +116,7 @@ export interface Workspace extends WorkspaceProperties {
org: Organization;
orgDomain?: string;
access: roles.Role;
shareType?: string;
owner?: FullUser; // Set when workspaces are in the "docs" pseudo-organization,
// assembled from multiple personal organizations.
// Not set when workspaces are all from the same organization.
@ -158,6 +159,7 @@ export interface Document extends DocumentProperties {
id: string;
workspace: Workspace;
access: roles.Role;
shareType?: string;
trunkAccess?: roles.Role|null;
forks?: Fork[];
}

View File

@ -1016,7 +1016,7 @@ export class HomeDBManager extends EventEmitter {
options: QueryOptions = {}): Promise<QueryResult<Workspace[]>> {
const query = this._orgWorkspaces(scope, orgKey, options);
// 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 result = await this._verifyAclPermissions(query, { scope, emptyAllowed });
// Return the workspaces, not the org(s).
@ -1027,6 +1027,39 @@ export class HomeDBManager extends EventEmitter {
ws.owner = o.owner;
// Include the org's domain so that the UI can build doc URLs that include the org.
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
@ -2421,7 +2454,8 @@ export class HomeDBManager extends EventEmitter {
// 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
// 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
// against changes to the workspace or org while we are querying.
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 personal = this._filterAccessData(scope, users, maxInheritedRole);
const personal = this._filterAccessData(scope, users, maxInheritedRole, realAccess);
return {
status: 200,
data: {
@ -2498,6 +2532,7 @@ export class HomeDBManager extends EventEmitter {
public async getDocAccess(scope: DocScope, options?: {
flatten?: boolean,
excludeUsersWithoutAccess?: boolean,
realAccess?: boolean
}): Promise<QueryResult<PermissionData>> {
// 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
@ -2550,7 +2585,7 @@ export class HomeDBManager extends EventEmitter {
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
// have been flattened.
@ -4867,6 +4902,7 @@ export class HomeDBManager extends EventEmitter {
scope: Scope,
users: UserAccessData[],
maxInheritedRole: roles.BasicRole|null,
realAccess?: boolean,
docId?: string
): {personal: true, public: boolean}|undefined {
if (scope.userId === this.getPreviewerUserId()) { return; }
@ -4877,15 +4913,15 @@ export class HomeDBManager extends EventEmitter {
const thisUser = this.getAnonymousUserId() === scope.userId
? null
: 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 (thisUser && realAccess === 'owners') { return; }
if ((thisUser && realAccessUser === 'owners') || realAccess) { return; }
// Limit user information returned to being about the current user.
users.length = 0;
if (thisUser) { users.push(thisUser); }
return { personal: true, public: !realAccess };
return { personal: true, public: !realAccessUser };
}
private _getWorkspaceWithACLRules(scope: Scope, wsId: number, options: Partial<QueryOptions> = {}) {