#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") 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 ?

View File

@ -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, `

View File

@ -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,

View File

@ -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;
} }

View File

@ -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};
}
`); `);

View File

@ -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[];
} }

View File

@ -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> = {}) {