From cabac3d9d8ffac339607af755908d9f6b926f76a Mon Sep 17 00:00:00 2001 From: Cyprien P Date: Tue, 3 Jan 2023 11:52:25 +0100 Subject: [PATCH] (core) Adds new view as banner Summary: Diff removes view-as pill in the document breadcrumbs and add new view-as banner. Note: Banners are still missing mechanism to handle several banners. As of now both doc-usage and view-as banners could show up at the same time. Test Plan: Refactored existing test. Reviewers: jarek Reviewed By: jarek Subscribers: jarek Differential Revision: https://phab.getgrist.com/D3732 --- app/client/aclui/ACLUsers.ts | 83 +++++++--------- app/client/aclui/AccessRules.ts | 3 +- app/client/components/Banner.ts | 14 ++- app/client/components/ViewAsBanner.ts | 132 ++++++++++++++++++++++++++ app/client/ui/AppUI.ts | 2 + app/client/ui/PagePanels.ts | 55 +++++++++-- app/client/ui/Tools.ts | 54 ----------- app/client/ui/TopBar.ts | 1 - app/client/ui/UserImage.ts | 16 +++- app/client/ui2018/breadcrumbs.ts | 13 --- app/common/UserAPI.ts | 19 +++- app/gen-server/lib/DocApiForwarder.ts | 1 + app/server/lib/ActiveDoc.ts | 2 +- app/server/lib/DocApi.ts | 5 + static/locales/en.client.json | 14 ++- 15 files changed, 282 insertions(+), 132 deletions(-) create mode 100644 app/client/components/ViewAsBanner.ts diff --git a/app/client/aclui/ACLUsers.ts b/app/client/aclui/ACLUsers.ts index 5c549325..7ef4f871 100644 --- a/app/client/aclui/ACLUsers.ts +++ b/app/client/aclui/ACLUsers.ts @@ -1,27 +1,21 @@ -import {copyToClipboard} from 'app/client/lib/copyToClipboard'; import {DocPageModel} from 'app/client/models/DocPageModel'; import {urlState} from 'app/client/models/gristUrlState'; import {createUserImage} from 'app/client/ui/UserImage'; import {cssMemberImage, cssMemberListItem, cssMemberPrimary, cssMemberSecondary, cssMemberText} from 'app/client/ui/UserItem'; -import {basicButton, basicButtonLink} from 'app/client/ui2018/buttons'; -import {testId, theme} from 'app/client/ui2018/cssVars'; -import {icon} from 'app/client/ui2018/icons'; -import {menuCssClass, menuDivider} from 'app/client/ui2018/menus'; +import {testId, theme, vars} from 'app/client/ui2018/cssVars'; +import {menuCssClass} from 'app/client/ui2018/menus'; import {PermissionDataWithExtraUsers} from 'app/common/ActiveDocAPI'; import {userOverrideParams} from 'app/common/gristUrls'; import {FullUser} from 'app/common/LoginSessionAPI'; -import * as roles from 'app/common/roles'; import {ANONYMOUS_USER_EMAIL, EVERYONE_EMAIL} from 'app/common/UserAPI'; import {getRealAccess, UserAccessData} from 'app/common/UserAPI'; import {Disposable, dom, Observable, styled} from 'grainjs'; -import {cssMenu, cssMenuWrap, defaultMenuOptions, IOpenController, setPopupToCreateDom} from 'popweasel'; +import {cssMenu, cssMenuWrap, defaultMenuOptions, IPopupOptions, setPopupToCreateDom} from 'popweasel'; +import {getUserRoleText} from 'app/common/UserAPI'; +import {makeT} from 'app/client/lib/localization'; -const roleNames: {[role: string]: string} = { - [roles.OWNER]: 'Owner', - [roles.EDITOR]: 'Editor', - [roles.VIEWER]: 'Viewer', -}; +const t = makeT('aclui.ViewAsDropdown'); function isSpecialEmail(email: string) { return email === ANONYMOUS_USER_EMAIL || email === EVERYONE_EMAIL; @@ -43,57 +37,53 @@ export class ACLUsersPopup extends Disposable { ...user, access: getRealAccess(user, permissionData), })) - .filter(user => user.access && !isSpecialEmail(user.email)); + .filter(user => user.access && !isSpecialEmail(user.email)) + .filter(user => this._currentUser?.id !== user.id); this._attributeTableUsers = permissionData.attributeTableUsers; this._exampleUsers = permissionData.exampleUsers; this.isInitialized.set(true); } } - public attachPopup(elem: Element) { + public attachPopup(elem: Element, options: IPopupOptions) { setPopupToCreateDom(elem, (ctl) => { - const buildRow = (user: UserAccessData) => this._buildUserRow(user, this._currentUser, ctl); + const buildRow = + (user: UserAccessData) => this._buildUserRow(user); + const buildExampleUserRow = + (user: UserAccessData) => this._buildUserRow(user, {isExampleUser: true}); return cssMenuWrap(cssMenu( dom.cls(menuCssClass), cssUsers.cls(''), + cssHeader(t('ViewAs'), dom.show(this._shareUsers.length > 0)), dom.forEach(this._shareUsers, buildRow), - // Add a divider between users-from-shares and users from attribute tables. - (this._attributeTableUsers.length > 0) ? menuDivider() : null, - dom.forEach(this._attributeTableUsers, buildRow), + (this._attributeTableUsers.length > 0) ? cssHeader(t('UsersFrom')) : null, + dom.forEach(this._attributeTableUsers, buildExampleUserRow), // Include example users only if there are not many "real" users. // It might be better to have an expandable section with these users, collapsed // by default, but that's beyond my UI ken. (this._shareUsers.length + this._attributeTableUsers.length < 5) ? [ - (this._exampleUsers.length > 0) ? menuDivider() : null, - dom.forEach(this._exampleUsers, buildRow) + (this._exampleUsers.length > 0) ? cssHeader(t('ExampleUsers')) : null, + dom.forEach(this._exampleUsers, buildExampleUserRow) ] : null, (el) => { setTimeout(() => el.focus(), 0); }, dom.onKeyDown({Escape: () => ctl.close()}), )); - }, {...defaultMenuOptions, placement: 'bottom-end'}); + }, {...defaultMenuOptions, ...options}); } - private _buildUserRow(user: UserAccessData, currentUser: FullUser|null, ctl: IOpenController) { - const isCurrentUser = Boolean(currentUser && user.id === currentUser.id); - return cssUserItem( + private _buildUserRow(user: UserAccessData, opt: {isExampleUser?: boolean} = {}) { + return dom('a', + {class: cssMemberListItem.className + ' ' + cssUserItem.className}, cssMemberImage( - createUserImage(user, 'large') + createUserImage(opt.isExampleUser ? 'exampleUser' : user, 'large') ), cssMemberText( cssMemberPrimary(user.name || dom('span', user.email), - cssRole('(', roleNames[user.access!] || user.access || 'no access', ')', testId('acl-user-access')), + cssRole('(', getUserRoleText(user), ')', testId('acl-user-access')), ), user.name ? cssMemberSecondary(user.email) : null ), - basicButton(cssUserButton.cls(''), icon('Copy'), 'Copy Email', - testId('acl-user-copy'), - dom.on('click', async (ev, elem) => { await copyToClipboard(user.email); ctl.close(); }), - ), - basicButtonLink(cssUserButton.cls(''), cssUserButton.cls('-disabled', isCurrentUser), - testId('acl-user-view-as'), - icon('FieldLink'), 'View As', - this._viewAs(user), - ), + this._viewAs(user), testId('acl-user-item'), ); } @@ -132,6 +122,9 @@ const cssUserItem = styled(cssMemberListItem, ` &:hover { background-color: ${theme.lightHover}; } + &, &:hover, &:focus { + text-decoration: none; + } `); const cssRole = styled('span', ` @@ -139,18 +132,10 @@ const cssRole = styled('span', ` font-weight: normal; `); -const cssUserButton = styled('div', ` - margin: 0 8px; - border: none; - display: inline-flex; - white-space: nowrap; - gap: 4px; - &:hover { - --icon-color: ${theme.controlFg}; - color: ${theme.controlFg}; - background-color: ${theme.hover}; - } - &-disabled { - visibility: hidden; - } +const cssHeader = styled('div', ` + margin: 11px 24px 14px 24px; + font-weight: 700; + text-transform: uppercase; + font-size: ${vars.xsmallFontSize}; + color: ${theme.darkText}; `); diff --git a/app/client/aclui/AccessRules.ts b/app/client/aclui/AccessRules.ts index b9b51205..d892a4a8 100644 --- a/app/client/aclui/AccessRules.ts +++ b/app/client/aclui/AccessRules.ts @@ -356,7 +356,8 @@ export class AccessRules extends Disposable { ), ), bigBasicButton(t('AddUserAttributes'), dom.on('click', () => this._addUserAttributes())), - bigBasicButton(t('Users'), cssDropdownIcon('Dropdown'), elem => this._aclUsersPopup.attachPopup(elem), + bigBasicButton(t('ViewAs'), cssDropdownIcon('Dropdown'), + elem => this._aclUsersPopup.attachPopup(elem, {placement: 'bottom-end'}), dom.style('visibility', use => use(this._aclUsersPopup.isInitialized) ? '' : 'hidden')), ), cssConditionError({style: 'margin-left: 16px'}, diff --git a/app/client/components/Banner.ts b/app/client/components/Banner.ts index 0f526fb7..cda7aa10 100644 --- a/app/client/components/Banner.ts +++ b/app/client/components/Banner.ts @@ -16,7 +16,7 @@ export interface BannerOptions { * Warning banners have a yellow background. Error banners have a red * background. */ - style: 'warning' | 'error'; + style: 'warning' | 'error' | 'info'; /** * Optional variant of `content` to display when screen width becomes narrow. @@ -40,6 +40,11 @@ export interface BannerOptions { */ showExpandButton?: boolean; + /** + * If provided, applies the css class to the banner container. + */ + bannerCssClass?: string; + /** * Function that is called when the banner close button is clicked. * @@ -59,7 +64,7 @@ export class Banner extends Disposable { } public buildDom() { - return cssBanner( + return cssBanner({class: this._options.bannerCssClass || ''}, cssBanner.cls(`-${this._options.style}`), this._buildContent(), this._buildButtons(), @@ -114,6 +119,11 @@ const cssBanner = styled('div', ` gap: 16px; color: white; + &-info { + color: black; + background: #FFFACD; + } + &-warning { background: #E6A117; } diff --git a/app/client/components/ViewAsBanner.ts b/app/client/components/ViewAsBanner.ts new file mode 100644 index 00000000..c42aecee --- /dev/null +++ b/app/client/components/ViewAsBanner.ts @@ -0,0 +1,132 @@ +import { reportError } from 'app/client/models/AppModel'; +import { Banner } from "app/client/components/Banner"; +import { DocPageModel } from "app/client/models/DocPageModel"; +import { icon } from "app/client/ui2018/icons"; +import { primaryButtonLink } from 'app/client/ui2018/buttons'; +import { Disposable, dom, styled } from "grainjs"; +import { testId, theme } from 'app/client/ui2018/cssVars'; +import { urlState } from 'app/client/models/gristUrlState'; +import { userOverrideParams } from 'app/common/gristUrls'; +import { cssMenuItem } from 'popweasel'; +import { getUserRoleText } from 'app/common/UserAPI'; +import { PermissionDataWithExtraUsers } from 'app/common/ActiveDocAPI'; +import { waitGrainObs } from 'app/common/gutil'; +import { cssSelectBtn } from 'app/client/ui2018/select'; +import { ACLUsersPopup } from 'app/client/aclui/ACLUsers'; +import { UserOverride } from 'app/common/DocListAPI'; +import { makeT } from 'app/client/lib/localization'; + +const t = makeT('components.ViewAsBanner'); + +export class ViewAsBanner extends Disposable { + + private _userOverride = this._docPageModel.userOverride; + private _usersPopup = ACLUsersPopup.create(this); + + constructor (private _docPageModel: DocPageModel) { + super(); + } + + public buildDom() { + return dom.maybe(this._userOverride, (userOverride) => { + this._initViewAsUsers().catch(reportError); + return dom.create(Banner, { + content: this._buildContent(userOverride), + style: 'info', + showCloseButton: false, + showExpandButton: false, + bannerCssClass: cssBanner.className, + }); + }); + } + + private _buildContent(userOverride: UserOverride) { + const {user, access} = userOverride; + return cssContent( + cssMessageText( + cssMessageIcon('EyeShow'), + 'You are viewing this document as', + ), + cssSelectBtn( + {tabIndex: '0'}, + cssBtnText( + user ? cssMember( + user.name || user.email, + cssRole('(', getUserRoleText({...user, access}), ')', dom.show(Boolean(access))), + ) : t('UnknownUser'), + ), + dom( + 'div', {style: 'flex: none;'}, + cssInlineCollapseIcon('Collapse'), + ), + elem => this._usersPopup.attachPopup(elem, {}), + testId('select-open'), + ), + cssPrimaryButtonLink( + 'View as Yourself', cssIcon('Convert'), + urlState().setHref(userOverrideParams(null)), + testId('revert'), + ), + testId('view-as-banner'), + ); + } + + private async _initViewAsUsers() { + await waitGrainObs(this._docPageModel.gristDoc); + const permissionData = await this._getUsersForViewAs(); + this._usersPopup.init(this._docPageModel, permissionData); + } + + private _getUsersForViewAs(): Promise { + const docId = this._docPageModel.currentDocId.get()!; + const docApi = this._docPageModel.appModel.api.getDocAPI(docId); + return docApi.getUsersForViewAs(); + } +} + +const cssContent = styled('div', ` + display: flex; + justify-content: center; + width: 100%; + column-gap: 13px; + align-items: center; + & .${cssSelectBtn.className} { + width: 184px; + } +`); +const cssIcon = styled(icon, ` + margin-left: 10px; +`); +const cssMember = styled('span', ` + font-weight: 500; + color: ${theme.text}; + + .${cssMenuItem.className}-sel & { + color: ${theme.menuItemSelectedFg}; + } +`); +const cssRole = styled('span', ` + font-weight: 400; + margin-left: 1ch; +`); +const cssMessageText = styled('span', ` +`); +const cssMessageIcon = styled(icon, ` + margin-right: 10px; +`); +const cssPrimaryButtonLink = styled(primaryButtonLink, ` + margin-left: 5px; +`); +const cssBtnText = styled('div', ` + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +`); +const cssInlineCollapseIcon = styled(icon, ` + margin: 0 2px; + pointer-events: none; +`); +const cssBanner = styled('div', ` + border-bottom: 1px solid ${theme.pagePanelsBorder}; + height: 45px; +`); diff --git a/app/client/ui/AppUI.ts b/app/client/ui/AppUI.ts index 5ee0cbfb..be7470f5 100644 --- a/app/client/ui/AppUI.ts +++ b/app/client/ui/AppUI.ts @@ -1,4 +1,5 @@ import {buildDocumentBanners, buildHomeBanners} from 'app/client/components/Banners'; +import {ViewAsBanner} from 'app/client/components/ViewAsBanner'; import {domAsync} from 'app/client/lib/domAsync'; import {loadBillingPage} from 'app/client/lib/imports'; import {createSessionObs, isBoolean, isNumber} from 'app/client/lib/sessionObs'; @@ -161,5 +162,6 @@ function pagePanelsDoc(owner: IDisposableOwner, appModel: AppModel, appObj: App) testId, contentTop: buildDocumentBanners(pageModel), contentBottom: dom.create(createBottomBarDoc, pageModel, leftPanelOpen, rightPanelOpen), + banner: dom.create(ViewAsBanner, pageModel), }); } diff --git a/app/client/ui/PagePanels.ts b/app/client/ui/PagePanels.ts index 9d4a6218..bb604370 100644 --- a/app/client/ui/PagePanels.ts +++ b/app/client/ui/PagePanels.ts @@ -9,7 +9,9 @@ import {transition, TransitionWatcher} from 'app/client/ui/transitions'; import {cssHideForNarrowScreen, isScreenResizing, mediaNotSmall, mediaSmall, theme} from 'app/client/ui2018/cssVars'; import {isNarrowScreenObs} from 'app/client/ui2018/cssVars'; import {icon} from 'app/client/ui2018/icons'; -import {dom, DomElementArg, MultiHolder, noTestId, Observable, styled, subscribe, TestId} from "grainjs"; +import { + dom, DomElementArg, DomElementMethod, MultiHolder, noTestId, Observable, styled, subscribe, TestId +} from "grainjs"; import noop from 'lodash/noop'; import once from 'lodash/once'; import {SessionObs} from 'app/client/lib/sessionObs'; @@ -36,6 +38,7 @@ export interface PageContents { headerMain: DomElementArg; contentMain: DomElementArg; + banner?: DomElementArg; onResize?: () => void; // Callback for when either pane is opened, closed, or resized. testId?: TestId; @@ -50,12 +53,15 @@ export function pagePanels(page: PageContents) { const onResize = page.onResize || (() => null); const leftOverlap = Observable.create(null, false); const dragResizer = Observable.create(null, false); + const bannerHeight = Observable.create(null, 0); const isScreenResizingObs = isScreenResizing(); let lastLeftOpen = left.panelOpen.get(); let lastRightOpen = right?.panelOpen.get() || false; let leftPaneDom: HTMLElement; let rightPaneDom: HTMLElement; + let mainHeaderDom: HTMLElement; + let contentTopDom: HTMLElement; let onLeftTransitionFinish = noop; // When switching to mobile mode, close panels; when switching to desktop, restore the @@ -107,13 +113,30 @@ export function pagePanels(page: PageContents) { dom.autoDispose(sub2), dom.autoDispose(commandsGroup), dom.autoDispose(leftOverlap), - page.contentTop, + dom('div', page.contentTop, elem => { contentTopDom = elem; }), + dom.maybe(page.banner, () => { + let elem: HTMLElement; + const updateTop = () => { + const height = mainHeaderDom.getBoundingClientRect().bottom; + elem.style.top = height + 'px'; + }; + setTimeout(() => watchHeightElem(contentTopDom, updateTop)); + const lis = isScreenResizingObs.addListener(val => val || updateTop()); + return elem = cssBannerContainer( + page.banner, + watchHeight(h => bannerHeight.set(h)), + dom.autoDispose(lis), + ); + }), cssContentMain( leftPaneDom = cssLeftPane( testId('left-panel'), cssOverflowContainer( contentWrapper = cssLeftPanelContainer( - cssLeftPaneHeader(left.header), + cssLeftPaneHeader( + left.header, + dom.style('margin-bottom', use => use(bannerHeight) + 'px') + ), left.content, ), ), @@ -242,7 +265,7 @@ export function pagePanels(page: PageContents) { cssHideForNarrowScreen.cls('')), cssMainPane( - cssTopHeader( + mainHeaderDom = cssTopHeader( testId('top-header'), (left.hideOpener ? null : cssPanelOpener('PanelRight', cssPanelOpener.cls('-open', left.panelOpen), @@ -260,6 +283,7 @@ export function pagePanels(page: PageContents) { dom.on('click', () => toggleObs(right.panelOpen)), cssHideForNarrowScreen.cls('')) ), + dom.style('margin-bottom', use => use(bannerHeight) + 'px'), ), page.contentMain, cssMainPane.cls('-left-overlap', leftOverlap), @@ -275,7 +299,10 @@ export function pagePanels(page: PageContents) { rightPaneDom = cssRightPane( testId('right-panel'), - cssRightPaneHeader(right.header), + cssRightPaneHeader( + right.header, + dom.style('margin-bottom', use => use(bannerHeight) + 'px') + ), right.content, dom.style('width', (use) => use(right.panelOpen) ? use(right.panelWidth) + 'px' : ''), @@ -606,7 +633,11 @@ const cssHiddenInput = styled('input', ` font-size: 1; z-index: -1; `); - +const cssBannerContainer = styled('div', ` + position: absolute; + z-index: 11; + width: 100%; +`); // watchElementForBlur does not work if focus is on body. Which never happens when running in Grist // because focus is constantly given to the copypasteField. But it does happen when running inside a // projects test. For that latter case we had a hidden field to the dom and give it focus. @@ -617,3 +648,15 @@ function maybePatchDomAndChangeFocus() { hiddenInput.focus(); } } +// Watch for changes in dom subtree and call callback with element height; +function watchHeight(callback: (height: number) => void): DomElementMethod { + return elem => watchHeightElem(elem, callback); +} + +function watchHeightElem(elem: HTMLElement, callback: (height: number) => void) { + const onChange = () => callback(elem.getBoundingClientRect().height); + const observer = new MutationObserver(onChange); + observer.observe(elem, {childList: true, subtree: true, attributes: true}); + dom.onDisposeElem(elem, () => observer.disconnect()); + onChange(); +} diff --git a/app/client/ui/Tools.ts b/app/client/ui/Tools.ts index a7cef50d..9e35a0d4 100644 --- a/app/client/ui/Tools.ts +++ b/app/client/ui/Tools.ts @@ -7,13 +7,10 @@ import {buildExamples} from 'app/client/ui/ExampleInfo'; import {createHelpTools, cssLinkText, cssPageEntry, cssPageEntryMain, cssPageEntrySmall, cssPageIcon, cssPageLink, cssSectionHeader, cssSpacer, cssSplitPageEntry, cssTools} from 'app/client/ui/LeftPanelCommon'; -import {hoverTooltip, tooltipCloseButton} from 'app/client/ui/tooltips'; import {theme} from 'app/client/ui2018/cssVars'; import {icon} from 'app/client/ui2018/icons'; -import {cssLink} from 'app/client/ui2018/links'; import {menuAnnotate} from 'app/client/ui2018/menus'; import {confirmModal} from 'app/client/ui2018/modals'; -import {userOverrideParams} from 'app/common/gristUrls'; import {isOwner} from 'app/common/roles'; import {Disposable, dom, makeTestId, Observable, observable, styled} from 'grainjs'; @@ -44,7 +41,6 @@ export function tools(owner: Disposable, gristDoc: GristDoc, leftPanelOpen: Obse menuAnnotate('Beta', cssBetaTag.cls('')) ), _canViewAccessRules ? urlState().setLinkUrl({docPage: 'acl'}) : null, - isOverridden ? addRevertViewAsUI() : null, ); }), testId('access-rules'), @@ -178,49 +174,6 @@ export interface AutomaticHelpToolInfo { markAsSeen: () => void; } -// When viewing a page as another user, the "Access Rules" page link includes a button to revert -// the user and open the page, and a click on the page link shows a tooltip to revert. -function addRevertViewAsUI() { - return [ - // A button that allows reverting back to yourself. - dom('a', - cssExampleCardOpener.cls(''), - cssRevertViewAsButton.cls(''), - icon('Convert'), - urlState().setHref(userOverrideParams(null, {docPage: 'acl'})), - dom.on('click', (ev) => ev.stopPropagation()), // Avoid refreshing the tooltip. - testId('revert-view-as'), - ), - - // A tooltip that allows reverting back to yourself. - hoverTooltip((ctl) => - cssConvertTooltip(icon('Convert'), - cssLink(t('ViewingAsYourself'), - urlState().setHref(userOverrideParams(null, {docPage: 'acl'})), - ), - tooltipCloseButton(ctl), - ), - { - openOnClick: true, - closeOnClick: false, - openDelay: 100, - closeDelay: 400, - placement: 'top', - } - ), - ]; -} - -const cssConvertTooltip = styled('div', ` - display: flex; - align-items: center; - --icon-color: ${theme.controlFg}; - - & > .${cssLink.className} { - margin-left: 8px; - } -`); - const cssExampleCardOpener = styled('div', ` cursor: pointer; margin-right: 4px; @@ -241,13 +194,6 @@ const cssExampleCardOpener = styled('div', ` } `); -const cssRevertViewAsButton = styled(cssExampleCardOpener, ` - background-color: ${theme.iconButtonSecondaryBg}; - &:hover { - background-color: ${theme.iconButtonSecondaryHoverBg}; - } -`); - const cssBetaTag = styled('div', ` .${cssPageEntry.className}-disabled & { opacity: 0.4; diff --git a/app/client/ui/TopBar.ts b/app/client/ui/TopBar.ts index a68eb4bc..cd193017 100644 --- a/app/client/ui/TopBar.ts +++ b/app/client/ui/TopBar.ts @@ -82,7 +82,6 @@ export function createTopBarDoc(owner: MultiHolder, appModel: AppModel, pageMode isFork: pageModel.isFork, isBareFork: pageModel.isBareFork, isRecoveryMode: pageModel.isRecoveryMode, - userOverride: pageModel.userOverride, isFiddle: Computed.create(owner, (use) => use(pageModel.isPrefork)), isSnapshot: Computed.create(owner, doc, (use, _doc) => Boolean(_doc && _doc.idParts.snapshotId)), isPublic: Computed.create(owner, doc, (use, _doc) => Boolean(_doc && _doc.public)), diff --git a/app/client/ui/UserImage.ts b/app/client/ui/UserImage.ts index 9cf1281c..ecf775a5 100644 --- a/app/client/ui/UserImage.ts +++ b/app/client/ui/UserImage.ts @@ -1,6 +1,7 @@ import {colors, theme} from 'app/client/ui2018/cssVars'; import {FullUser} from 'app/common/LoginSessionAPI'; import {dom, DomElementArg, styled} from 'grainjs'; +import {icon} from 'app/client/ui2018/icons'; export type Size = 'small' | 'medium' | 'large'; @@ -8,10 +9,11 @@ export type Size = 'small' | 'medium' | 'large'; * Returns a DOM element showing a circular icon with a user's picture, or the user's initials if * picture is missing. Also varies the color of the circle when using initials. */ -export function createUserImage(user: FullUser|null, size: Size, ...args: DomElementArg[]): HTMLElement { +export function createUserImage(user: FullUser|'exampleUser'|null, size: Size, ...args: DomElementArg[]): HTMLElement { let initials: string; return cssUserImage( cssUserImage.cls('-' + size), + (user === 'exampleUser') ? [cssUserImage.cls('-example'), cssExampleUserIcon('EyeShow')] : (!user || user.anonymous) ? cssUserImage.cls('-anon') : [ (user.picture ? cssUserPicture({src: user.picture}, dom.on('error', (ev, el) => dom.hideElem(el, true))) : null), @@ -113,6 +115,11 @@ export const cssUserImage = styled('div', ` &-reduced { font-size: var(--reduced-font-size); } + + &-example { + background-color: ${colors.slate}; + border: 1px solid ${colors.slate}; + } `); const cssUserPicture = styled('img', ` @@ -124,3 +131,10 @@ const cssUserPicture = styled('img', ` border-radius: 100px; box-sizing: content-box; /* keep the border outside of the size of the image */ `); + +const cssExampleUserIcon = styled(icon, ` + background-color: white; + width: 45px; + height: 45px; + transform: scaleY(0.75); +`); diff --git a/app/client/ui2018/breadcrumbs.ts b/app/client/ui2018/breadcrumbs.ts index f973ebc1..d89b62c3 100644 --- a/app/client/ui2018/breadcrumbs.ts +++ b/app/client/ui2018/breadcrumbs.ts @@ -11,8 +11,6 @@ import { cssHideForNarrowScreen, mediaNotSmall, testId, theme } from 'app/client import { editableLabel } from 'app/client/ui2018/editableLabel'; import { icon } from 'app/client/ui2018/icons'; import { cssLink } from 'app/client/ui2018/links'; -import { UserOverride } from 'app/common/DocListAPI'; -import { userOverrideParams } from 'app/common/gristUrls'; import { BindableValue, dom, Observable, styled } from 'grainjs'; import { tooltip } from 'popweasel'; @@ -99,7 +97,6 @@ export function docBreadcrumbs( isBareFork: Observable, isFiddle: Observable, isRecoveryMode: Observable, - userOverride: Observable, isSnapshot?: Observable, isPublic?: Observable, } @@ -152,16 +149,6 @@ export function docBreadcrumbs( icon('CrossSmall')), testId('recovery-mode-tag')); } - const userOverride = use(options.userOverride); - if (userOverride) { - return cssAlertTag(userOverride.user?.email || t('Override'), - dom('a', - urlState().setHref(userOverrideParams(null)), - icon('CrossSmall') - ), - testId('user-override-tag') - ); - } if (use(options.isFiddle)) { return cssTag(t('Fiddle'), tooltip({title: t('FiddleExplanation')}), testId('fiddle-tag')); } diff --git a/app/common/UserAPI.ts b/app/common/UserAPI.ts index d7664397..85de14a0 100644 --- a/app/common/UserAPI.ts +++ b/app/common/UserAPI.ts @@ -1,5 +1,5 @@ import {ActionSummary} from 'app/common/ActionSummary'; -import {ApplyUAResult, QueryFilters} from 'app/common/ActiveDocAPI'; +import {ApplyUAResult, PermissionDataWithExtraUsers, QueryFilters} from 'app/common/ActiveDocAPI'; import {BaseAPI, IOptions} from 'app/common/BaseAPI'; import {BillingAPI, BillingAPIImpl} from 'app/common/BillingAPI'; import {BrowserSettings} from 'app/common/BrowserSettings'; @@ -199,6 +199,16 @@ export function getRealAccess(user: UserAccessData, permissionData: PermissionDa return roles.getStrongestRole(user.access, inheritedAccess); } +const roleNames: {[role: string]: string} = { + [roles.OWNER]: 'Owner', + [roles.EDITOR]: 'Editor', + [roles.VIEWER]: 'Viewer', +}; + +export function getUserRoleText(user: UserAccessData) { + return roleNames[user.access!] || user.access || 'no access'; +} + export interface ActiveSessionInfo { user: FullUser & {helpScoutSignature?: string}; org: Organization|null; @@ -401,6 +411,9 @@ export interface DocAPI { // Upload a single attachment and return the resulting metadata row ID. // The arguments are passed to FormData.append. uploadAttachment(value: string | Blob, filename?: string): Promise; + + // Get users that are worth proposing to "View As" for access control purposes. + getUsersForViewAs(): Promise; } // Operations that are supported by a doc worker. @@ -833,6 +846,10 @@ export class DocAPIImpl extends BaseAPI implements DocAPI { }); } + public async getUsersForViewAs(): Promise { + return this.requestJson(`${this._url}/usersForViewAs`); + } + public async forceReload(): Promise { await this.request(`${this._url}/force-reload`, { method: 'POST' diff --git a/app/gen-server/lib/DocApiForwarder.ts b/app/gen-server/lib/DocApiForwarder.ts index 8770c5a2..8dca49c1 100644 --- a/app/gen-server/lib/DocApiForwarder.ts +++ b/app/gen-server/lib/DocApiForwarder.ts @@ -50,6 +50,7 @@ export class DocApiForwarder { app.use('/api/docs/:docId/apply', withDoc); app.use('/api/docs/:docId/attachments', withDoc); app.use('/api/docs/:docId/snapshots', withDoc); + app.use('/api/docs/:docId/usersForViewAs', withDoc); app.use('/api/docs/:docId/replace', withDoc); app.use('/api/docs/:docId/flush', withDoc); app.use('/api/docs/:docId/states', withDoc); diff --git a/app/server/lib/ActiveDoc.ts b/app/server/lib/ActiveDoc.ts index b179a880..82845766 100644 --- a/app/server/lib/ActiveDoc.ts +++ b/app/server/lib/ActiveDoc.ts @@ -1504,7 +1504,7 @@ export class ActiveDoc extends EventEmitter { * * Example users are always included. */ - public async getUsersForViewAs(docSession: DocSession): Promise { + public async getUsersForViewAs(docSession: OptDocSession): Promise { // Make sure we have rights to view access rules. const db = this.getHomeDbManager(); if (!db || !await this._granularAccess.hasAccessRulesPermission(docSession)) { diff --git a/app/server/lib/DocApi.ts b/app/server/lib/DocApi.ts index 152a05e2..12cf1e0b 100644 --- a/app/server/lib/DocApi.ts +++ b/app/server/lib/DocApi.ts @@ -673,6 +673,11 @@ export class DocWorkerApi { res.json({snapshots}); })); + this._app.get('/api/docs/:docId/usersForViewAs', isOwner, withDoc(async (activeDoc, req, res) => { + const docSession = docSessionFromRequest(req); + res.json(await activeDoc.getUsersForViewAs(docSession)); + })); + this._app.post('/api/docs/:docId/snapshots/remove', isOwner, withDoc(async (activeDoc, req, res) => { const docSession = docSessionFromRequest(req); const snapshotIds = req.body.snapshotIds as string[]; diff --git a/static/locales/en.client.json b/static/locales/en.client.json index 92896ee1..dbdaeeab 100644 --- a/static/locales/en.client.json +++ b/static/locales/en.client.json @@ -567,7 +567,6 @@ "Reset": "Reset", "AddTableRules": "Add Table Rules", "AddUserAttributes": "Add User Attributes", - "Users": "Users", "UserAttributes": "User Attributes", "AttributeToLookUp": "Attribute to Look Up", "LookupTable": "Lookup Table", @@ -591,7 +590,13 @@ "RemoveRulesMentioningTable": "Remove {{- tableId }} rules", "RemoveRulesMentioningColumn": "Remove column {{- colId }} from {{- tableId }} rules", "RemoveUserAttribute": "Remove {{- name }} user attribute", - "MemoEditorPlaceholder": "Type a message..." + "MemoEditorPlaceholder": "Type a message...", + "ViewAs": "View As" + }, + "ViewAsDropdown": { + "ViewAs": "View As", + "UsersFrom": "Users from table", + "ExampleUsers": "Example Users" }, "PermissionsWidget": { "AllowAll": "Allow All", @@ -729,7 +734,7 @@ "PluginColon": "Plugin: ", "SectionColon": "Section: " }, - "Drafts": { + "Drafts": { "UndoDiscard":"Undo discard", "RestoreLastEdit":"Restore last edit" }, @@ -777,6 +782,9 @@ "ValidationPanel": { "RuleLength":"Rule {{length}}", "UpdateFormula":"Update formula (Shift+Enter)" + }, + "ViewAsBanner": { + "UnknownUser": "Unknown User" } } }