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 {testId, theme, vars} from 'app/client/ui2018/cssVars'; import {PermissionDataWithExtraUsers} from 'app/common/ActiveDocAPI'; import {menu, menuCssClass, menuItemLink} from 'app/client/ui2018/menus'; import {IGristUrlState, userOverrideParams} from 'app/common/gristUrls'; import {FullUser} from 'app/common/LoginSessionAPI'; 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, IMenuOptions, IPopupOptions, setPopupToCreateDom} from 'popweasel'; import {getUserRoleText} from 'app/common/UserAPI'; import {makeT} from 'app/client/lib/localization'; import {waitGrainObs} from 'app/common/gutil'; import noop from 'lodash/noop'; const t = makeT("ViewAsDropdown"); function isSpecialEmail(email: string) { return email === ANONYMOUS_USER_EMAIL || email === EVERYONE_EMAIL; } export class ACLUsersPopup extends Disposable { public readonly isInitialized = Observable.create(this, false); public readonly allUsers = Observable.create(this, []); private _shareUsers: UserAccessData[] = []; // Users doc is shared with. private _attributeTableUsers: UserAccessData[] = []; // Users mentioned in attribute tables. private _exampleUsers: UserAccessData[] = []; // Example users. private _currentUser: FullUser|null = null; constructor(public pageModel: DocPageModel, public fetch: () => Promise = () => this._fetchData()) { super(); } public async load() { const permissionData = await this.fetch(); if (this.isDisposed()) { return; } this.init(permissionData); } public getUsers() { const users = [...this._shareUsers, ...this._attributeTableUsers]; if (this._showExampleUsers()) { users.push(...this._exampleUsers); } return users; } public init(permissionData: PermissionDataWithExtraUsers|null) { const pageModel = this.pageModel; this._currentUser = pageModel.userOverride.get()?.user || pageModel.appModel.currentValidUser; if (permissionData) { this._shareUsers = permissionData.users.map(user => ({ ...user, access: getRealAccess(user, permissionData), })) .filter(user => user.access && !isSpecialEmail(user.email)) .filter(user => this._currentUser?.id !== user.id); this._attributeTableUsers = permissionData.attributeTableUsers; this._exampleUsers = permissionData.exampleUsers; this.allUsers.set(this.getUsers()); this.isInitialized.set(true); } } // Optionnally have document page reverts to the default page upon activation of the view as mode // by setting `options.resetDocPage` to true. public attachPopup(elem: Element, options: IPopupOptions & {resetDocPage?: boolean}) { setPopupToCreateDom(elem, (ctl) => { const buildRow = (user: UserAccessData) => this._buildUserRow(user, options); const buildExampleUserRow = (user: UserAccessData) => this._buildUserRow(user, {isExampleUser: true, ...options}); return cssMenuWrap(cssMenu( dom.cls(menuCssClass), cssUsers.cls(''), cssHeader(t('View As'), dom.show(this._shareUsers.length > 0)), dom.forEach(this._shareUsers, buildRow), (this._attributeTableUsers.length > 0) ? cssHeader(t("Users from table")) : 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._showExampleUsers() ? [ (this._exampleUsers.length > 0) ? cssHeader(t("Example Users")) : null, dom.forEach(this._exampleUsers, buildExampleUserRow) ] : null, (el) => { setTimeout(() => el.focus(), 0); }, dom.onKeyDown({Escape: () => ctl.close()}), )); }, {...defaultMenuOptions, ...options}); } // See 'attachPopup' for more info on the 'resetDocPage' option. public menu(options: IMenuOptions) { return menu(() => { this.load().catch(noop); return [ cssMenuHeader('view as'), dom.forEach(this.allUsers, user => menuItemLink( `${user.name || user.email} (${getUserRoleText(user)})`, testId('acl-user-access'), this._viewAs(user), )), ]; }, options); } private async _fetchData() { const doc = this.pageModel.currentDoc.get(); const gristDoc = await waitGrainObs(this.pageModel.gristDoc); return doc && gristDoc.docComm.getUsersForViewAs(); } private _showExampleUsers() { return this._shareUsers.length + this._attributeTableUsers.length < 5; } private _buildUserRow(user: UserAccessData, opt: {isExampleUser?: boolean, resetDocPage?: boolean} = {}) { return dom('a', {class: cssMemberListItem.className + ' ' + cssUserItem.className}, cssMemberImage( createUserImage(opt.isExampleUser ? 'exampleUser' : user, 'large') ), cssMemberText( cssMemberPrimary(user.name || dom('span', user.email), cssRole('(', getUserRoleText(user), ')', testId('acl-user-access')), ), user.name ? cssMemberSecondary(user.email) : null ), this._viewAs(user, opt.resetDocPage), testId('acl-user-item'), ); } private _viewAs(user: UserAccessData, resetDocPage: boolean = false) { const extraState: IGristUrlState = {}; if (resetDocPage) { extraState.docPage = undefined; } if (this.pageModel?.isPrefork.get() && this.pageModel?.currentDoc.get()?.access !== 'owners') { // "View As" is restricted to document owners on the back-end. Non-owners can be // permitted to pretend to be owners of a pre-forked document, but if they want // to do "View As", that would be layering pretence over pretense. Better to just // go ahead and create the fork, so the user becomes a genuine owner, so the // back-end doesn't have to become too metaphysical (and maybe hard to review). return dom.on('click', async () => { const forkResult = await this.pageModel?.gristDoc.get()?.docComm.fork(); if (!forkResult) { throw new Error('Failed to create fork'); } window.location.assign(urlState().makeUrl(userOverrideParams(user.email, {...extraState, doc: forkResult.urlId}))); }); } else { // When forking isn't needed, we return a direct link to be maximally transparent // about where button will go. return urlState().setHref(userOverrideParams(user.email, extraState)); } } } const cssUsers = styled('div', ` max-width: unset; `); const cssUserItem = styled(cssMemberListItem, ` width: auto; padding: 8px 16px; align-items: center; &:hover { background-color: ${theme.lightHover}; } &, &:hover, &:focus { text-decoration: none; } `); const cssRole = styled('span', ` margin: 0 8px; font-weight: normal; `); const cssHeader = styled('div', ` margin: 11px 24px 14px 24px; font-weight: 700; text-transform: uppercase; font-size: ${vars.xsmallFontSize}; color: ${theme.darkText}; `); const cssMenuHeader = styled('div', ` margin: 8px 24px; margin-bottom: 4px; font-weight: 700; text-transform: uppercase; font-size: ${vars.xsmallFontSize}; color: ${theme.darkText}; `);