You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
gristlabs_grist-core/app/common/UserAPI.ts

1005 lines
38 KiB

import {ActionSummary} from 'app/common/ActionSummary';
import {ApplyUAResult, ForkResult, 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';
import {BulkColValues, TableColValues, TableRecordValue, TableRecordValues, UserAction} from 'app/common/DocActions';
import {DocCreationInfo, OpenDocMode} from 'app/common/DocListAPI';
import {OrgUsageSummary} from 'app/common/DocUsage';
import {Product} from 'app/common/Features';
import {ICustomWidget} from 'app/common/CustomWidget';
import {isClient} from 'app/common/gristUrls';
import {FullUser, UserProfile} from 'app/common/LoginSessionAPI';
import {OrgPrefs, UserOrgPrefs, UserPrefs} from 'app/common/Prefs';
import * as roles from 'app/common/roles';
import {addCurrentOrgToPath} from 'app/common/urlUtils';
import {encodeQueryParams} from 'app/common/gutil';
export type {FullUser, UserProfile};
(core) support adding user characteristic tables for granular ACLs Summary: This is a prototype for expanding the conditions that can be used in granular ACLs. When processing ACLs, the following variables (called "characteristics") are now available in conditions: * UserID * Email * Name * Access (owners, editors, viewers) The set of variables can be expanded by adding a "characteristic" clause. This is a clause which specifies: * A tableId * The name of an existing characteristic * A colId The effect of the clause is to expand the available characteristics with all the columns in the table, with values taken from the record where there is a match between the specified characteristic and the specified column. Existing clauses are generalized somewhat to demonstrate and test the use these variables. That isn't the main point of this diff though, and I propose to leave generalizing+systematizing those clauses for a future diff. Issues I'm not dealing with here: * How clauses combine. (The scope on GranularAccessRowClause is a hack to save me worrying about that yet). * The full set of matching methods we'll allow. * Refreshing row access in clients when the tables mentioned in characteristic tables change. * Full CRUD permission control. * Default rules (part of combination). * Reporting errors in access rules. That said, with this diff it is possible to e.g. assign a City to editors by their email address or name, and have only rows for those Cities be visible in their client. Ability to modify those rows, and remain updates about them, remains under incomplete control. Test Plan: added tests Reviewers: dsagal Reviewed By: dsagal Differential Revision: https://phab.getgrist.com/D2642
4 years ago
// Nominal email address of the anonymous user.
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';
// A special 'docId' that means to create a new document.
export const NEW_DOCUMENT_CODE = 'new';
// Properties shared by org, workspace, and doc resources.
export interface CommonProperties {
name: string;
createdAt: string; // ISO date string
updatedAt: string; // ISO date string
removedAt?: string; // ISO date string - only can appear on docs and workspaces currently
public?: boolean; // If set, resource is available to the public
}
export const commonPropertyKeys = ['createdAt', 'name', 'updatedAt'];
export interface OrganizationProperties extends CommonProperties {
domain: string|null;
// Organization includes preferences relevant to interacting with its content.
userOrgPrefs?: UserOrgPrefs; // Preferences specific to user and org
orgPrefs?: OrgPrefs; // Preferences specific to org (but not a particular user)
userPrefs?: UserPrefs; // Preferences specific to user (but not a particular org)
}
export const organizationPropertyKeys = [...commonPropertyKeys, 'domain',
'orgPrefs', 'userOrgPrefs', 'userPrefs'];
// Basic information about an organization, excluding the user's access level
export interface OrganizationWithoutAccessInfo extends OrganizationProperties {
id: number;
owner: FullUser|null;
billingAccount?: BillingAccount;
host: string|null; // if set, org's preferred domain (e.g. www.thing.com)
}
// Organization information plus the user's access level
export interface Organization extends OrganizationWithoutAccessInfo {
access: roles.Role;
}
// Basic information about a billing account associated with an org or orgs.
export interface BillingAccount {
id: number;
individual: boolean;
product: Product;
isManager: boolean;
inGoodStanding: boolean;
externalOptions?: {
invoiceId?: string;
};
}
// The upload types vary based on which fetch implementation is in use. This is
// an incomplete list. For example, node streaming types are supported by node-fetch.
export type UploadType = string | Blob | Buffer;
/**
* Returns a user-friendly org name, which is either org.name, or "@User Name" for personal orgs.
*/
export function getOrgName(org: Organization): string {
return org.owner ? `@` + org.owner.name : org.name;
}
/**
* Returns whether the given org is the templates org, which contains the public templates.
*/
export function isTemplatesOrg(org: Organization): boolean {
// TODO: It would be nice to have a more robust way to detect the templates org.
return org.domain === 'templates' || org.domain === 'templates-s';
}
export type WorkspaceProperties = CommonProperties;
export const workspacePropertyKeys = ['createdAt', 'name', 'updatedAt'];
export interface Workspace extends WorkspaceProperties {
id: number;
docs: Document[];
org: Organization;
orgDomain?: string;
access: roles.Role;
owner?: FullUser; // Set when workspaces are in the "docs" pseudo-organization,
// assembled from multiple personal organizations.
// Not set when workspaces are all from the same organization.
// Set when the workspace belongs to support@getgrist.com. We expect only one such workspace
// ("Examples & Templates"), containing sample documents.
isSupportWorkspace?: boolean;
}
export type DocumentType = 'tutorial';
// Non-core options for a document.
// "Non-core" means bundled into a single options column in the database.
// TODO: consider smoothing over this distinction in the API.
export interface DocumentOptions {
description?: string|null;
icon?: string|null;
openMode?: OpenDocMode|null;
externalId?: string|null; // A slot for storing an externally maintained id.
// Not used in grist-core, but handy for Electron app.
tutorial?: TutorialMetadata|null;
}
export interface TutorialMetadata {
lastSlideIndex?: number;
}
export interface DocumentProperties extends CommonProperties {
isPinned: boolean;
urlId: string|null;
trunkId: string|null;
type: DocumentType|null;
options: DocumentOptions|null;
}
export const documentPropertyKeys = [...commonPropertyKeys, 'isPinned', 'urlId', 'options', 'type'];
export interface Document extends DocumentProperties {
id: string;
workspace: Workspace;
access: roles.Role;
trunkAccess?: roles.Role|null;
forks?: Fork[];
}
export interface Fork {
id: string;
trunkId: string;
updatedAt: string; // ISO date string
options: DocumentOptions|null;
}
// Non-core options for a user.
export interface UserOptions {
// Whether signing in with Google is allowed. Defaults to true if unset.
allowGoogleLogin?: boolean;
// The "sub" (subject) from the JWT issued by the password-based authentication provider.
authSubject?: string;
// Whether user is a consultant. Consultant users can be added to sites
// without being counted for billing. Defaults to false if unset.
isConsultant?: boolean;
// Locale selected by the user. Defaults to 'en' if unset.
locale?: string;
}
export interface PermissionDelta {
maxInheritedRole?: roles.BasicRole|null;
users?: {
// Maps from email to group name, or null to inherit.
[email: string]: roles.NonGuestRole|null
};
}
export interface PermissionData {
// True if permission data is restricted to current user.
personal?: true;
// True if current user is a public member.
public?: boolean;
maxInheritedRole?: roles.BasicRole|null;
users: UserAccessData[];
}
// A structure for modifying managers of a billing account.
export interface ManagerDelta {
users: {
// To add a manager, link their email to 'managers'.
// To remove a manager, link their email to null.
// This format is used to rhyme with the ACL PermissionDelta format.
[email: string]: 'managers'|null
};
}
// Information about a user and their access to an unspecified resource of interest.
export interface UserAccessData {
id: number;
name: string;
email: string;
ref?: string|null;
picture?: string|null; // When present, a url to a public image of unspecified dimensions.
// Represents the user's direct access to the resource of interest. Lack of access to a resource
// is represented by a null value.
access: roles.Role|null;
// A user's parentAccess represent their effective inheritable access to the direct parent of the resource
// of interest. The user's effective access to the resource of interest can be determined based
// on the user's parentAccess, the maxInheritedRole setting of the resource and the user's direct
// access to the resource. Lack of access to the parent resource is represented by a null value.
// If parent has non-inheritable access, this should be null.
parentAccess?: roles.BasicRole|null;
orgAccess?: roles.BasicRole|null;
anonymous?: boolean; // If set to true, the user is the anonymous user.
isMember?: boolean;
}
/**
* Combines access, parentAccess, and maxInheritedRole info into the resulting access role.
*/
export function getRealAccess(user: UserAccessData, permissionData: PermissionData): roles.Role|null {
const inheritedAccess = roles.getWeakestRole(user.parentAccess || null, permissionData.maxInheritedRole || null);
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;
orgError?: OrgError;
}
export interface OrgError {
error: string;
status: number;
}
/**
* Options to control the source of a document being replaced. For
* example, a document could be initialized from another document
* (e.g. a fork) or from a snapshot.
*/
export interface DocReplacementOptions {
/**
* The docId to copy from.
*/
sourceDocId?: string;
/**
* The s3 version ID.
*/
snapshotId?: string;
/**
* True if tutorial metadata should be reset.
*
* Metadata that's reset includes the doc (i.e. tutorial) name, and the
* properties under options.tutorial (e.g. lastSlideIndex).
*/
resetTutorialMetadata?: boolean;
}
/**
* Information about a single document snapshot/backup.
*/
export interface DocSnapshot {
lastModified: string; // when the snapshot was made
snapshotId: string; // the id of the snapshot in the underlying store
docId: string; // an id for accessing the snapshot as a Grist document
}
/**
* A list of document snapshots.
*/
export interface DocSnapshots {
snapshots: DocSnapshot[]; // snapshots, freshest first.
}
/**
* Information about a single document state.
*/
export interface DocState {
n: number; // a sequential identifier
h: string; // a hash identifier
}
/**
* A list of document states. Most recent is first.
*/
export interface DocStates {
states: DocState[];
}
/**
* A comparison between two documents, called "left" and "right".
* The comparison is based on the action histories in the documents.
* If those histories have been truncated, the comparison may report
* two documents as being unrelated even if they do in fact have some
* shared history.
*/
export interface DocStateComparison {
left: DocState; // left / local document
right: DocState; // right / remote document
parent: DocState|null; // most recent common ancestor of left and right
// summary of the relationship between the two documents.
// same: documents have the same most recent state
// left: the left document has actions not yet in the right
// right: the right document has actions not yet in the left
// both: both documents have changes (possible divergence)
// unrelated: no common history found
summary: 'same' | 'left' | 'right' | 'both' | 'unrelated';
// optionally, details of what changed may be included.
details?: DocStateComparisonDetails;
}
/**
* Detailed comparison between document versions. For now, this
* is provided as a pair of ActionSummary objects, relative to
* the most recent common ancestor.
*/
export interface DocStateComparisonDetails {
leftChanges: ActionSummary;
rightChanges: ActionSummary;
}
export interface UserAPI {
getSessionActive(): Promise<ActiveSessionInfo>;
setSessionActive(email: string): Promise<void>;
getSessionAll(): Promise<{users: FullUser[], orgs: Organization[]}>;
getOrgs(merged?: boolean): Promise<Organization[]>;
getWorkspace(workspaceId: number): Promise<Workspace>;
getOrg(orgId: number|string): Promise<Organization>;
getOrgWorkspaces(orgId: number|string): Promise<Workspace[]>;
getOrgUsageSummary(orgId: number|string): Promise<OrgUsageSummary>;
getTemplates(onlyFeatured?: boolean): Promise<Workspace[]>;
getDoc(docId: string): Promise<Document>;
newOrg(props: Partial<OrganizationProperties>): Promise<number>;
newWorkspace(props: Partial<WorkspaceProperties>, orgId: number|string): Promise<number>;
newDoc(props: Partial<DocumentProperties>, workspaceId: number): Promise<string>;
newUnsavedDoc(options?: {timezone?: string}): Promise<string>;
renameOrg(orgId: number|string, name: string): Promise<void>;
renameWorkspace(workspaceId: number, name: string): Promise<void>;
renameDoc(docId: string, name: string): Promise<void>;
updateOrg(orgId: number|string, props: Partial<OrganizationProperties>): Promise<void>;
updateDoc(docId: string, props: Partial<DocumentProperties>): Promise<void>;
deleteOrg(orgId: number|string): Promise<void>;
deleteWorkspace(workspaceId: number): Promise<void>; // delete workspace permanently
softDeleteWorkspace(workspaceId: number): Promise<void>; // soft-delete workspace
undeleteWorkspace(workspaceId: number): Promise<void>; // recover soft-deleted workspace
deleteDoc(docId: string): Promise<void>; // delete doc permanently
softDeleteDoc(docId: string): Promise<void>; // soft-delete doc
undeleteDoc(docId: string): Promise<void>; // recover soft-deleted doc
updateOrgPermissions(orgId: number|string, delta: PermissionDelta): Promise<void>;
updateWorkspacePermissions(workspaceId: number, delta: PermissionDelta): Promise<void>;
updateDocPermissions(docId: string, delta: PermissionDelta): Promise<void>;
getOrgAccess(orgId: number|string): Promise<PermissionData>;
getWorkspaceAccess(workspaceId: number): Promise<PermissionData>;
getDocAccess(docId: string): Promise<PermissionData>;
pinDoc(docId: string): Promise<void>;
unpinDoc(docId: string): Promise<void>;
moveDoc(docId: string, workspaceId: number): Promise<void>;
getUserProfile(): Promise<FullUser>;
updateUserName(name: string): Promise<void>;
updateUserLocale(locale: string|null): Promise<void>;
updateAllowGoogleLogin(allowGoogleLogin: boolean): Promise<void>;
updateIsConsultant(userId: number, isConsultant: boolean): Promise<void>;
getWorker(key: string): Promise<string>;
getWorkerAPI(key: string): Promise<DocWorkerAPI>;
getBillingAPI(): BillingAPI;
getDocAPI(docId: string): DocAPI;
fetchApiKey(): Promise<string>;
createApiKey(): Promise<string>;
deleteApiKey(): Promise<void>;
getTable(docId: string, tableName: string): Promise<TableColValues>;
applyUserActions(docId: string, actions: UserAction[]): Promise<ApplyUAResult>;
importUnsavedDoc(material: UploadType, options?: {
filename?: string,
timezone?: string,
onUploadProgress?: (ev: ProgressEvent) => void,
}): Promise<string>;
deleteUser(userId: number, name: string): Promise<void>;
getBaseUrl(): string; // Get the prefix for all the endpoints this object wraps.
forRemoved(): UserAPI; // Get a version of the API that works on removed resources.
getWidgets(): Promise<ICustomWidget[]>;
}
/**
* Parameters for the download CSV and XLSX endpoint (/download/table-schema & /download/csv & /download/csv).
*/
export interface DownloadDocParams {
tableId: string;
viewSection?: number;
activeSortSpec?: string;
filters?: string;
}
interface GetRowsParams {
filters?: QueryFilters;
immediate?: boolean;
}
/**
* Collect endpoints related to the content of a single document that we've been thinking
* of as the (restful) "Doc API". A few endpoints that could be here are not, for historical
* reasons, such as downloads.
*/
export interface DocAPI {
(core) get all tests working under python3/gvisor Summary: This verifies that all existing tests are capable of running under python3/gvisor, and fixes the small issues that came up. It does not yet activate python3 tests on all diffs, only diffs that specifically request them. * Adds a suffix in test names and output directories for tests run with PYTHON_VERSION=3, so that results of the same test run with and without the flag can be aggregated cleanly. * Adds support for checkpointing to the gvisor sandbox adapter. * Prepares a checkpoint made after grist python code has loaded in the gvisor sandbox. * Changes how `DOC_URL` is passed to the sandbox, since it can no longer be passed in as an environment variable when using checkpoints. * Uses the checkpoint to speed up tests using the gvisor sandbox, otherwise a lot of tests need more time (especially on mac under docker). * Directs jenkins to run all tests with python2 and python3 when a new file `buildtools/changelogs/python.txt` is touched (this diff counts as touching that file). * Tweaks miscellaneous tests - some needed fixes exposed by slightly different timing - a small number actually give different results in py3 (removal of `u` prefixes). - some needed a little more time The DOC_URL change is not the ultimate solution we want for DOC_URL. Eventually it should be a variable that gets updated, like the date perhaps. This is just a small pragmatic change to preserve existing behavior. Tests are run mindlessly as py3, and for some tests it won't change anything (e.g. if they do not use NSandbox). Tests are not run in parallel, doubling overall test time. Checkpoints could be useful in deployment, though this diff doesn't use them there. The application of checkpoints doesn't check for other configuration like 3-versus-5-pipe that we don't actually use. Python2 tests run using pynbox as always for now. The diff got sufficiently bulky that I didn't tackle running py3 on "regular" diffs in it. My preference, given that most tests don't appear to stress the python side of things, would be to make a selection of the tests that do and a few wild cards, and run those tests on both pythons rather then all of them. For diffs making a significant python change, I'd propose touching buildtools/changelogs/python.txt for full tests. But this is a conversation in progress. A total of 6886 tests ran on this diff. Test Plan: this is a step in preparing tests for py3 transition Reviewers: dsagal Reviewed By: dsagal Subscribers: dsagal Differential Revision: https://phab.getgrist.com/D3066
3 years ago
// Immediate flag is a currently not-advertised feature, allowing a query to proceed without
// waiting for a document to be initialized. This is useful if the calculations done when
// opening a document are irrelevant.
getRows(tableId: string, options?: GetRowsParams): Promise<TableColValues>;
getRecords(tableId: string, options?: GetRowsParams): Promise<TableRecordValue[]>;
updateRows(tableId: string, changes: TableColValues): Promise<number[]>;
addRows(tableId: string, additions: BulkColValues): Promise<number[]>;
removeRows(tableId: string, removals: number[]): Promise<number[]>;
fork(): Promise<ForkResult>;
replace(source: DocReplacementOptions): Promise<void>;
// Get list of document versions (specify raw to bypass caching, which should only make
// a difference if snapshots have "leaked")
getSnapshots(raw?: boolean): Promise<DocSnapshots>;
// remove selected snapshots, or all snapshots that have "leaked" from inventory (should
// be empty), or all but the current snapshot.
removeSnapshots(snapshotIds: string[] | 'unlisted' | 'past'): Promise<{snapshotIds: string[]}>;
forceReload(): Promise<void>;
recover(recoveryMode: boolean): Promise<void>;
// Compare two documents, optionally including details of the changes.
compareDoc(remoteDocId: string, options?: { detail: boolean }): Promise<DocStateComparison>;
// Compare two versions within a document, including details of the changes.
// Versions are identified by action hashes, or aliases understood by HashUtil.
// Currently, leftHash is expected to be an ancestor of rightHash. If rightHash
// is HEAD, the result will contain a copy of any rows added or updated.
compareVersion(leftHash: string, rightHash: string): Promise<DocStateComparison>;
getDownloadUrl(template?: boolean): string;
getDownloadXlsxUrl(params?: DownloadDocParams): string;
getDownloadCsvUrl(params: DownloadDocParams): string;
getDownloadTableSchemaUrl(params: DownloadDocParams): string;
/**
* Exports current document to the Google Drive as a spreadsheet file. To invoke this method, first
* acquire "code" via Google Auth Endpoint (see ShareMenu.ts for an example).
* @param code Authorization code returned from Google (requested via Grist's Google Auth Endpoint)
* @param title Name of the spreadsheet that will be created (should use a Grist document's title)
*/
sendToDrive(code: string, title: string): Promise<{url: string}>;
// 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<number>;
// Get users that are worth proposing to "View As" for access control purposes.
getUsersForViewAs(): Promise<PermissionDataWithExtraUsers>;
}
// Operations that are supported by a doc worker.
export interface DocWorkerAPI {
readonly url: string;
importDocToWorkspace(uploadId: number, workspaceId: number, settings?: BrowserSettings): Promise<DocCreationInfo>;
upload(material: UploadType, filename?: string): Promise<number>;
downloadDoc(docId: string, template?: boolean): Promise<Response>;
copyDoc(docId: string, template?: boolean, name?: string): Promise<number>;
}
export class UserAPIImpl extends BaseAPI implements UserAPI {
constructor(private _homeUrl: string, private _options: IOptions = {}) {
super(_options);
}
public forRemoved(): UserAPI {
const extraParameters = new Map<string, string>([['showRemoved', '1']]);
return new UserAPIImpl(this._homeUrl, {...this._options, extraParameters});
}
public async getSessionActive(): Promise<ActiveSessionInfo> {
return this.requestJson(`${this._url}/api/session/access/active`, {method: 'GET'});
}
public async setSessionActive(email: string): Promise<void> {
const body = JSON.stringify({ email });
return this.requestJson(`${this._url}/api/session/access/active`, {method: 'POST', body});
}
public async getSessionAll(): Promise<{users: FullUser[], orgs: Organization[]}> {
return this.requestJson(`${this._url}/api/session/access/all`, {method: 'GET'});
}
public async getOrgs(merged: boolean = false): Promise<Organization[]> {
return this.requestJson(`${this._url}/api/orgs?merged=${merged ? 1 : 0}`, { method: 'GET' });
}
public async getWorkspace(workspaceId: number): Promise<Workspace> {
return this.requestJson(`${this._url}/api/workspaces/${workspaceId}`, { method: 'GET' });
}
public async getOrg(orgId: number|string): Promise<Organization> {
return this.requestJson(`${this._url}/api/orgs/${orgId}`, { method: 'GET' });
}
public async getOrgWorkspaces(orgId: number|string): Promise<Workspace[]> {
return this.requestJson(`${this._url}/api/orgs/${orgId}/workspaces?includeSupport=1`,
{ method: 'GET' });
}
public async getOrgUsageSummary(orgId: number|string): Promise<OrgUsageSummary> {
return this.requestJson(`${this._url}/api/orgs/${orgId}/usage`, { method: 'GET' });
}
public async getTemplates(onlyFeatured: boolean = false): Promise<Workspace[]> {
return this.requestJson(`${this._url}/api/templates?onlyFeatured=${onlyFeatured ? 1 : 0}`, { method: 'GET' });
}
public async getWidgets(): Promise<ICustomWidget[]> {
return await this.requestJson(`${this._url}/api/widgets`, { method: 'GET' });
}
public async getDoc(docId: string): Promise<Document> {
return this.requestJson(`${this._url}/api/docs/${docId}`, { method: 'GET' });
}
public async newOrg(props: Partial<OrganizationProperties>): Promise<number> {
return this.requestJson(`${this._url}/api/orgs`, {
method: 'POST',
body: JSON.stringify(props)
});
}
public async newWorkspace(props: Partial<WorkspaceProperties>, orgId: number|string): Promise<number> {
return this.requestJson(`${this._url}/api/orgs/${orgId}/workspaces`, {
method: 'POST',
body: JSON.stringify(props)
});
}
public async newDoc(props: Partial<DocumentProperties>, workspaceId: number): Promise<string> {
return this.requestJson(`${this._url}/api/workspaces/${workspaceId}/docs`, {
method: 'POST',
body: JSON.stringify(props)
});
}
public async newUnsavedDoc(options: {timezone?: string} = {}): Promise<string> {
return this.requestJson(`${this._url}/api/docs`, {
method: 'POST',
body: JSON.stringify(options),
});
}
public async renameOrg(orgId: number|string, name: string): Promise<void> {
await this.request(`${this._url}/api/orgs/${orgId}`, {
method: 'PATCH',
body: JSON.stringify({ name })
});
}
public async renameWorkspace(workspaceId: number, name: string): Promise<void> {
await this.request(`${this._url}/api/workspaces/${workspaceId}`, {
method: 'PATCH',
body: JSON.stringify({ name })
});
}
public async renameDoc(docId: string, name: string): Promise<void> {
return this.updateDoc(docId, {name});
}
public async updateOrg(orgId: number|string, props: Partial<OrganizationProperties>): Promise<void> {
await this.request(`${this._url}/api/orgs/${orgId}`, {
method: 'PATCH',
body: JSON.stringify(props)
});
}
public async updateDoc(docId: string, props: Partial<DocumentProperties>): Promise<void> {
await this.request(`${this._url}/api/docs/${docId}`, {
method: 'PATCH',
body: JSON.stringify(props)
});
}
public async deleteOrg(orgId: number|string): Promise<void> {
await this.request(`${this._url}/api/orgs/${orgId}`, { method: 'DELETE' });
}
public async deleteWorkspace(workspaceId: number): Promise<void> {
await this.request(`${this._url}/api/workspaces/${workspaceId}`, { method: 'DELETE' });
}
public async softDeleteWorkspace(workspaceId: number): Promise<void> {
await this.request(`${this._url}/api/workspaces/${workspaceId}/remove`, { method: 'POST' });
}
public async undeleteWorkspace(workspaceId: number): Promise<void> {
await this.request(`${this._url}/api/workspaces/${workspaceId}/unremove`, { method: 'POST' });
}
public async deleteDoc(docId: string): Promise<void> {
await this.request(`${this._url}/api/docs/${docId}`, { method: 'DELETE' });
}
public async softDeleteDoc(docId: string): Promise<void> {
await this.request(`${this._url}/api/docs/${docId}/remove`, { method: 'POST' });
}
public async undeleteDoc(docId: string): Promise<void> {
await this.request(`${this._url}/api/docs/${docId}/unremove`, { method: 'POST' });
}
public async updateOrgPermissions(orgId: number|string, delta: PermissionDelta): Promise<void> {
await this.request(`${this._url}/api/orgs/${orgId}/access`, {
method: 'PATCH',
body: JSON.stringify({ delta })
});
}
public async updateWorkspacePermissions(workspaceId: number, delta: PermissionDelta): Promise<void> {
await this.request(`${this._url}/api/workspaces/${workspaceId}/access`, {
method: 'PATCH',
body: JSON.stringify({ delta })
});
}
public async updateDocPermissions(docId: string, delta: PermissionDelta): Promise<void> {
await this.request(`${this._url}/api/docs/${docId}/access`, {
method: 'PATCH',
body: JSON.stringify({ delta })
});
}
public async getOrgAccess(orgId: number|string): Promise<PermissionData> {
return this.requestJson(`${this._url}/api/orgs/${orgId}/access`, { method: 'GET' });
}
public async getWorkspaceAccess(workspaceId: number): Promise<PermissionData> {
return this.requestJson(`${this._url}/api/workspaces/${workspaceId}/access`, { method: 'GET' });
}
public async getDocAccess(docId: string): Promise<PermissionData> {
return this.requestJson(`${this._url}/api/docs/${docId}/access`, { method: 'GET' });
}
public async pinDoc(docId: string): Promise<void> {
await this.request(`${this._url}/api/docs/${docId}/pin`, {
method: 'PATCH'
});
}
public async unpinDoc(docId: string): Promise<void> {
await this.request(`${this._url}/api/docs/${docId}/unpin`, {
method: 'PATCH'
});
}
public async moveDoc(docId: string, workspaceId: number): Promise<void> {
await this.request(`${this._url}/api/docs/${docId}/move`, {
method: 'PATCH',
body: JSON.stringify({ workspace: workspaceId })
});
}
public async getUserProfile(): Promise<FullUser> {
return this.requestJson(`${this._url}/api/profile/user`);
}
public async updateUserName(name: string): Promise<void> {
await this.request(`${this._url}/api/profile/user/name`, {
method: 'POST',
body: JSON.stringify({name})
});
}
public async updateUserLocale(locale: string|null): Promise<void> {
await this.request(`${this._url}/api/profile/user/locale`, {
method: 'POST',
body: JSON.stringify({locale})
});
}
public async updateAllowGoogleLogin(allowGoogleLogin: boolean): Promise<void> {
await this.request(`${this._url}/api/profile/allowGoogleLogin`, {
method: 'POST',
body: JSON.stringify({allowGoogleLogin})
});
}
public async updateIsConsultant(userId: number, isConsultant: boolean): Promise<void> {
await this.request(`${this._url}/api/profile/isConsultant`, {
method: 'POST',
body: JSON.stringify({userId, isConsultant})
});
}
public async getWorker(key: string): Promise<string> {
const json = await this.requestJson(`${this._url}/api/worker/${key}`, {
method: 'GET',
credentials: 'include'
});
return getDocWorkerUrl(this._homeUrl, json);
}
public async getWorkerAPI(key: string): Promise<DocWorkerAPI> {
const docUrl = this._urlWithOrg(await this.getWorker(key));
return new DocWorkerAPIImpl(docUrl, this._options);
}
public getBillingAPI(): BillingAPI {
return new BillingAPIImpl(this._url, this._options);
}
public getDocAPI(docId: string): DocAPI {
return new DocAPIImpl(this._url, docId, this._options);
}
public async fetchApiKey(): Promise<string> {
const resp = await this.request(`${this._url}/api/profile/apiKey`);
return await resp.text();
}
public async createApiKey(): Promise<string> {
const res = await this.request(`${this._url}/api/profile/apiKey`, {
method: 'POST'
});
return await res.text();
}
public async deleteApiKey(): Promise<void> {
await this.request(`${this._url}/api/profile/apiKey`, {
method: 'DELETE'
});
}
// This method is not strictly needed anymore, but is widely used by
// tests so supporting as a handy shortcut for getDocAPI(docId).getRows(tableName)
public async getTable(docId: string, tableName: string): Promise<TableColValues> {
return this.getDocAPI(docId).getRows(tableName);
}
public async applyUserActions(docId: string, actions: UserAction[]): Promise<ApplyUAResult> {
return this.requestJson(`${this._url}/api/docs/${docId}/apply`, {
method: 'POST',
body: JSON.stringify(actions)
});
}
public async importUnsavedDoc(material: UploadType, options?: {
filename?: string,
timezone?: string,
onUploadProgress?: (ev: ProgressEvent) => void,
}): Promise<string> {
options = options || {};
const formData = this.newFormData();
formData.append('upload', material as any, options.filename);
if (options.timezone) { formData.append('timezone', options.timezone); }
const resp = await this.requestAxios(`${this._url}/api/docs`, {
method: 'POST',
data: formData,
onUploadProgress: options.onUploadProgress,
// On browser, it is important not to set Content-Type so that the browser takes care
// of setting HTTP headers appropriately. Outside browser, requestAxios has logic
// for setting the HTTP headers.
headers: {...this.defaultHeadersWithoutContentType()},
});
return resp.data;
}
public async deleteUser(userId: number, name: string) {
await this.request(`${this._url}/api/users/${userId}`,
{method: 'DELETE',
body: JSON.stringify({name})});
}
public getBaseUrl(): string { return this._url; }
// Recomputes the URL on every call to pick up changes in the URL when switching orgs.
// (Feels inefficient, but probably doesn't matter, and it's simpler than the alternatives.)
private get _url(): string {
return this._urlWithOrg(this._homeUrl);
}
private _urlWithOrg(base: string): string {
return isClient() ? addCurrentOrgToPath(base) : base.replace(/\/$/, '');
}
}
export class DocWorkerAPIImpl extends BaseAPI implements DocWorkerAPI {
constructor(public readonly url: string, _options: IOptions = {}) {
super(_options);
}
public async importDocToWorkspace(uploadId: number, workspaceId: number, browserSettings?: BrowserSettings):
Promise<DocCreationInfo> {
return this.requestJson(`${this.url}/api/workspaces/${workspaceId}/import`, {
method: 'POST',
body: JSON.stringify({ uploadId, browserSettings })
});
}
public async upload(material: UploadType, filename?: string): Promise<number> {
const formData = this.newFormData();
formData.append('upload', material as any, filename);
const json = await this.requestJson(`${this.url}/uploads`, {
// On browser, it is important not to set Content-Type so that the browser takes care
// of setting HTTP headers appropriately. Outside of browser, node-fetch also appears
// to take care of this - https://github.github.io/fetch/#request-body
headers: {...this.defaultHeadersWithoutContentType()},
method: 'POST',
body: formData
});
return json.uploadId;
}
public async downloadDoc(docId: string, template: boolean = false): Promise<Response> {
const extra = template ? '?template=1' : '';
const result = await this.request(`${this.url}/api/docs/${docId}/download${extra}`, {
method: 'GET',
});
if (!result.ok) { throw new Error(await result.text()); }
return result;
}
public async copyDoc(docId: string, template: boolean = false, name?: string): Promise<number> {
const url = new URL(`${this.url}/copy?doc=${docId}`);
if (template) {
url.searchParams.append('template', '1');
}
if (name) {
url.searchParams.append('name', name);
}
const json = await this.requestJson(url.href, {
method: 'POST',
});
return json.uploadId;
}
}
export class DocAPIImpl extends BaseAPI implements DocAPI {
private _url: string;
constructor(url: string, public readonly docId: string, options: IOptions = {}) {
super(options);
this._url = `${url}/api/docs/${docId}`;
}
public async getRows(tableId: string, options?: GetRowsParams): Promise<TableColValues> {
return this._getRecords(tableId, 'data', options);
}
public async getRecords(tableId: string, options?: GetRowsParams): Promise<TableRecordValue[]> {
const response: TableRecordValues = await this._getRecords(tableId, 'records', options);
return response.records;
}
public async updateRows(tableId: string, changes: TableColValues): Promise<number[]> {
return this.requestJson(`${this._url}/tables/${tableId}/data`, {
body: JSON.stringify(changes),
method: 'PATCH'
});
}
public async addRows(tableId: string, additions: BulkColValues): Promise<number[]> {
return this.requestJson(`${this._url}/tables/${tableId}/data`, {
body: JSON.stringify(additions),
method: 'POST'
});
}
public async removeRows(tableId: string, removals: number[]): Promise<number[]> {
return this.requestJson(`${this._url}/tables/${tableId}/data/delete`, {
body: JSON.stringify(removals),
method: 'POST'
});
}
public async fork(): Promise<ForkResult> {
return this.requestJson(`${this._url}/fork`, {
method: 'POST'
});
}
public async replace(source: DocReplacementOptions): Promise<void> {
return this.requestJson(`${this._url}/replace`, {
body: JSON.stringify(source),
method: 'POST'
});
}
public async getSnapshots(raw?: boolean): Promise<DocSnapshots> {
return this.requestJson(`${this._url}/snapshots?raw=${raw}`);
}
public async removeSnapshots(snapshotIds: string[] | 'unlisted' | 'past') {
const body = typeof snapshotIds === 'string' ? { select: snapshotIds } : { snapshotIds };
return await this.requestJson(`${this._url}/snapshots/remove`, {
method: 'POST',
body: JSON.stringify(body)
});
}
public async getUsersForViewAs(): Promise<PermissionDataWithExtraUsers> {
return this.requestJson(`${this._url}/usersForViewAs`);
}
public async forceReload(): Promise<void> {
await this.request(`${this._url}/force-reload`, {
method: 'POST'
});
}
public async recover(recoveryMode: boolean): Promise<void> {
await this.request(`${this._url}/recover`, {
body: JSON.stringify({recoveryMode}),
method: 'POST'
});
}
public async compareDoc(remoteDocId: string, options: {
detail?: boolean
} = {}): Promise<DocStateComparison> {
const q = options.detail ? '?detail=true' : '';
return this.requestJson(`${this._url}/compare/${remoteDocId}${q}`);
}
public async compareVersion(leftHash: string, rightHash: string): Promise<DocStateComparison> {
const url = new URL(`${this._url}/compare`);
url.searchParams.append('left', leftHash);
url.searchParams.append('right', rightHash);
return this.requestJson(url.href);
}
public getDownloadUrl(template: boolean = false) {
return this._url + `/download?template=${Number(template)}`;
}
public getDownloadXlsxUrl(params: DownloadDocParams) {
return this._url + '/download/xlsx?' + encodeQueryParams({...params});
}
public getDownloadCsvUrl(params: DownloadDocParams) {
// We spread `params` to work around TypeScript being overly cautious.
return this._url + '/download/csv?' + encodeQueryParams({...params});
}
public getDownloadTableSchemaUrl(params: DownloadDocParams) {
// We spread `params` to work around TypeScript being overly cautious.
return this._url + '/download/table-schema?' + encodeQueryParams({...params});
}
public async sendToDrive(code: string, title: string): Promise<{url: string}> {
const url = new URL(`${this._url}/send-to-drive`);
url.searchParams.append('title', title);
url.searchParams.append('code', code);
return this.requestJson(url.href);
}
public async uploadAttachment(value: string | Blob, filename?: string): Promise<number> {
const formData = this.newFormData();
formData.append('upload', value, filename);
const response = await this.requestAxios(`${this._url}/attachments`, {
method: 'POST',
data: formData,
// On browser, it is important not to set Content-Type so that the browser takes care
// of setting HTTP headers appropriately. Outside browser, requestAxios has logic
// for setting the HTTP headers.
headers: {...this.defaultHeadersWithoutContentType()},
});
return response.data[0];
}
private _getRecords(tableId: string, endpoint: 'data' | 'records', options?: GetRowsParams): Promise<any> {
const url = new URL(`${this._url}/tables/${tableId}/${endpoint}`);
if (options?.filters) {
url.searchParams.append('filter', JSON.stringify(options.filters));
}
if (options?.immediate) {
url.searchParams.append('immediate', 'true');
}
return this.requestJson(url.href);
}
}
/**
* Get a docWorkerUrl from information returned from backend. When the backend
* is fully configured, and there is a pool of workers, this is straightforward,
* just return the docWorkerUrl reported by the backend. For single-instance
* installs, the backend returns a null docWorkerUrl, and a client can simply
* use the homeUrl of the backend, with extra path prefix information
* given by selfPrefix. At the time of writing, the selfPrefix contains a
* doc-worker id, and a tag for the codebase (used in consistency checks).
*/
export function getDocWorkerUrl(homeUrl: string, docWorkerInfo: {
docWorkerUrl: string|null,
selfPrefix?: string,
}): string {
if (!docWorkerInfo.docWorkerUrl) {
if (!docWorkerInfo.selfPrefix) {
// This should never happen.
throw new Error('missing selfPrefix for docWorkerUrl');
}
const url = new URL(homeUrl);
url.pathname = docWorkerInfo.selfPrefix + url.pathname;
return url.href;
}
return docWorkerInfo.docWorkerUrl;
}