mirror of
				https://github.com/gristlabs/grist-core.git
				synced 2025-06-13 20:53:59 +00:00 
			
		
		
		
	(core) updates from grist-core
This commit is contained in:
		
						commit
						c0ce791e28
					
				@ -35,6 +35,9 @@ export const ANONYMOUS_USER_EMAIL = 'anon@getgrist.com';
 | 
			
		||||
// Nominal email address of a user who, if you share with them, everyone gets access.
 | 
			
		||||
export const EVERYONE_EMAIL = 'everyone@getgrist.com';
 | 
			
		||||
 | 
			
		||||
// Nominal email address of a user who can view anything (for thumbnails).
 | 
			
		||||
export const PREVIEWER_EMAIL = 'thumbnail@getgrist.com';
 | 
			
		||||
 | 
			
		||||
// A special 'docId' that means to create a new document.
 | 
			
		||||
export const NEW_DOCUMENT_CODE = 'new';
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
										
											
												File diff suppressed because it is too large
												Load Diff
											
										
									
								
							
							
								
								
									
										40
									
								
								app/gen-server/lib/homedb/Interfaces.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										40
									
								
								app/gen-server/lib/homedb/Interfaces.ts
									
									
									
									
									
										Normal file
									
								
							@ -0,0 +1,40 @@
 | 
			
		||||
import { UserProfile } from "app/common/LoginSessionAPI";
 | 
			
		||||
import { UserOptions } from "app/common/UserAPI";
 | 
			
		||||
import * as roles from 'app/common/roles';
 | 
			
		||||
import { Document } from "app/gen-server/entity/Document";
 | 
			
		||||
import { Group } from "app/gen-server/entity/Group";
 | 
			
		||||
import { Organization } from "app/gen-server/entity/Organization";
 | 
			
		||||
import { Workspace } from "app/gen-server/entity/Workspace";
 | 
			
		||||
 | 
			
		||||
import { EntityManager } from "typeorm";
 | 
			
		||||
 | 
			
		||||
export interface QueryResult<T> {
 | 
			
		||||
  status: number;
 | 
			
		||||
  data?: T;
 | 
			
