diff --git a/app/client/ui/DocMenu.ts b/app/client/ui/DocMenu.ts index e2e149f0..997919c4 100644 --- a/app/client/ui/DocMenu.ts +++ b/app/client/ui/DocMenu.ts @@ -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, 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, 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, diff --git a/app/client/ui/LeftPanelCommon.ts b/app/client/ui/LeftPanelCommon.ts index 533fd9e8..43c6bd0d 100644 --- a/app/client/ui/LeftPanelCommon.ts +++ b/app/client/ui/LeftPanelCommon.ts @@ -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; } diff --git a/app/client/ui/PinnedDocs.ts b/app/client/ui/PinnedDocs.ts index 7077749c..7088fe0e 100644 --- a/app/client/ui/PinnedDocs.ts +++ b/app/client/ui/PinnedDocs.ts @@ -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}; + } `); diff --git a/app/common/UserAPI.ts b/app/common/UserAPI.ts index c10486ad..7b3ff242 100644 --- a/app/common/UserAPI.ts +++ b/app/common/UserAPI.ts @@ -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[]; } diff --git a/app/gen-server/lib/HomeDBManager.ts b/app/gen-server/lib/HomeDBManager.ts index f8815b6e..ab7948be 100644 --- a/app/gen-server/lib/HomeDBManager.ts +++ b/app/gen-server/lib/HomeDBManager.ts @@ -1016,7 +1016,7 @@ export class HomeDBManager extends EventEmitter { options: QueryOptions = {}): Promise> { 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(user.parentAccess, 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> { + public async getWorkspaceAccess(scope: Scope, wsId: number, + realAccess?: boolean): Promise> { // 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> { // 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 = {}) {