diff --git a/app/client/aclui/ACLUsers.ts b/app/client/aclui/ACLUsers.ts index e1e7bf10..655abe79 100644 --- a/app/client/aclui/ACLUsers.ts +++ b/app/client/aclui/ACLUsers.ts @@ -7,12 +7,13 @@ import {cssMemberImage, cssMemberListItem, cssMemberPrimary, import {basicButton, basicButtonLink} from 'app/client/ui2018/buttons'; import {colors, testId} from 'app/client/ui2018/cssVars'; import {icon} from 'app/client/ui2018/icons'; -import {menuCssClass} from 'app/client/ui2018/menus'; +import {menuCssClass, menuDivider} 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, PermissionData, UserAccessData} 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'; @@ -30,7 +31,7 @@ function buildUserRow(user: UserAccessData, currentUser: FullUser|null, ctl: IOp ), cssMemberText( cssMemberPrimary(user.name || dom('span', user.email), - cssRole('(', roleNames[user.access!] || user.access, ')', testId('acl-user-access')), + cssRole('(', roleNames[user.access!] || user.access || 'no access', ')', testId('acl-user-access')), ), user.name ? cssMemberSecondary(user.email) : null ), @@ -53,31 +54,46 @@ function isSpecialEmail(email: string) { export class ACLUsersPopup extends Disposable { public readonly isInitialized = Observable.create(this, false); - private _usersInDoc: UserAccessData[] = []; + 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; - public init(pageModel: DocPageModel, permissionData: PermissionData|null) { + public init(pageModel: DocPageModel, permissionData: PermissionDataWithExtraUsers|null) { this._currentUser = pageModel.userOverride.get()?.user || pageModel.appModel.currentValidUser; if (permissionData) { - this._usersInDoc = permissionData.users.map(user => ({ + this._shareUsers = permissionData.users.map(user => ({ ...user, access: getRealAccess(user, permissionData), })) .filter(user => user.access && !isSpecialEmail(user.email)); + this._attributeTableUsers = permissionData.attributeTableUsers; + this._exampleUsers = permissionData.exampleUsers; this.isInitialized.set(true); } } public attachPopup(elem: Element) { - setPopupToCreateDom(elem, (ctl) => cssMenuWrap(cssMenu( + setPopupToCreateDom(elem, (ctl) => { + const buildRow = (user: UserAccessData) => buildUserRow(user, this._currentUser, ctl); + return cssMenuWrap(cssMenu( dom.cls(menuCssClass), cssUsers.cls(''), - dom.forEach(this._usersInDoc, user => buildUserRow(user, this._currentUser, ctl)), + 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), + // 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) + ] : null, (el) => { setTimeout(() => el.focus(), 0); }, dom.onKeyDown({Escape: () => ctl.close()}), - )), - {...defaultMenuOptions, placement: 'bottom-end'} - ); + )); + }, {...defaultMenuOptions, placement: 'bottom-end'}); } } diff --git a/app/client/aclui/AccessRules.ts b/app/client/aclui/AccessRules.ts index 3139ea7c..8f9209c3 100644 --- a/app/client/aclui/AccessRules.ts +++ b/app/client/aclui/AccessRules.ts @@ -163,8 +163,8 @@ export class AccessRules extends Disposable { for (const tableId of ['_grist_ACLResources', '_grist_ACLRules']) { const tableData = this._gristDoc.docData.getTable(tableId)!; this.autoDispose(tableData.tableActionEmitter.addListener(this._onChange, this)); - this.autoDispose(this._gristDoc.docPageModel.currentDoc.addListener(this._updateDocAccessData, this)); } + this.autoDispose(this._gristDoc.docPageModel.currentDoc.addListener(this._updateDocAccessData, this)); this.update().catch((e) => this._errorMessage.set(e.message)); } @@ -336,12 +336,8 @@ export class AccessRules extends Disposable { ), ), bigBasicButton('Add User Attributes', dom.on('click', () => this._addUserAttributes())), - // Disabling "View as user" for forks for the moment. TODO Modify getDocAccess endpoint - // to accept forks, through the kind of manipulation that getDoc does; then can enable. - !this._gristDoc.docPageModel.isFork.get() ? - bigBasicButton('Users', cssDropdownIcon('Dropdown'), elem => this._aclUsersPopup.attachPopup(elem), - dom.style('visibility', use => use(this._aclUsersPopup.isInitialized) ? '' : 'hidden'), - ) : null, + bigBasicButton('Users', cssDropdownIcon('Dropdown'), elem => this._aclUsersPopup.attachPopup(elem), + dom.style('visibility', use => use(this._aclUsersPopup.isInitialized) ? '' : 'hidden')), ), cssConditionError({style: 'margin-left: 16px'}, dom.maybe(this._publicEditAccess, () => dom('div', @@ -468,8 +464,7 @@ export class AccessRules extends Disposable { const pageModel = this._gristDoc.docPageModel; const doc = pageModel.currentDoc.get(); - // Note that the getDocAccess endpoint does not succeed for forks currently. - const permissionData = doc && !doc.isFork ? await pageModel.appModel.api.getDocAccess(doc.id) : null; + const permissionData = doc && await this._gristDoc.docComm.getUsersForViewAs(); if (this.isDisposed()) { return; } this._aclUsersPopup.init(pageModel, permissionData); diff --git a/app/client/components/DocComm.ts b/app/client/components/DocComm.ts index be25f8d4..6b1835e6 100644 --- a/app/client/components/DocComm.ts +++ b/app/client/components/DocComm.ts @@ -56,6 +56,7 @@ export class DocComm extends Disposable implements ActiveDocAPI { public checkAclFormula = this._wrapMethod("checkAclFormula"); public getAclResources = this._wrapMethod("getAclResources"); public waitForInitialization = this._wrapMethod("waitForInitialization"); + public getUsersForViewAs = this._wrapMethod("getUsersForViewAs"); public changeUrlIdEmitter = this.autoDispose(new Emitter()); diff --git a/app/common/ActiveDocAPI.ts b/app/common/ActiveDocAPI.ts index 7ed2eafc..2113fd35 100644 --- a/app/common/ActiveDocAPI.ts +++ b/app/common/ActiveDocAPI.ts @@ -2,6 +2,7 @@ import {ActionGroup} from 'app/common/ActionGroup'; import {CellValue, TableDataAction, UserAction} from 'app/common/DocActions'; import {FormulaProperties} from 'app/common/GranularAccessClause'; import {FetchUrlOptions, UploadResult} from 'app/common/uploads'; +import {PermissionData, UserAccessData} from 'app/common/UserAPI'; import {ParseOptions} from 'app/plugin/FileParserAPI'; import {IMessage} from 'grain-rpc'; @@ -134,6 +135,16 @@ export interface ForkResult { urlId: string; } +/** + * An extension of PermissionData to cover not just users with whom a document is shared, + * but also users mentioned in the document (in user attribute tables), and suggested + * example users. This is for use in the "View As" feature of the access rules page. + */ +export interface PermissionDataWithExtraUsers extends PermissionData { + attributeTableUsers: UserAccessData[]; + exampleUsers: UserAccessData[]; +} + export interface ActiveDocAPI { /** * Closes a document, and unsubscribes from its userAction events. @@ -277,4 +288,9 @@ export interface ActiveDocAPI { * Wait for document to finish initializing. */ waitForInitialization(): Promise; + + /** + * Get users that are worth proposing to "View As" for access control purposes. + */ + getUsersForViewAs(): Promise; } diff --git a/app/gen-server/lib/HomeDBManager.ts b/app/gen-server/lib/HomeDBManager.ts index 90c10e61..0eea5930 100644 --- a/app/gen-server/lib/HomeDBManager.ts +++ b/app/gen-server/lib/HomeDBManager.ts @@ -8,7 +8,7 @@ import {checkSubdomainValidity} from 'app/common/orgNameUtils'; import {UserOrgPrefs} from 'app/common/Prefs'; import * as roles from 'app/common/roles'; // TODO: API should implement UserAPI -import {ANONYMOUS_USER_EMAIL, DocumentProperties, EVERYONE_EMAIL, +import {ANONYMOUS_USER_EMAIL, DocumentProperties, EVERYONE_EMAIL, getRealAccess, ManagerDelta, NEW_DOCUMENT_CODE, OrganizationProperties, Organization as OrgInfo, PermissionData, PermissionDelta, SUPPORT_EMAIL, UserAccessData, WorkspaceProperties} from "app/common/UserAPI"; @@ -556,6 +556,22 @@ export class HomeDBManager extends EventEmitter { return userByLogin; } + /** + * Find a user by email. Don't create the user if it doesn't already exist. + */ + public async getExistingUserByLogin( + email: string, + manager?: EntityManager + ): Promise { + const normalizedEmail = normalizeEmail(email); + return (manager || this._connection).createQueryBuilder() + .select('user') + .from(User, 'user') + .leftJoinAndSelect('user.logins', 'logins') + .where('email = :email', {email: normalizedEmail}) + .getOne(); + } + /** * Returns true if the given domain string is available, and false if it is not available. * NOTE that the endpoint only checks if the domain string is taken in the database, it does @@ -982,22 +998,8 @@ export class HomeDBManager extends EventEmitter { // Set trunkAccess field. doc.trunkAccess = doc.access; - // Forks without a user id are editable by anyone with view access to the trunk. - if (forkUserId === undefined && roles.canView(doc.access)) { doc.access = 'owners'; } - if (forkUserId !== undefined) { - // A fork user id is known, so only that user should get to edit the fork. - if (userId === forkUserId) { - if (roles.canView(doc.access)) { doc.access = 'owners'; } - } else { - // reduce to viewer if not already viewer - doc.access = roles.getWeakestRole('viewers', doc.access); - } - } - - // Finally, if we are viewing a snapshot, we can't edit it. - if (snapshotId) { - doc.access = roles.getWeakestRole('viewers', doc.access); - } + // Update access for fork. + this._setForkAccess({userId, forkUserId, snapshotId}, doc); } return doc; } @@ -2027,8 +2029,26 @@ export class HomeDBManager extends EventEmitter { // a more straightforward way of determining inheritance. The difficulty here is that all users // in the org and their logins are needed for inclusion in the result, which would require an // extra lookup step when traversing from the doc. - public async getDocAccess(scope: DocScope): Promise> { - const doc = await this._loadDocAccess(scope, Permissions.VIEW); + // + // If the user is not an owner of the document, only that user (at most) will be mentioned + // in the result. + // + // Optionally, the results can be flattened, removing all information about inheritance and + // parents, and just giving the effective access level of each user (frankly, the default + // output of this method is quite confusing). + // + // Optionally, users without access to the document can be removed from the results + // (I believe they are included in order to one day facilitate auto-completion in the client?). + public async getDocAccess(scope: DocScope, options?: { + flatten?: boolean, + excludeUsersWithoutAccess?: 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 + // up for the fork immediately afterwards). + const {trunkId, forkId, forkUserId, snapshotId} = parseUrlId(scope.urlId); + + const doc = await this._loadDocAccess({...scope, urlId: trunkId}, Permissions.VIEW); const docMap = getMemberUserRoles(doc, this.defaultCommonGroupNames); // The wsMap gives the ws access inherited by each user. const wsMap = getMemberUserRoles(doc.workspace, this.defaultBasicGroupNames); @@ -2036,7 +2056,7 @@ export class HomeDBManager extends EventEmitter { const orgMap = getMemberUserRoles(doc.workspace.org, this.defaultBasicGroupNames); const wsMaxInheritedRole = this._getMaxInheritedRole(doc.workspace); // Iterate through the org since all users will be in the org. - const users: UserAccessData[] = getResourceUsers([doc, doc.workspace, doc.workspace.org]).map(u => { + let users: UserAccessData[] = getResourceUsers([doc, doc.workspace, doc.workspace.org]).map(u => { // Merge the strongest roles from the resource and parent resources. Note that the parent // resource access levels must be tempered by the maxInheritedRole values of their children. const inheritFromOrg = roles.getWeakestRole(orgMap[u.id] || null, wsMaxInheritedRole); @@ -2048,10 +2068,42 @@ export class HomeDBManager extends EventEmitter { ) }; }); + let maxInheritedRole = this._getMaxInheritedRole(doc); + + if (options?.excludeUsersWithoutAccess) { + users = users.filter(user => { + const access = getRealAccess(user, { maxInheritedRole, users }); + return roles.canView(access); + }); + } + + if (forkId || snapshotId || options?.flatten) { + for (const user of users) { + const access = getRealAccess(user, { maxInheritedRole, users }); + user.access = access; + user.parentAccess = undefined; + } + maxInheritedRole = null; + } + + const thisUser = users.find(user => user.id === scope.userId); + if (!thisUser || getRealAccess(thisUser, { maxInheritedRole, users }) !== 'owners') { + // If not an owner, don't return information about other users. + users = thisUser ? [thisUser] : []; + } + + // If we are on a fork, make any access changes needed. Assumes results + // have been flattened. + if (forkId || snapshotId) { + for (const user of users) { + this._setForkAccess({userId: user.id, forkUserId, snapshotId}, user); + } + } + return { status: 200, data: { - maxInheritedRole: this._getMaxInheritedRole(doc), + maxInheritedRole, users } }; @@ -2732,6 +2784,33 @@ export class HomeDBManager extends EventEmitter { return id; } + /** + * Modify an access level when the document is a fork. Here are the rules, as they + * have evolved (the main constraint is that currently forks have no access info of + * their own in the db). + * - If fork is a snapshot, all users are at most viewers. Else: + * - If there is no ~USERID in fork id, then all viewers of trunk are owners of the fork. + * - If there is a ~USERID in fork id, that user is owner, all others are at most viewers. + */ + private _setForkAccess(ids: {userId: number, forkUserId?: number, snapshotId?: string}, + res: {access: roles.Role|null}) { + // Forks without a user id are editable by anyone with view access to the trunk. + if (ids.forkUserId === undefined && roles.canView(res.access)) { res.access = 'owners'; } + if (ids.forkUserId !== undefined) { + // A fork user id is known, so only that user should get to edit the fork. + if (ids.userId === ids.forkUserId) { + if (roles.canView(res.access)) { res.access = 'owners'; } + } else { + // reduce to viewer if not already viewer + res.access = roles.getWeakestRole('viewers', res.access); + } + } + // Finally, if we are viewing a snapshot, we can't edit it. + if (ids.snapshotId) { + res.access = roles.getWeakestRole('viewers', res.access); + } + } + // This deals with the problem posed by receiving a PermissionDelta specifying a // role for both alice@x and Alice@x. We do not distinguish between such emails. // If there are multiple indistinguishabe emails, we preserve just one of them, diff --git a/app/server/lib/ActiveDoc.ts b/app/server/lib/ActiveDoc.ts index 1321851d..8f509c10 100644 --- a/app/server/lib/ActiveDoc.ts +++ b/app/server/lib/ActiveDoc.ts @@ -23,6 +23,7 @@ import { ForkResult, ImportOptions, ImportResult, + PermissionDataWithExtraUsers, QueryResult, ServerQuery } from 'app/common/ActiveDocAPI'; @@ -40,6 +41,7 @@ import { import {DocData} from 'app/common/DocData'; import {DocSnapshots} from 'app/common/DocSnapshot'; import {DocumentSettings} from 'app/common/DocumentSettings'; +import {normalizeEmail} from 'app/common/emails'; import {FormulaProperties, getFormulaProperties} from 'app/common/GranularAccessClause'; import {byteString, countIf, safeJsonParse} from 'app/common/gutil'; import {InactivityTimer} from 'app/common/InactivityTimer'; @@ -1105,6 +1107,65 @@ export class ActiveDoc extends EventEmitter { return result; } + /** + * Get users that are worth proposing to "View As" for access control purposes. + * User are drawn from the following sources: + * - Users document is shared with. + * - Users mentioned in user attribute tables keyed by email address. + * - Some predefined example users. + * + * The users the document is shared with are only available if the + * user is an owner of the document (or, in a fork, an owner of the + * trunk document). For viewers or editors, only the user calling + * the method will be included as users the document is shared with. + * + * Users mentioned in user attribute tables will be available to any user with + * the right to view access rules. + * + * Example users are always included. + */ + public async getUsersForViewAs(docSession: DocSession): Promise { + // Make sure we have rights to view access rules. + const db = this.getHomeDbManager(); + if (!db || !await this._granularAccess.hasAccessRulesPermission(docSession)) { + throw new Error('Cannot list ACL users'); + } + + // Prepare a stub for the collected results. + const result: PermissionDataWithExtraUsers = { + users: [], + attributeTableUsers: [], + exampleUsers: [], + }; + const isShared = new Set(); + + // Collect users the document is shared with. + const userId = getDocSessionUserId(docSession); + if (!userId) { throw new Error('Cannot determine user'); } + const access = db.unwrapQueryResult( + await db.getDocAccess({userId, urlId: this.docName}, { + flatten: true, excludeUsersWithoutAccess: true, + })); + result.users = access.users; + result.users.forEach(user => isShared.add(normalizeEmail(user.email))); + + // Collect users from user attribute tables. Omit duplicates with users the document is + // shared with. + const usersFromUserAttributes = await this._granularAccess.collectViewAsUsersFromUserAttributeTables(); + for (const user of usersFromUserAttributes) { + if (!user.email) { continue; } + const email = normalizeEmail(user.email); + if (!isShared.has(email)) { + result.attributeTableUsers.push({email: user.email, name: user.name || '', + id: 0, access: user.access === undefined ? 'editors' : user.access}); + } + } + + // Add some example users. + result.exampleUsers = this._granularAccess.getExampleViewAsUsers(); + return result; + } + public getGristDocAPI(): GristDocAPI { return this.docPluginManager.gristDocAPI; } diff --git a/app/server/lib/DocWorker.ts b/app/server/lib/DocWorker.ts index 0deea539..19a65a02 100644 --- a/app/server/lib/DocWorker.ts +++ b/app/server/lib/DocWorker.ts @@ -110,6 +110,7 @@ export class DocWorker { checkAclFormula: activeDocMethod.bind(null, 'viewers', 'checkAclFormula'), getAclResources: activeDocMethod.bind(null, 'viewers', 'getAclResources'), waitForInitialization: activeDocMethod.bind(null, 'viewers', 'waitForInitialization'), + getUsersForViewAs: activeDocMethod.bind(null, 'viewers', 'getUsersForViewAs'), }); } diff --git a/app/server/lib/GranularAccess.ts b/app/server/lib/GranularAccess.ts index 440cb2ce..9e0c31c7 100644 --- a/app/server/lib/GranularAccess.ts +++ b/app/server/lib/GranularAccess.ts @@ -10,13 +10,14 @@ import { CellValue, ColValues, DocAction, getTableId, isSchemaAction } from 'app import { TableDataAction, UserAction } from 'app/common/DocActions'; import { DocData } from 'app/common/DocData'; import { UserOverride } from 'app/common/DocListAPI'; +import { normalizeEmail } from 'app/common/emails'; import { ErrorWithCode } from 'app/common/ErrorWithCode'; import { AclMatchInput, InfoEditor, InfoView } from 'app/common/GranularAccessClause'; import { UserInfo } from 'app/common/GranularAccessClause'; import { isCensored } from 'app/common/gristTypes'; import { getSetMapValue, isObject, pruneArray } from 'app/common/gutil'; -import { canEdit, canView, Role } from 'app/common/roles'; -import { FullUser } from 'app/common/UserAPI'; +import { canEdit, canView, isValidRole, Role } from 'app/common/roles'; +import { FullUser, UserAccessData } from 'app/common/UserAPI'; import { HomeDBManager } from 'app/gen-server/lib/HomeDBManager'; import { compileAclFormula } from 'app/server/lib/ACLFormula'; import { DocClients } from 'app/server/lib/DocClients'; @@ -718,6 +719,49 @@ export class GranularAccess implements GranularAccessForBundle { this._prevUserAttributesMap?.delete(docSession); } + // Get a set of example users for playing with access control. + // We use the example.com domain, which is reserved for uses like this. + public getExampleViewAsUsers(): UserAccessData[] { + return [ + {id: 0, email: 'owner@example.com', name: 'Owner', access: 'owners'}, + {id: 0, email: 'editor1@example.com', name: 'Editor 1', access: 'editors'}, + {id: 0, email: 'editor2@example.com', name: 'Editor 2', access: 'editors'}, + {id: 0, email: 'viewer@example.com', name: 'Viewer', access: 'viewers'}, + {id: 0, email: 'unknown@example.com', name: 'Unknown User', access: null}, + ]; + } + + // Compile a list of users mentioned in user attribute tables keyed by email. + // If there is a Name column or an Access column, in the table, we use them. + public async collectViewAsUsersFromUserAttributeTables(): Promise>> { + const result: Array> = []; + for (const clause of this._ruler.ruleCollection.getUserAttributeRules().values()) { + if (clause.charId !== 'Email') { continue; } + try { + const users = await this._fetchQueryFromDB({ + tableId: clause.tableId, + filters: {}, + }); + const user = new RecordView(users, undefined); + const count = users[2].length; + for (let i = 0; i < count; i++) { + user.index = i; + const email = user.get(clause.lookupColId); + const name = user.get('Name') || String(email).split('@')[0]; + const access = user.has('Access') ? String(user.get('Access')) : 'editors'; + result.push({ + email: email ? String(email) : undefined, + name: name ? String(name) : undefined, + access: isValidRole(access) ? access : null, // 'null' -> null a bit circuitously + }); + } + } catch (e) { + log.warn(`User attribute ${clause.name} failed`, e); + } + } + return result; + } + /** * Get the role the session user has for this document. User may be overridden, * in which case the role of the override is returned. @@ -1243,18 +1287,8 @@ export class GranularAccess implements GranularAccessForBundle { access = attrs.override.access; fullUser = attrs.override.user; } else { - // Look up user information in database. - if (!this._homeDbManager) { throw new Error('database required'); } - const dbUser = linkParameters.aclAsUserId ? - (await this._homeDbManager.getUser(integerParam(linkParameters.aclAsUserId))) : - (await this._homeDbManager.getUserByLogin(linkParameters.aclAsUser)); - const docAuth = dbUser && await this._homeDbManager.getDocAuthCached({ - urlId: this._docId, - userId: dbUser.id - }); - access = docAuth?.access || null; - fullUser = dbUser && this._homeDbManager.makeFullUser(dbUser) || null; - attrs.override = { access, user: fullUser }; + attrs.override = await this._getViewAsUser(linkParameters); + fullUser = attrs.override.user; } } else { fullUser = getDocSessionUser(docSession); @@ -1305,6 +1339,45 @@ export class GranularAccess implements GranularAccessForBundle { return user; } + /** + * Get the "View As" user specified in link parameters. + * If aclAsUserId is set, we get the user with the specified id. + * If aclAsUser is set, we get the user with the specified email, + * from the database if possible, otherwise from user attribute + * tables or examples. + */ + private async _getViewAsUser(linkParameters: Record): Promise { + // Look up user information in database. + if (!this._homeDbManager) { throw new Error('database required'); } + const dbUser = linkParameters.aclAsUserId ? + (await this._homeDbManager.getUser(integerParam(linkParameters.aclAsUserId))) : + (await this._homeDbManager.getExistingUserByLogin(linkParameters.aclAsUser)); + if (!dbUser && linkParameters.aclAsUser) { + // Look further for the user, in user attribute tables or examples. + const otherUsers = (await this.collectViewAsUsersFromUserAttributeTables()) + .concat(this.getExampleViewAsUsers()); + const email = normalizeEmail(linkParameters.aclAsUser); + const dummyUser = otherUsers.find(user => normalizeEmail(user?.email || '') === email); + if (dummyUser) { + return { + access: dummyUser.access || null, + user: { + id: -1, + email: dummyUser.email!, + name: dummyUser.name || dummyUser.email!, + } + }; + } + } + const docAuth = dbUser && await this._homeDbManager.getDocAuthCached({ + urlId: this._docId, + userId: dbUser.id + }); + const access = docAuth?.access || null; + const user = dbUser && this._homeDbManager.makeFullUser(dbUser) || null; + return { access, user }; + } + /** * Remove a set of rows from a DocAction. If the DocAction ends up empty, null is returned. * If the DocAction needs modification, it is copied first - the original is never @@ -1801,6 +1874,10 @@ export class RecordView implements InfoView { return this.data[3][colId]?.[this.index]; } + public has(colId: string) { + return colId === 'id' || colId in this.data[3]; + } + public toJSON() { if (this.index === undefined) { return {}; } const results: {[key: string]: any} = {};