		||||
  errMessage?: string;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export interface GetUserOptions {
 | 
			
		||||
  manager?: EntityManager;
 | 
			
		||||
  profile?: UserProfile;
 | 
			
		||||
  userOptions?: UserOptions;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export interface UserProfileChange {
 | 
			
		||||
  name?: string;
 | 
			
		||||
  isFirstTimeUser?: boolean;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
// A specification of the users available during a request.  This can be a single
 | 
			
		||||
// user, identified by a user id, or a collection of profiles (typically drawn from
 | 
			
		||||
// the session).
 | 
			
		||||
export type AvailableUsers = number | UserProfile[];
 | 
			
		||||
 | 
			
		||||
export type NonGuestGroup = Group & { name: roles.NonGuestRole };
 | 
			
		||||
 | 
			
		||||
export type Resource = Organization|Workspace|Document;
 | 
			
		||||
 | 
			
		||||
export type RunInTransaction = (
 | 
			
		||||
  transaction: EntityManager|undefined,
 | 
			
		||||
  op: ((manager: EntityManager) => Promise<any>)
 | 
			
		||||
) => Promise<any>;
 | 
			
		||||
							
								
								
									
										755
									
								
								app/gen-server/lib/homedb/UsersManager.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										755
									
								
								app/gen-server/lib/homedb/UsersManager.ts
									
									
									
									
									
										Normal file
									
								
							@ -0,0 +1,755 @@
 | 
			
		||||
import { ApiError } from 'app/common/ApiError';
 | 
			
		||||
import { normalizeEmail } from 'app/common/emails';
 | 
			
		||||
import { PERSONAL_FREE_PLAN } from 'app/common/Features';
 | 
			
		||||
import { UserOrgPrefs } from 'app/common/Prefs';
 | 
			
		||||
import * as roles from 'app/common/roles';
 | 
			
		||||
import {
 | 
			
		||||
  ANONYMOUS_USER_EMAIL,
 | 
			
		||||
  EVERYONE_EMAIL,
 | 
			
		||||
  FullUser,
 | 
			
		||||
  PermissionDelta,
 | 
			
		||||
  PREVIEWER_EMAIL,
 | 
			
		||||
  UserOptions,
 | 
			
		||||
  UserProfile
 | 
			
		||||
} from 'app/common/UserAPI';
 | 
			
		||||
import { AclRule } from 'app/gen-server/entity/AclRule';
 | 
			
		||||
import { Group } from 'app/gen-server/entity/Group';
 | 
			
		||||
import { Login } from 'app/gen-server/entity/Login';
 | 
			
		||||
import { User } from 'app/gen-server/entity/User';
 | 
			
		||||
import { appSettings } from 'app/server/lib/AppSettings';
 | 
			
		||||
import { HomeDBManager, PermissionDeltaAnalysis, Scope } from 'app/gen-server/lib/HomeDBManager';
 | 
			
		||||
import {
 | 
			
		||||
  AvailableUsers, GetUserOptions, NonGuestGroup, QueryResult, Resource, RunInTransaction, UserProfileChange
 | 
			
		||||
} from 'app/gen-server/lib/homedb/Interfaces';
 | 
			
		||||
import { Permissions } from 'app/gen-server/lib/Permissions';
 | 
			
		||||
import { Pref } from 'app/gen-server/entity/Pref';
 | 
			
		||||
 | 
			
		||||
import flatten from 'lodash/flatten';
 | 
			
		||||
import { EntityManager } from 'typeorm';
 | 
			
		||||
 | 
			
		||||
// A special user allowed to add/remove the EVERYONE_EMAIL to/from a resource.
 | 
			
		||||
export const SUPPORT_EMAIL = appSettings.section('access').flag('supportEmail').requireString({
 | 
			
		||||
  envVar: 'GRIST_SUPPORT_EMAIL',
 | 
			
		||||
  defaultValue: 'support@getgrist.com',
 | 
			
		||||
});
 | 
			
		||||
 | 
			
		||||
// A list of emails we don't expect to see logins for.
 | 
			
		||||
const NON_LOGIN_EMAILS = [PREVIEWER_EMAIL, EVERYONE_EMAIL, ANONYMOUS_USER_EMAIL];
 | 
			
		||||
 | 
			
		||||
/**
 | 
			
		||||
 * Class responsible for Users Management.
 | 
			
		||||
 *
 | 
			
		||||
 * It's only meant to be used by HomeDBManager. If you want to use one of its (instance or static) methods,
 | 
			
		||||
 * please make an indirection which passes through HomeDBManager.
 | 
			
		||||
 */
 | 
			
		||||
export class UsersManager {
 | 
			
		||||
  public static isSingleUser(users: AvailableUsers): users is number {
 | 
			
		||||
    return typeof users === 'number';
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  // Returns all first-level memberUsers in the resources. Requires all resources' aclRules, groups
 | 
			
		||||
  // and memberUsers to be populated.
 | 
			
		||||
  // If optRoles is provided, only checks membership in resource groups with the given roles.
 | 
			
		||||
  public static getResourceUsers(res: Resource|Resource[], optRoles?: string[]): User[] {
 | 
			
		||||
    res = Array.isArray(res) ? res : [res];
 | 
			
		||||
    const users: {[uid: string]: User} = {};
 | 
			
		||||
    let resAcls: AclRule[] = flatten(res.map(_res => _res.aclRules as AclRule[]));
 | 
			
		||||
    if (optRoles) {
 | 
			
		||||
      resAcls = resAcls.filter(_acl => optRoles.includes(_acl.group.name));
 | 
			
		||||
    }
 | 
			
		||||
    resAcls.forEach((aclRule: AclRule) => {
 | 
			
		||||
      aclRule.group.memberUsers.forEach((u: User) => users[u.id] = u);
 | 
			
		||||
    });
 | 
			
		||||
    const userList = Object.keys(users).map(uid => users[uid]);
 | 
			
		||||
    userList.sort((a, b) => a.id - b.id);
 | 
			
		||||
    return userList;
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  // Returns a map of users indexed by their roles. Optionally excludes users whose ids are in
 | 
			
		||||
  // excludeUsers.
 | 
			
		||||
  public static getUsersWithRole(groups: NonGuestGroup[], excludeUsers?: number[]): Map<roles.NonGuestRole, User[]> {
 | 
			
		||||
    const members = new Map<roles.NonGuestRole, User[]>();
 | 
			
		||||
    for (const group of groups) {
 | 
			
		||||
      let users = group.memberUsers;
 | 
			
		||||
      if (excludeUsers) {
 | 
			
		||||
        users = users.filter((user) => !excludeUsers.includes(user.id));
 | 
			
		||||
      }
 | 
			
		||||
      members.set(group.name, users);
 | 
			
		||||
    }
 | 
			
		||||
    return members;
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  private _specialUserIds: {[name: string]: number} = {}; // id for anonymous user, previewer, etc
 | 
			
		||||
 | 
			
		||||
  private get _connection () {
 | 
			
		||||
    return this._homeDb.connection;
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  public constructor(
 | 
			
		||||
    private readonly _homeDb: HomeDBManager,
 | 
			
		||||
    private _runInTransaction: RunInTransaction
 | 
			
		||||
  ) {}
 | 
			
		||||
 | 
			
		||||
  /**
 | 
			
		||||
   * Clear all user preferences associated with the given email addresses.
 | 
			
		||||
   * For use in tests.
 | 
			
		||||
   */
 | 
			
		||||
  public async testClearUserPrefs(emails: string[]) {
 | 
			
		||||
    return await this._connection.transaction(async manager => {
 | 
			
		||||
      for (const email of emails) {
 | 
			
		||||
        const user = await this.getUserByLogin(email, {manager});
 | 
			
		||||
        if (user) {
 | 
			
		||||
          await manager.delete(Pref, {userId: user.id});
 | 
			
		||||
        }
 | 
			
		||||
      }
 | 
			
		||||
    });
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  public getSpecialUserId(key: string) {
 | 
			
		||||
    return this._specialUserIds[key];
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  /**
 | 
			
		||||
   *
 | 
			
		||||
   * Get the id of the anonymous user.
 | 
			
		||||
   *
 | 
			
		||||
   */
 | 
			
		||||
  public getAnonymousUserId(): number {
 | 
			
		||||
    const id = this._specialUserIds[ANONYMOUS_USER_EMAIL];
 | 
			
		||||
    if (!id) { throw new Error("Anonymous user not available"); }
 | 
			
		||||
    return id;
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  /**
 | 
			
		||||
   * Get the id of the thumbnail user.
 | 
			
		||||
   */
 | 
			
		||||
  public getPreviewerUserId(): number {
 | 
			
		||||
    const id = this._specialUserIds[PREVIEWER_EMAIL];
 | 
			
		||||
    if (!id) { throw new Error("Previewer user not available"); }
 | 
			
		||||
    return id;
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  /**
 | 
			
		||||
   * Get the id of the 'everyone' user.
 | 
			
		||||
   */
 | 
			
		||||
  public getEveryoneUserId(): number {
 | 
			
		||||
    const id = this._specialUserIds[EVERYONE_EMAIL];
 | 
			
		||||
    if (!id) { throw new Error("'everyone' user not available"); }
 | 
			
		||||
    return id;
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  /**
 | 
			
		||||
   * Get the id of the 'support' user.
 | 
			
		||||
   */
 | 
			
		||||
  public getSupportUserId(): number {
 | 
			
		||||
    const id = this._specialUserIds[SUPPORT_EMAIL];
 | 
			
		||||
    if (!id) { throw new Error("'support' user not available"); }
 | 
			
		||||
    return id;
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  public async getUserByKey(apiKey: string): Promise<User|undefined> {
 | 
			
		||||
    // Include logins relation for Authorization convenience.
 | 
			
		||||
    return await User.findOne({where: {apiKey}, relations: ["logins"]}) || undefined;
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  public async getUserByRef(ref: string): Promise<User|undefined> {
 | 
			
		||||
    return await User.findOne({where: {ref}, relations: ["logins"]}) || undefined;
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  public async getUser(
 | 
			
		||||
    userId: number,
 | 
			
		||||
    options: {includePrefs?: boolean} = {}
 | 
			
		||||
  ): Promise<User|undefined> {
 | 
			
		||||
    const {includePrefs} = options;
 | 
			
		||||
    const relations = ["logins"];
 | 
			
		||||
    if (includePrefs) { relations.push("prefs"); }
 | 
			
		||||
    return await User.findOne({where: {id: userId}, relations}) || undefined;
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  public async getFullUser(userId: number): Promise<FullUser> {
 | 
			
		||||
    const user = await User.findOne({where: {id: userId}, relations: ["logins"]});
 | 
			
		||||
    if (!user) { throw new ApiError("unable to find user", 400); }
 | 
			
		||||
    return this.makeFullUser(user);
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  /**
 | 
			
		||||
   * Convert a user record into the format specified in api.
 | 
			
		||||
   */
 | 
			
		||||
  public makeFullUser(user: User): FullUser {
 | 
			
		||||
    if (!user.logins?.[0]?.displayEmail) {
 | 
			
		||||
      throw new ApiError("unable to find mandatory user email", 400);
 | 
			
		||||
    }
 | 
			
		||||
    const displayEmail = user.logins[0].displayEmail;
 | 
			
		||||
    const loginEmail = user.loginEmail;
 | 
			
		||||
    const result: FullUser = {
 | 
			
		||||
      id: user.id,
 | 
			
		||||
      email: displayEmail,
 | 
			
		||||
      // Only include loginEmail when it's different, to avoid overhead when FullUser is sent
 | 
			
		||||
      // around, and also to avoid updating too many tests.
 | 
			
		||||
      loginEmail: loginEmail !== displayEmail ? loginEmail : undefined,
 | 
			
		||||
      name: user.name,
 | 
			
		||||
      picture: user.picture,
 | 
			
		||||
      ref: user.ref,
 | 
			
		||||
      locale: user.options?.locale,
 | 
			
		||||
      prefs: user.prefs?.find((p)=> p.orgId === null)?.prefs,
 | 
			
		||||
    };
 | 
			
		||||
    if (this.getAnonymousUserId() === user.id) {
 | 
			
		||||
      result.anonymous = true;
 | 
			
		||||
    }
 | 
			
		||||
    if (this.getSupportUserId() === user.id) {
 | 
			
		||||
      result.isSupport = true;
 | 
			
		||||
    }
 | 
			
		||||
    return result;
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  /**
 | 
			
		||||
   * Ensures that user with external id exists and updates its profile and email if necessary.
 | 
			
		||||
   *
 | 
			
		||||
   * @param profile External profile
 | 
			
		||||
   */
 | 
			
		||||
  public async ensureExternalUser(profile: UserProfile) {
 | 
			
		||||
    await this._connection.transaction(async manager => {
 | 
			
		||||
      // First find user by the connectId from the profile
 | 
			
		||||
      const existing = await manager.findOne(User, {
 | 
			
		||||
        where: {connectId: profile.connectId || undefined},
 | 
			
		||||
        relations: ["logins"],
 | 
			
		||||
      });
 | 
			
		||||
 | 
			
		||||
      // If a user does not exist, create it with data from the external profile.
 | 
			
		||||
      if (!existing) {
 | 
			
		||||
        const newUser = await this.getUserByLoginWithRetry(profile.email, {
 | 
			
		||||
          profile,
 | 
			
		||||
          manager
 | 
			
		||||
        });
 | 
			
		||||
        if (!newUser) {
 | 
			
		||||
          throw new ApiError("Unable to create user", 500);
 | 
			
		||||
        }
 | 
			
		||||
        // No need to survey this user.
 | 
			
		||||
        newUser.isFirstTimeUser = false;
 | 
			
		||||
        await newUser.save();
 | 
			
		||||
      } else {
 | 
			
		||||
        // Else update profile and login information from external profile.
 | 
			
		||||
        let updated = false;
 | 
			
		||||
        let login: Login = existing.logins[0]!;
 | 
			
		||||
        const properEmail = normalizeEmail(profile.email);
 | 
			
		||||
 | 
			
		||||
        if (properEmail !== existing.loginEmail) {
 | 
			
		||||
          login = login ?? new Login();
 | 
			
		||||
          login.email = properEmail;
 | 
			
		||||
          login.displayEmail = profile.email;
 | 
			
		||||
          existing.logins.splice(0, 1, login);
 | 
			
		||||
          login.user = existing;
 | 
			
		||||
          updated = true;
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        if (profile?.name && profile?.name !== existing.name) {
 | 
			
		||||
          existing.name = profile.name;
 | 
			
		||||
          updated = true;
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        if (profile?.picture && profile?.picture !== existing.picture) {
 | 
			
		||||
          existing.picture = profile.picture;
 | 
			
		||||
          updated = true;
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        if (updated) {
 | 
			
		||||
          await manager.save([existing, login]);
 | 
			
		||||
        }
 | 
			
		||||
      }
 | 
			
		||||
    });
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  public async updateUser(userId: number, props: UserProfileChange) {
 | 
			
		||||
    let isWelcomed: boolean = false;
 | 
			
		||||
    let user: User|null = null;
 | 
			
		||||
    await this._connection.transaction(async manager => {
 | 
			
		||||
      user = await manager.findOne(User, {relations: ['logins'],
 | 
			
		||||
                                          where: {id: userId}});
 | 
			
		||||
      let needsSave = false;
 | 
			
		||||
      if (!user) { throw new ApiError("unable to find user", 400); }
 | 
			
		||||
      if (props.name && props.name !== user.name) {
 | 
			
		||||
        user.name = props.name;
 | 
			
		||||
        needsSave = true;
 | 
			
		||||
      }
 | 
			
		||||
      if (props.isFirstTimeUser !== undefined && props.isFirstTimeUser !== user.isFirstTimeUser) {
 | 
			
		||||
        user.isFirstTimeUser = props.isFirstTimeUser;
 | 
			
		||||
        needsSave = true;
 | 
			
		||||
        // If we are turning off the isFirstTimeUser flag, then right
 | 
			
		||||
        // after this transaction commits is a great time to trigger
 | 
			
		||||
        // any automation for first logins
 | 
			
		||||
        if (!props.isFirstTimeUser) { isWelcomed = true; }
 | 
			
		||||
      }
 | 
			
		||||
      if (needsSave) {
 | 
			
		||||
        await user.save();
 | 
			
		||||
      }
 | 
			
		||||
    });
 | 
			
		||||
    return { user, isWelcomed };
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  public async updateUserName(userId: number, name: string) {
 | 
			
		||||
    const user = await User.findOne({where: {id: userId}});
 | 
			
		||||
    if (!user) { throw new ApiError("unable to find user", 400); }
 | 
			
		||||
    user.name = name;
 | 
			
		||||
    await user.save();
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  public async updateUserOptions(userId: number, props: Partial<UserOptions>) {
 | 
			
		||||
    const user = await User.findOne({where: {id: userId}});
 | 
			
		||||
    if (!user) { throw new ApiError("unable to find user", 400); }
 | 
			
		||||
 | 
			
		||||
    const newOptions = {...(user.options ?? {}), ...props};
 | 
			
		||||
    user.options = newOptions;
 | 
			
		||||
    await user.save();
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  /**
 | 
			
		||||
   * Get the anonymous user, as a constructed object rather than a database lookup.
 | 
			
		||||
   */
 | 
			
		||||
  public getAnonymousUser(): User {
 | 
			
		||||
    const user = new User();
 | 
			
		||||
    user.id = this.getAnonymousUserId();
 | 
			
		||||
    user.name = "Anonymous";
 | 
			
		||||
    user.isFirstTimeUser = false;
 | 
			
		||||
    const login = new Login();
 | 
			
		||||
    login.displayEmail = login.email = ANONYMOUS_USER_EMAIL;
 | 
			
		||||
    user.logins = [login];
 | 
			
		||||
    user.ref = '';
 | 
			
		||||
    return user;
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  // Fetch user from login, creating the user if previously unseen, allowing one retry
 | 
			
		||||
  // for an email key conflict failure. This is in case our transaction conflicts with a peer
 | 
			
		||||
  // doing the same thing. This is quite likely if the first page visited by a previously
 | 
			
		||||
  // unseen user fires off multiple api calls.
 | 
			
		||||
  public async getUserByLoginWithRetry(email: string, options: GetUserOptions = {}): Promise<User|undefined> {
 | 
			
		||||
    try {
 | 
			
		||||
      return await this.getUserByLogin(email, options);
 | 
			
		||||
    } catch (e) {
 | 
			
		||||
      if (e.name === 'QueryFailedError' && e.detail &&
 | 
			
		||||
          e.detail.match(/Key \(email\)=[^ ]+ already exists/)) {
 | 
			
		||||
        // This is a postgres-specific error message. This problem cannot arise in sqlite,
 | 
			
		||||
        // because we have to serialize sqlite transactions in any case to get around a typeorm
 | 
			
		||||
        // limitation.
 | 
			
		||||
        return await this.getUserByLogin(email, options);
 | 
			
		||||
      }
 | 
			
		||||
      throw e;
 | 
			
		||||
    }
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  /**
 | 
			
		||||
   * Find a user by email. Don't create the user if it doesn't already exist.
 | 
			
		||||
   */
 | 
			
		||||
  public async getExistingUserByLogin(
 | 
			
		||||
    email: string,
 | 
			
		||||
    manager?: EntityManager
 | 
			
		||||
  ): Promise<User|undefined> {
 | 
			
		||||
    const normalizedEmail = normalizeEmail(email);
 | 
			
		||||
    return await (manager || this._connection).createQueryBuilder()
 | 
			
		||||
      .select('user')
 | 
			
		||||
      .from(User, 'user')
 | 
			
		||||
      .leftJoinAndSelect('user.logins', 'logins')
 | 
			
		||||
      .where('email = :email', {email: normalizedEmail})
 | 
			
		||||
      .getOne() || undefined;
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  /**
 | 
			
		||||
   *
 | 
			
		||||
   * Fetches a user record based on an email address. If a user record already
 | 
			
		||||
   * exists linked to the email address supplied, that is the record returned.
 | 
			
		||||
   * Otherwise a fresh record is created, linked to the supplied email address.
 | 
			
		||||
   * The supplied `options` are used when creating a fresh record, or updating
 | 
			
		||||
   * unset/outdated fields of an existing record.
 | 
			
		||||
   *
 | 
			
		||||
   */
 | 
			
		||||
  public async getUserByLogin(email: string, options: GetUserOptions = {}): Promise<User|undefined> {
 | 
			
		||||
    const {manager: transaction, profile, userOptions} = options;
 | 
			
		||||
    const normalizedEmail = normalizeEmail(email);
 | 
			
		||||
    const userByLogin = await this._runInTransaction(transaction, async manager => {
 | 
			
		||||
      let needUpdate = false;
 | 
			
		||||
      const userQuery = manager.createQueryBuilder()
 | 
			
		||||
        .select('user')
 | 
			
		||||
        .from(User, 'user')
 | 
			
		||||
        .leftJoinAndSelect('user.logins', 'logins')
 | 
			
		||||
        .leftJoinAndSelect('user.personalOrg', 'personalOrg')
 | 
			
		||||
        .where('email = :email', {email: normalizedEmail});
 | 
			
		||||
      let user = await userQuery.getOne();
 | 
			
		||||
      let login: Login;
 | 
			
		||||
      if (!user) {
 | 
			
		||||
        user = new User();
 | 
			
		||||
        // Special users do not have first time user set so that they don't get redirected to the
 | 
			
		||||
        // welcome page.
 | 
			
		||||
        user.isFirstTimeUser = !NON_LOGIN_EMAILS.includes(normalizedEmail);
 | 
			
		||||
        login = new Login();
 | 
			
		||||
        login.email = normalizedEmail;
 | 
			
		||||
        login.user = user;
 | 
			
		||||
        needUpdate = true;
 | 
			
		||||
      } else {
 | 
			
		||||
        login = user.logins[0];
 | 
			
		||||
      }
 | 
			
		||||
 | 
			
		||||
      // Check that user and login records are up to date.
 | 
			
		||||
      if (!user.name) {
 | 
			
		||||
        // Set the user's name if our provider knows it. Otherwise use their username
 | 
			
		||||
        // from email, for lack of something better. If we don't have a profile at this
 | 
			
		||||
        // time, then leave the name blank in the hopes of learning it when the user logs in.
 | 
			
		||||
        user.name = (profile && (profile.name || email.split('@')[0])) || '';
 | 
			
		||||
        needUpdate = true;
 | 
			
		||||
      }
 | 
			
		||||
      if (profile && !user.firstLoginAt) {
 | 
			
		||||
        // set first login time to now (remove milliseconds for compatibility with other
 | 
			
		||||
        // timestamps in db set by typeorm, and since second level precision is fine)
 | 
			
		||||
        const nowish = new Date();
 | 
			
		||||
        nowish.setMilliseconds(0);
 | 
			
		||||
        user.firstLoginAt = nowish;
 | 
			
		||||
        needUpdate = true;
 | 
			
		||||
      }
 | 
			
		||||
      if (!user.picture && profile && profile.picture) {
 | 
			
		||||
        // Set the user's profile picture if our provider knows it.
 | 
			
		||||
        user.picture = profile.picture;
 | 
			
		||||
        needUpdate = true;
 | 
			
		||||
      }
 | 
			
		||||
      if (profile && profile.email && profile.email !== login.displayEmail) {
 | 
			
		||||
        // Use provider's version of email address for display.
 | 
			
		||||
        login.displayEmail = profile.email;
 | 
			
		||||
        needUpdate = true;
 | 
			
		||||
      }
 | 
			
		||||
 | 
			
		||||
      if (profile?.connectId && profile?.connectId !== user.connectId) {
 | 
			
		||||
        user.connectId = profile.connectId;
 | 
			
		||||
        needUpdate = true;
 | 
			
		||||
      }
 | 
			
		||||
 | 
			
		||||
      if (!login.displayEmail) {
 | 
			
		||||
        // Save some kind of display email if we don't have anything at all for it yet.
 | 
			
		||||
        // This could be coming from how someone wrote it in a UserManager dialog, for
 | 
			
		||||
        // instance. It will get overwritten when the user logs in if the provider's
 | 
			
		||||
        // version is different.
 | 
			
		||||
        login.displayEmail = email;
 | 
			
		||||
        needUpdate = true;
 | 
			
		||||
      }
 | 
			
		||||
      if (!user.options?.authSubject && userOptions?.authSubject) {
 | 
			
		||||
        // Link subject from password-based authentication provider if not previously linked.
 | 
			
		||||
        user.options = {...(user.options ?? {}), authSubject: userOptions.authSubject};
 | 
			
		||||
        needUpdate = true;
 | 
			
		||||
      }
 | 
			
		||||
      if (needUpdate) {
 | 
			
		||||
        login.user = user;
 | 
			
		||||
        await manager.save([user, login]);
 | 
			
		||||
      }
 | 
			
		||||
      if (!user.personalOrg && !NON_LOGIN_EMAILS.includes(login.email)) {
 | 
			
		||||
        // Add a personal organization for this user.
 | 
			
		||||
        // We don't add a personal org for anonymous/everyone/previewer "users" as it could
 | 
			
		||||
        // get a bit confusing.
 | 
			
		||||
        const result = await this._homeDb.addOrg(user, {name: "Personal"}, {
 | 
			
		||||
          setUserAsOwner: true,
 | 
			
		||||
          useNewPlan: true,
 | 
			
		||||
          product: PERSONAL_FREE_PLAN,
 | 
			
		||||
        }, manager);
 | 
			
		||||
        if (result.status !== 200) {
 | 
			
		||||
          throw new Error(result.errMessage);
 | 
			
		||||
        }
 | 
			
		||||
        needUpdate = true;
 | 
			
		||||
 | 
			
		||||
        // We just created a personal org; set userOrgPrefs that should apply for new users only.
 | 
			
		||||
        const userOrgPrefs: UserOrgPrefs = {showGristTour: true};
 | 
			
		||||
        const orgId = result.data;
 | 
			
		||||
        if (orgId) {
 | 
			
		||||
          await this._homeDb.updateOrg({userId: user.id}, orgId, {userOrgPrefs}, manager);
 | 
			
		||||
        }
 | 
			
		||||
      }
 | 
			
		||||
      if (needUpdate) {
 | 
			
		||||
        // We changed the db - reload user in order to give consistent results.
 | 
			
		||||
        // In principle this could be optimized, but this is simpler to maintain.
 | 
			
		||||
        user = await userQuery.getOne();
 | 
			
		||||
      }
 | 
			
		||||
      return user;
 | 
			
		||||
    });
 | 
			
		||||
    return userByLogin;
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  /**
 | 
			
		||||
   * Deletes a user from the database. For the moment, the only person with the right
 | 
			
		||||
   * to delete a user is the user themselves.
 | 
			
		||||
   * Users have logins, a personal org, and entries in the group_users table. All are
 | 
			
		||||
   * removed together in a transaction. All material in the personal org will be lost.
 | 
			
		||||
   *
 | 
			
		||||
   * @param scope: request scope, including the id of the user initiating this action
 | 
			
		||||
   * @param userIdToDelete: the id of the user to delete from the database
 | 
			
		||||
   * @param name: optional cross-check, delete only if user name matches this
 | 
			
		||||
   */
 | 
			
		||||
  public async deleteUser(scope: Scope, userIdToDelete: number,
 | 
			
		||||
                          name?: string): Promise<QueryResult<void>> {
 | 
			
		||||
    const userIdDeleting = scope.userId;
 | 
			
		||||
    if (userIdDeleting !== userIdToDelete) {
 | 
			
		||||
      throw new ApiError('not permitted to delete this user', 403);
 | 
			
		||||
    }
 | 
			
		||||
    await this._connection.transaction(async manager => {
 | 
			
		||||
      const user = await manager.findOne(User, {where: {id: userIdToDelete},
 | 
			
		||||
                                                relations: ["logins", "personalOrg", "prefs"]});
 | 
			
		||||
      if (!user) { throw new ApiError('user not found', 404); }
 | 
			
		||||
      if (name) {
 | 
			
		||||
        if (user.name !== name) {
 | 
			
		||||
          throw new ApiError(`user name did not match ('${name}' vs '${user.name}')`, 400);
 | 
			
		||||
        }
 | 
			
		||||
      }
 | 
			
		||||
      if (user.personalOrg) { await this._homeDb.deleteOrg(scope, user.personalOrg.id, manager); }
 | 
			
		||||
      await manager.remove([...user.logins]);
 | 
			
		||||
      // We don't have a GroupUser entity, and adding one tickles lots of TypeOrm quirkiness,
 | 
			
		||||
      // so use a plain query to delete entries in the group_users table.
 | 
			
		||||
      await manager.createQueryBuilder()
 | 
			
		||||
        .delete()
 | 
			
		||||
        .from('group_users')
 | 
			
		||||
        .where('user_id = :userId', {userId: userIdToDelete})
 | 
			
		||||
        .execute();
 | 
			
		||||
 | 
			
		||||
      await manager.delete(User, userIdToDelete);
 | 
			
		||||
    });
 | 
			
		||||
    return {
 | 
			
		||||
      status: 200
 | 
			
		||||
    };
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  // Looks up the emails in the permission delta and adds them to the users map in
 | 
			
		||||
  // the delta object.
 | 
			
		||||
  // Returns a QueryResult based on the validity of the passed in PermissionDelta object.
 | 
			
		||||
  public async verifyAndLookupDeltaEmails(
 | 
			
		||||
    userId: number,
 | 
			
		||||
    delta: PermissionDelta,
 | 
			
		||||
    isOrg: boolean = false,
 | 
			
		||||
    transaction?: EntityManager
 | 
			
		||||
  ): Promise<PermissionDeltaAnalysis> {
 | 
			
		||||
    if (!delta) {
 | 
			
		||||
      throw new ApiError('Bad request: missing permission delta', 400);
 | 
			
		||||
    }
 | 
			
		||||
    this._mergeIndistinguishableEmails(delta);
 | 
			
		||||
    const hasInherit = 'maxInheritedRole' in delta;
 | 
			
		||||
    const hasUsers = delta.users; // allow zero actual changes; useful to reduce special
 | 
			
		||||
                                  // cases in scripts
 | 
			
		||||
    if ((isOrg && (hasInherit || !hasUsers)) || (!isOrg && !hasInherit && !hasUsers)) {
 | 
			
		||||
      throw new ApiError('Bad request: invalid permission delta', 400);
 | 
			
		||||
    }
 | 
			
		||||
    // Lookup the email access changes and move them to the users object.
 | 
			
		||||
    const userIdMap: {[userId: string]: roles.NonGuestRole|null} = {};
 | 
			
		||||
    if (hasInherit) {
 | 
			
		||||
      // Verify maxInheritedRole
 | 
			
		||||
      const role = delta.maxInheritedRole;
 | 
			
		||||
      const validRoles = new Set(this._homeDb.defaultBasicGroupNames);
 | 
			
		||||
      if (role && !validRoles.has(role)) {
 | 
			
		||||
        throw new ApiError(`Invalid maxInheritedRole ${role}`, 400);
 | 
			
		||||
      }
 | 
			
		||||
    }
 | 
			
		||||
    if (delta.users) {
 | 
			
		||||
      // Verify roles
 | 
			
		||||
      const deltaRoles = Object.keys(delta.users).map(_userId => delta.users![_userId]);
 | 
			
		||||
      // Cannot set role "members" on workspace/doc.
 | 
			
		||||
      const validRoles = new Set(isOrg ? this._homeDb.defaultNonGuestGroupNames : this._homeDb.defaultBasicGroupNames);
 | 
			
		||||
      for (const role of deltaRoles) {
 | 
			
		||||
        if (role && !validRoles.has(role)) {
 | 
			
		||||
          throw new ApiError(`Invalid user role ${role}`, 400);
 | 
			
		||||
        }
 | 
			
		||||
      }
 | 
			
		||||
      // Lookup emails
 | 
			
		||||
      const emailMap = delta.users;
 | 
			
		||||
      const emails = Object.keys(emailMap);
 | 
			
		||||
      const emailUsers = await Promise.all(
 | 
			
		||||
        emails.map(async email => await this.getUserByLogin(email, {manager: transaction}))
 | 
			
		||||
      );
 | 
			
		||||
      emails.forEach((email, i) => {
 | 
			
		||||
        const userIdAffected = emailUsers[i]!.id;
 | 
			
		||||
        // Org-level sharing with everyone would allow serious spamming - forbid it.
 | 
			
		||||
        if (emailMap[email] !== null &&                    // allow removing anything
 | 
			
		||||
            userId !== this.getSupportUserId() &&          // allow support user latitude
 | 
			
		||||
            userIdAffected === this.getEveryoneUserId() &&
 | 
			
		||||
            isOrg) {
 | 
			
		||||
            throw new ApiError('This user cannot share with everyone at top level', 403);
 | 
			
		||||
        }
 | 
			
		||||
        userIdMap[userIdAffected] = emailMap[email];
 | 
			
		||||
      });
 | 
			
		||||
    }
 | 
			
		||||
    const userIdDelta = delta.users ? userIdMap : null;
 | 
			
		||||
    const userIds = Object.keys(userIdDelta || {});
 | 
			
		||||
    const removingSelf = userIds.length === 1 && userIds[0] === String(userId) &&
 | 
			
		||||
      delta.maxInheritedRole === undefined && userIdDelta?.[userId] === null;
 | 
			
		||||
    const permissionThreshold = removingSelf ? Permissions.VIEW : Permissions.ACL_EDIT;
 | 
			
		||||
    return {
 | 
			
		||||
      userIdDelta,
 | 
			
		||||
      permissionThreshold,
 | 
			
		||||
      affectsSelf: userId in userIdMap,
 | 
			
		||||
    };
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  public async initializeSpecialIds(): Promise<void> {
 | 
			
		||||
    await this._maybeCreateSpecialUserId({
 | 
			
		||||
      email: ANONYMOUS_USER_EMAIL,
 | 
			
		||||
      name: "Anonymous"
 | 
			
		||||
    });
 | 
			
		||||
    await this._maybeCreateSpecialUserId({
 | 
			
		||||
      email: PREVIEWER_EMAIL,
 | 
			
		||||
      name: "Preview"
 | 
			
		||||
    });
 | 
			
		||||
    await this._maybeCreateSpecialUserId({
 | 
			
		||||
      email: EVERYONE_EMAIL,
 | 
			
		||||
      name: "Everyone"
 | 
			
		||||
    });
 | 
			
		||||
    await this._maybeCreateSpecialUserId({
 | 
			
		||||
      email: SUPPORT_EMAIL,
 | 
			
		||||
      name: "Support"
 | 
			
		||||
    });
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  /**
 | 
			
		||||
   * Check for anonymous user, either encoded directly as an id, or as a singular
 | 
			
		||||
   * profile (this case arises during processing of the session/access/all endpoint
 | 
			
		||||
   * whether we are checking for available orgs without committing yet to a particular
 | 
			
		||||
   * choice of user).
 | 
			
		||||
   */
 | 
			
		||||
  public isAnonymousUser(users: AvailableUsers): boolean {
 | 
			
		||||
    return UsersManager.isSingleUser(users) ? users === this.getAnonymousUserId() :
 | 
			
		||||
      users.length === 1 && normalizeEmail(users[0].email) === ANONYMOUS_USER_EMAIL;
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  /**
 | 
			
		||||
   * Get ids of users to be excluded from member counts and emails.
 | 
			
		||||
   */
 | 
			
		||||
  public getExcludedUserIds(): number[] {
 | 
			
		||||
    return [this.getSupportUserId(), this.getAnonymousUserId(), this.getEveryoneUserId()];
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  /**
 | 
			
		||||
   * Returns a Promise for an array of User entites for the given userIds.
 | 
			
		||||
   */
 | 
			
		||||
  public async getUsers(userIds: number[], optManager?: EntityManager): Promise<User[]> {
 | 
			
		||||
    if (userIds.length === 0) {
 | 
			
		||||
      return [];
 | 
			
		||||
    }
 | 
			
		||||
    const manager = optManager || new EntityManager(this._connection);
 | 
			
		||||
    const queryBuilder = manager.createQueryBuilder()
 | 
			
		||||
      .select('users')
 | 
			
		||||
      .from(User, 'users')
 | 
			
		||||
      .where('users.id IN (:...userIds)', {userIds});
 | 
			
		||||
    return await queryBuilder.getMany();
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  /**
 | 
			
		||||
   * Don't add everyone@ as a guest, unless also sharing with anon@.
 | 
			
		||||
   * This means that material shared with everyone@ doesn't become
 | 
			
		||||
   * listable/discoverable by default.
 | 
			
		||||
   *
 | 
			
		||||
   * This is a HACK to allow existing example doc setup to continue to
 | 
			
		||||
   * work. It could be removed if we are willing to share the entire
 | 
			
		||||
   * support org with users. E.g. move any material we don't want to
 | 
			
		||||
   * share into a workspace that doesn't inherit ACLs. TODO: remove
 | 
			
		||||
   * this hack, or enhance it up as a way to support discoverability /
 | 
			
		||||
   * listing. It has the advantage of cloning well.
 | 
			
		||||
   */
 | 
			
		||||
  public filterEveryone(users: User[]): User[] {
 | 
			
		||||
    const everyone = this.getEveryoneUserId();
 | 
			
		||||
    const anon = this.getAnonymousUserId();
 | 
			
		||||
    if (users.find(u => u.id === anon)) { return users; }
 | 
			
		||||
    return users.filter(u => u.id !== everyone);
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  // Given two arrays of groups, returns a map of users present in the first array but
 | 
			
		||||
  // not the second, where the map is broken down by user role.
 | 
			
		||||
  // This method is used for checking limits on shares.
 | 
			
		||||
  // Excluded users are removed from the results.
 | 
			
		||||
  public getUserDifference(groupsA: Group[], groupsB: Group[]): Map<roles.NonGuestRole, User[]> {
 | 
			
		||||
    const subtractSet: Set<number> =
 | 
			
		||||
      new Set(flatten(groupsB.map(grp => grp.memberUsers)).map(usr => usr.id));
 | 
			
		||||
    const result = new Map<roles.NonGuestRole, User[]>();
 | 
			
		||||
    for (const group of groupsA) {
 | 
			
		||||
      const name = group.name;
 | 
			
		||||
      if (!roles.isNonGuestRole(name)) { continue; }
 | 
			
		||||
      result.set(name, group.memberUsers.filter(user => !subtractSet.has(user.id)));
 | 
			
		||||
    }
 | 
			
		||||
    return this.withoutExcludedUsers(result);
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  public withoutExcludedUsers(members: Map<roles.NonGuestRole, User[]>): Map<roles.NonGuestRole, User[]> {
 | 
			
		||||
    const excludedUsers = this.getExcludedUserIds();
 | 
			
		||||
    for (const [role, users] of members.entries()) {
 | 
			
		||||
      members.set(role, users.filter((user) => !excludedUsers.includes(user.id)));
 | 
			
		||||
    }
 | 
			
		||||
    return members;
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  /**
 | 
			
		||||
   *
 | 
			
		||||
   * Take a list of user profiles coming from the client's session, correlate
 | 
			
		||||
   * them with Users and Logins in the database, and construct full profiles
 | 
			
		||||
   * with user ids, standardized display emails, pictures, and anonymous flags.
 | 
			
		||||
   *
 | 
			
		||||
   */
 | 
			
		||||
  public async completeProfiles(profiles: UserProfile[]): Promise<FullUser[]> {
 | 
			
		||||
    if (profiles.length === 0) { return []; }
 | 
			
		||||
    const qb = this._connection.createQueryBuilder()
 | 
			
		||||
      .select('logins')
 | 
			
		||||
      .from(Login, 'logins')
 | 
			
		||||
      .leftJoinAndSelect('logins.user', 'user')
 | 
			
		||||
      .where('logins.email in (:...emails)', {emails: profiles.map(profile => normalizeEmail(profile.email))});
 | 
			
		||||
    const completedProfiles: {[email: string]: FullUser} = {};
 | 
			
		||||
    for (const login of await qb.getMany()) {
 | 
			
		||||
      completedProfiles[login.email] = {
 | 
			
		||||
        id: login.user.id,
 | 
			
		||||
        email: login.displayEmail,
 | 
			
		||||
        name: login.user.name,
 | 
			
		||||
        picture: login.user.picture,
 | 
			
		||||
        anonymous: login.user.id === this.getAnonymousUserId(),
 | 
			
		||||
        locale: login.user.options?.locale
 | 
			
		||||
      };
 | 
			
		||||
    }
 | 
			
		||||
    return profiles.map(profile => completedProfiles[normalizeEmail(profile.email)])
 | 
			
		||||
      .filter(profile => profile);
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  // For the moment only the support user can add both everyone@ and anon@ to a
 | 
			
		||||
  // resource, since that allows spam. TODO: enhance or remove.
 | 
			
		||||
  public checkUserChangeAllowed(userId: number, groups: Group[]) {
 | 
			
		||||
    if (userId === this.getSupportUserId()) { return; }
 | 
			
		||||
    const ids = new Set(flatten(groups.map(g => g.memberUsers)).map(u => u.id));
 | 
			
		||||
    if (ids.has(this.getEveryoneUserId()) && ids.has(this.getAnonymousUserId())) {
 | 
			
		||||
      throw new Error('this user cannot share with everyone and anonymous');
 | 
			
		||||
    }
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  /**
 | 
			
		||||
   *
 | 
			
		||||
   * Get the id of a special user, creating that user if it is not already present.
 | 
			
		||||
   *
 | 
			
		||||
   */
 | 
			
		||||
  private async _maybeCreateSpecialUserId(profile: UserProfile) {
 | 
			
		||||
    let id = this._specialUserIds[profile.email];
 | 
			
		||||
    if (!id) {
 | 
			
		||||
      // get or create user - with retry, since there'll be a race to create the
 | 
			
		||||
      // user if a bunch of servers start simultaneously and the user doesn't exist
 | 
			
		||||
      // yet.
 | 
			
		||||
      const user = await this.getUserByLoginWithRetry(profile.email, {profile});
 | 
			
		||||
      if (user) { id = this._specialUserIds[profile.email] = user.id; }
 | 
			
		||||
    }
 | 
			
		||||
    if (!id) { throw new Error(`Could not find or create user ${profile.email}`); }
 | 
			
		||||
    return id;
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  // 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,
 | 
			
		||||
  // assigning it the most powerful permission specified. The email variant perserved
 | 
			
		||||
  // is the earliest alphabetically.
 | 
			
		||||
  private _mergeIndistinguishableEmails(delta: PermissionDelta) {
 | 
			
		||||
    if (!delta.users) { return; }
 | 
			
		||||
    // We normalize emails for comparison, but track how they were capitalized
 | 
			
		||||
    // in order to preserve it. This is worth doing since for the common case
 | 
			
		||||
    // of a user being added to a resource prior to ever logging in, their
 | 
			
		||||
    // displayEmail will be seeded from this value.
 | 
			
		||||
    const displayEmails: {[email: string]: string} = {};
 | 
			
		||||
    // This will be our output.
 | 
			
		||||
    const users: {[email: string]: roles.NonGuestRole|null} = {};
 | 
			
		||||
    for (const displayEmail of Object.keys(delta.users).sort()) {
 | 
			
		||||
      const email = normalizeEmail(displayEmail);
 | 
			
		||||
      const role = delta.users[displayEmail];
 | 
			
		||||
      const key = displayEmails[email] = displayEmails[email] || displayEmail;
 | 
			
		||||
      users[key] = users[key] ? roles.getStrongestRole(users[key], role) : role;
 | 
			
		||||
    }
 | 
			
		||||
    delta.users = users;
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
@ -339,7 +339,9 @@
 | 
			
		||||
        "Stop timing...": "Stop timing...",
 | 
			
		||||
        "Time reload": "Time reload",
 | 
			
		||||
        "Timing is on": "Timing is on",
 | 
			
		||||
        "You can make changes to the document, then stop timing to see the results.": "You can make changes to the document, then stop timing to see the results."
 | 
			
		||||
        "You can make changes to the document, then stop timing to see the results.": "You can make changes to the document, then stop timing to see the results.",
 | 
			
		||||
        "Only available to document editors": "Only available to document editors",
 | 
			
		||||
        "Only available to document owners": "Only available to document owners"
 | 
			
		||||
    },
 | 
			
		||||
    "DocumentUsage": {
 | 
			
		||||
        "Attachments Size": "Size of Attachments",
 | 
			
		||||
 | 
			
		||||
		Loading…
	
		Reference in New Issue
	
	Block a user