mirror of
https://github.com/gristlabs/grist-core.git
synced 2024-10-27 20:44:07 +00:00
(core) add per-user per-org preferences to database
Summary: Adds preferences to orgs. There are a few flavors: * `userOrgPrefs`: these are specific to a certain user and a certain org. * `orgPrefs`: these are specific to a certain org, and apply to all users. * `userPrefs`: these are specific to a certain user, and apply to all orgs. The three flavors of prefs are reported by `GET` for an org, and can be modified by `PATCH` for an org. The user needs to have UPDATE rights to change `orgPrefs`, but can change `userOrgPrefs` and `userPrefs` without that right since the settings only affect themselves. Test Plan: added tests Reviewers: dsagal Reviewed By: dsagal Differential Revision: https://phab.getgrist.com/D2572
This commit is contained in:
parent
30866c6c95
commit
6b24d496db
9
app/common/Prefs.ts
Normal file
9
app/common/Prefs.ts
Normal file
@ -0,0 +1,9 @@
|
|||||||
|
// A collection of preferences related to a user or org (or combination).
|
||||||
|
export interface Prefs {
|
||||||
|
// TODO replace this with real preferences.
|
||||||
|
placeholder?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type UserPrefs = Prefs;
|
||||||
|
export type UserOrgPrefs = Prefs;
|
||||||
|
export type OrgPrefs = Prefs;
|
@ -7,6 +7,7 @@ import {DocCreationInfo} from 'app/common/DocListAPI';
|
|||||||
import {Features} from 'app/common/Features';
|
import {Features} from 'app/common/Features';
|
||||||
import {isClient} from 'app/common/gristUrls';
|
import {isClient} from 'app/common/gristUrls';
|
||||||
import {FullUser} from 'app/common/LoginSessionAPI';
|
import {FullUser} from 'app/common/LoginSessionAPI';
|
||||||
|
import {OrgPrefs, UserOrgPrefs, UserPrefs} from 'app/common/Prefs';
|
||||||
import * as roles from 'app/common/roles';
|
import * as roles from 'app/common/roles';
|
||||||
import {addCurrentOrgToPath} from 'app/common/urlUtils';
|
import {addCurrentOrgToPath} from 'app/common/urlUtils';
|
||||||
|
|
||||||
@ -31,8 +32,13 @@ export const commonPropertyKeys = ['createdAt', 'name', 'updatedAt'];
|
|||||||
|
|
||||||
export interface OrganizationProperties extends CommonProperties {
|
export interface OrganizationProperties extends CommonProperties {
|
||||||
domain: string|null;
|
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'];
|
export const organizationPropertyKeys = [...commonPropertyKeys, 'domain',
|
||||||
|
'orgPrefs', 'userOrgPrefs', 'userPrefs'];
|
||||||
|
|
||||||
// Basic information about an organization, excluding the user's access level
|
// Basic information about an organization, excluding the user's access level
|
||||||
export interface OrganizationWithoutAccessInfo extends OrganizationProperties {
|
export interface OrganizationWithoutAccessInfo extends OrganizationProperties {
|
||||||
@ -232,6 +238,7 @@ export interface UserAPI {
|
|||||||
renameOrg(orgId: number|string, name: string): Promise<void>;
|
renameOrg(orgId: number|string, name: string): Promise<void>;
|
||||||
renameWorkspace(workspaceId: number, name: string): Promise<void>;
|
renameWorkspace(workspaceId: number, name: string): Promise<void>;
|
||||||
renameDoc(docId: string, 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>;
|
updateDoc(docId: string, props: Partial<DocumentProperties>): Promise<void>;
|
||||||
deleteOrg(orgId: number|string): Promise<void>;
|
deleteOrg(orgId: number|string): Promise<void>;
|
||||||
deleteWorkspace(workspaceId: number): Promise<void>; // delete workspace permanently
|
deleteWorkspace(workspaceId: number): Promise<void>; // delete workspace permanently
|
||||||
@ -384,6 +391,13 @@ export class UserAPIImpl extends BaseAPI implements UserAPI {
|
|||||||
return this.updateDoc(docId, {name});
|
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> {
|
public async updateDoc(docId: string, props: Partial<DocumentProperties>): Promise<void> {
|
||||||
await this.request(`${this._url}/api/docs/${docId}`, {
|
await this.request(`${this._url}/api/docs/${docId}`, {
|
||||||
method: 'PATCH',
|
method: 'PATCH',
|
||||||
|
@ -1,9 +1,11 @@
|
|||||||
import {Column, Entity, JoinColumn, ManyToOne, OneToMany, OneToOne,
|
import {Column, Entity, JoinColumn, ManyToOne, OneToMany, OneToOne,
|
||||||
PrimaryGeneratedColumn, RelationId} from "typeorm";
|
PrimaryGeneratedColumn, RelationId} from "typeorm";
|
||||||
|
|
||||||
import {Role} from "app/common/roles";
|
import {Role} from "app/common/roles";
|
||||||
import {OrganizationProperties, organizationPropertyKeys} from "app/common/UserAPI";
|
import {OrganizationProperties, organizationPropertyKeys} from "app/common/UserAPI";
|
||||||
import {AclRuleOrg} from "./AclRule";
|
import {AclRuleOrg} from "./AclRule";
|
||||||
import {BillingAccount} from "./BillingAccount";
|
import {BillingAccount} from "./BillingAccount";
|
||||||
|
import {Pref} from "./Pref";
|
||||||
import {Resource} from "./Resource";
|
import {Resource} from "./Resource";
|
||||||
import {User} from "./User";
|
import {User} from "./User";
|
||||||
import {Workspace} from "./Workspace";
|
import {Workspace} from "./Workspace";
|
||||||
@ -68,6 +70,13 @@ export class Organization extends Resource {
|
|||||||
@Column({name: 'host', type: 'text', nullable: true})
|
@Column({name: 'host', type: 'text', nullable: true})
|
||||||
public host: string|null;
|
public host: string|null;
|
||||||
|
|
||||||
|
// Any prefs relevant to the org and user. This relation is marked to not result
|
||||||
|
// in saves, since OneToMany saves in TypeORM are not reliable - see e.g. later
|
||||||
|
// parts of this issue:
|
||||||
|
// https://github.com/typeorm/typeorm/issues/3095
|
||||||
|
@OneToMany(type => Pref, pref => pref.org, {persistence: false})
|
||||||
|
public prefs?: Pref[];
|
||||||
|
|
||||||
public checkProperties(props: any): props is Partial<OrganizationProperties> {
|
public checkProperties(props: any): props is Partial<OrganizationProperties> {
|
||||||
return super.checkProperties(props, organizationPropertyKeys);
|
return super.checkProperties(props, organizationPropertyKeys);
|
||||||
}
|
}
|
||||||
|
31
app/gen-server/entity/Pref.ts
Normal file
31
app/gen-server/entity/Pref.ts
Normal file
@ -0,0 +1,31 @@
|
|||||||
|
import {Prefs} from 'app/common/Prefs';
|
||||||
|
import {Organization} from 'app/gen-server/entity/Organization';
|
||||||
|
import {User} from 'app/gen-server/entity/User';
|
||||||
|
import {nativeValues} from 'app/gen-server/lib/values';
|
||||||
|
import {Column, Entity, JoinColumn, ManyToOne, PrimaryColumn} from 'typeorm';
|
||||||
|
|
||||||
|
@Entity({name: 'prefs'})
|
||||||
|
export class Pref {
|
||||||
|
// This table may refer to users and/or orgs.
|
||||||
|
// We pretend userId/orgId are the primary key since TypeORM insists on having
|
||||||
|
// one, but we haven't marked them as so in the DB since the SQL standard frowns
|
||||||
|
// on nullable primary keys (and Postgres doesn't support them). We could add
|
||||||
|
// another primary key, but we don't actually need one.
|
||||||
|
@PrimaryColumn({name: 'user_id'})
|
||||||
|
public userId: number|null;
|
||||||
|
|
||||||
|
@PrimaryColumn({name: 'org_id'})
|
||||||
|
public orgId: number|null;
|
||||||
|
|
||||||
|
@ManyToOne(type => User)
|
||||||
|
@JoinColumn({name: 'user_id'})
|
||||||
|
public user?: User;
|
||||||
|
|
||||||
|
@ManyToOne(type => Organization)
|
||||||
|
@JoinColumn({name: 'org_id'})
|
||||||
|
public org?: Organization;
|
||||||
|
|
||||||
|
// Finally, the actual preferences, in JSON.
|
||||||
|
@Column({type: nativeValues.jsonEntityType})
|
||||||
|
public prefs: Prefs;
|
||||||
|
}
|
@ -18,6 +18,7 @@ import {Document} from "app/gen-server/entity/Document";
|
|||||||
import {Group} from "app/gen-server/entity/Group";
|
import {Group} from "app/gen-server/entity/Group";
|
||||||
import {Login} from "app/gen-server/entity/Login";
|
import {Login} from "app/gen-server/entity/Login";
|
||||||
import {AccessOption, AccessOptionWithRole, Organization} from "app/gen-server/entity/Organization";
|
import {AccessOption, AccessOptionWithRole, Organization} from "app/gen-server/entity/Organization";
|
||||||
|
import {Pref} from "app/gen-server/entity/Pref";
|
||||||
import {getDefaultProductNames, Product, starterFeatures} from "app/gen-server/entity/Product";
|
import {getDefaultProductNames, Product, starterFeatures} from "app/gen-server/entity/Product";
|
||||||
import {User} from "app/gen-server/entity/User";
|
import {User} from "app/gen-server/entity/User";
|
||||||
import {Workspace} from "app/gen-server/entity/Workspace";
|
import {Workspace} from "app/gen-server/entity/Workspace";
|
||||||
@ -641,6 +642,18 @@ export class HomeDBManager extends EventEmitter {
|
|||||||
}
|
}
|
||||||
qb = this._withAccess(qb, effectiveUserId, 'orgs');
|
qb = this._withAccess(qb, effectiveUserId, 'orgs');
|
||||||
qb = qb.leftJoinAndSelect('orgs.owner', 'owner');
|
qb = qb.leftJoinAndSelect('orgs.owner', 'owner');
|
||||||
|
// Add preference information that will be relevant for presentation of the org.
|
||||||
|
// That includes preference information specific to the site and the user,
|
||||||
|
// or specific just to the site, or specific just to the user.
|
||||||
|
qb = qb.leftJoinAndMapMany('orgs.prefs', Pref, 'prefs',
|
||||||
|
'(prefs.org_id = orgs.id or prefs.org_id IS NULL) AND ' +
|
||||||
|
'(prefs.user_id = :userId or prefs.user_id IS NULL)',
|
||||||
|
{userId});
|
||||||
|
// Apply a particular order (user+org first if present, then org, then user).
|
||||||
|
// Slightly round-about syntax because Sqlite and Postgres disagree about NULL
|
||||||
|
// ordering (Sqlite does support NULL LAST syntax now, but not on our fork yet).
|
||||||
|
qb = qb.addOrderBy('coalesce(prefs.org_id, 0)', 'DESC');
|
||||||
|
qb = qb.addOrderBy('coalesce(prefs.user_id, 0)', 'DESC');
|
||||||
const result = await this._verifyAclPermissions(qb);
|
const result = await this._verifyAclPermissions(qb);
|
||||||
if (result.status === 200) {
|
if (result.status === 200) {
|
||||||
// Return the only org.
|
// Return the only org.
|
||||||
@ -1164,19 +1177,45 @@ export class HomeDBManager extends EventEmitter {
|
|||||||
return orgResult;
|
return orgResult;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// If setting anything more than prefs:
|
||||||
// Checks that the user has UPDATE permissions to the given org. If not, throws an
|
// Checks that the user has UPDATE permissions to the given org. If not, throws an
|
||||||
// error. Otherwise updates the given org with the given name. Returns an empty
|
// error. Otherwise updates the given org with the given name. Returns an empty
|
||||||
// query result with status 200 on success.
|
// query result with status 200 on success.
|
||||||
|
// For setting userPrefs or userOrgPrefs:
|
||||||
|
// These are user-specific setting, so are allowed with VIEW access (that includes
|
||||||
|
// guests). Prefs are replaced in their entirety, not merged.
|
||||||
|
// For setting orgPrefs:
|
||||||
|
// These are not user-specific, so require UPDATE permissions.
|
||||||
public async updateOrg(
|
public async updateOrg(
|
||||||
scope: Scope,
|
scope: Scope,
|
||||||
orgKey: string|number,
|
orgKey: string|number,
|
||||||
props: Partial<OrganizationProperties>
|
props: Partial<OrganizationProperties>
|
||||||
): Promise<QueryResult<number>> {
|
): Promise<QueryResult<number>> {
|
||||||
// TODO: Unsetting a domain will likely have to be supported.
|
|
||||||
|
// Check the scope of the modifications.
|
||||||
|
let markPermissions: number = Permissions.VIEW;
|
||||||
|
let modifyOrg: boolean = false;
|
||||||
|
let modifyPrefs: boolean = false;
|
||||||
|
for (const key of Object.keys(props)) {
|
||||||
|
if (key === 'orgPrefs') {
|
||||||
|
// If setting orgPrefs, make sure we have UPDATE rights since this
|
||||||
|
// will affect other users.
|
||||||
|
markPermissions = Permissions.UPDATE;
|
||||||
|
modifyPrefs = true;
|
||||||
|
} else if (key === 'userPrefs' || key === 'userOrgPrefs') {
|
||||||
|
// These keys only affect the current user.
|
||||||
|
modifyPrefs = true;
|
||||||
|
} else {
|
||||||
|
markPermissions = Permissions.UPDATE;
|
||||||
|
modifyOrg = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TODO: Unsetting a domain will likely have to be supported; also possibly prefs.
|
||||||
return await this._connection.transaction(async manager => {
|
return await this._connection.transaction(async manager => {
|
||||||
const orgQuery = this.org(scope, orgKey, {
|
const orgQuery = this.org(scope, orgKey, {
|
||||||
manager,
|
manager,
|
||||||
markPermissions: Permissions.UPDATE
|
markPermissions,
|
||||||
});
|
});
|
||||||
const queryResult = await verifyIsPermitted(orgQuery);
|
const queryResult = await verifyIsPermitted(orgQuery);
|
||||||
if (queryResult.status !== 200) {
|
if (queryResult.status !== 200) {
|
||||||
@ -1185,14 +1224,33 @@ export class HomeDBManager extends EventEmitter {
|
|||||||
}
|
}
|
||||||
// Update the fields and save.
|
// Update the fields and save.
|
||||||
const org: Organization = queryResult.data;
|
const org: Organization = queryResult.data;
|
||||||
|
org.checkProperties(props);
|
||||||
|
if (modifyOrg) {
|
||||||
if (props.domain) {
|
if (props.domain) {
|
||||||
if (org.owner) {
|
if (org.owner) {
|
||||||
throw new ApiError('Cannot set a domain for a personal organization', 400);
|
throw new ApiError('Cannot set a domain for a personal organization', 400);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
org.checkProperties(props);
|
|
||||||
org.updateFromProperties(props);
|
org.updateFromProperties(props);
|
||||||
await manager.save(org);
|
await manager.save(org);
|
||||||
|
}
|
||||||
|
if (modifyPrefs) {
|
||||||
|
for (const flavor of ['orgPrefs', 'userOrgPrefs', 'userPrefs'] as const) {
|
||||||
|
const prefs = props[flavor];
|
||||||
|
if (prefs === undefined) { continue; }
|
||||||
|
const orgId = ['orgPrefs', 'userOrgPrefs'].includes(flavor) ? org.id : null;
|
||||||
|
const userId = ['userOrgPrefs', 'userPrefs'].includes(flavor) ? scope.userId : null;
|
||||||
|
await manager.createQueryBuilder()
|
||||||
|
.insert()
|
||||||
|
// if pref flavor has been set before, update it
|
||||||
|
.onConflict('(COALESCE(org_id,0), COALESCE(user_id,0)) DO UPDATE SET prefs = :prefs')
|
||||||
|
// TypeORM muddles JSON handling a bit here
|
||||||
|
.setParameters({prefs: JSON.stringify(prefs)})
|
||||||
|
.into(Pref)
|
||||||
|
.values({orgId, userId, prefs})
|
||||||
|
.execute();
|
||||||
|
}
|
||||||
|
}
|
||||||
return {status: 200};
|
return {status: 200};
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@ -3135,6 +3193,20 @@ export class HomeDBManager extends EventEmitter {
|
|||||||
value[key] = managers;
|
value[key] = managers;
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
if (key === 'prefs' && Array.isArray(subValue)) {
|
||||||
|
delete value[key];
|
||||||
|
const prefs = this._normalizeQueryResults(subValue, childOptions);
|
||||||
|
for (const pref of prefs) {
|
||||||
|
if (pref.orgId && pref.userId) {
|
||||||
|
value['userOrgPrefs'] = pref.prefs;
|
||||||
|
} else if (pref.orgId) {
|
||||||
|
value['orgPrefs'] = pref.prefs;
|
||||||
|
} else if (pref.userId) {
|
||||||
|
value['userPrefs'] = pref.prefs;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
if (key !== 'permissions') {
|
if (key !== 'permissions') {
|
||||||
value[key] = this._normalizeQueryResults(subValue, childOptions);
|
value[key] = this._normalizeQueryResults(subValue, childOptions);
|
||||||
continue;
|
continue;
|
||||||
|
62
app/gen-server/migration/1596456522124-Prefs.ts
Normal file
62
app/gen-server/migration/1596456522124-Prefs.ts
Normal file
@ -0,0 +1,62 @@
|
|||||||
|
import {nativeValues} from 'app/gen-server/lib/values';
|
||||||
|
import {MigrationInterface, QueryRunner, Table} from 'typeorm';
|
||||||
|
|
||||||
|
export class Prefs1596456522124 implements MigrationInterface {
|
||||||
|
|
||||||
|
public async up(queryRunner: QueryRunner): Promise<any> {
|
||||||
|
await queryRunner.createTable(new Table({
|
||||||
|
name: 'prefs',
|
||||||
|
columns: [
|
||||||
|
{
|
||||||
|
name: 'org_id',
|
||||||
|
type: 'integer',
|
||||||
|
isNullable: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'user_id',
|
||||||
|
type: 'integer',
|
||||||
|
isNullable: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'prefs',
|
||||||
|
type: nativeValues.jsonType
|
||||||
|
}
|
||||||
|
],
|
||||||
|
foreignKeys: [
|
||||||
|
{
|
||||||
|
columnNames: ['org_id'],
|
||||||
|
referencedColumnNames: ['id'],
|
||||||
|
referencedTableName: 'orgs',
|
||||||
|
onDelete: 'CASCADE' // delete pref if linked to org that is deleted
|
||||||
|
},
|
||||||
|
{
|
||||||
|
columnNames: ['user_id'],
|
||||||
|
referencedColumnNames: ['id'],
|
||||||
|
referencedTableName: 'users',
|
||||||
|
onDelete: 'CASCADE' // delete pref if linked to user that is deleted
|
||||||
|
},
|
||||||
|
],
|
||||||
|
indices: [
|
||||||
|
{ columnNames: ['org_id', 'user_id'] },
|
||||||
|
{ columnNames: ['user_id'] },
|
||||||
|
],
|
||||||
|
checks: [
|
||||||
|
// Make sure pref refers to something, either a user or an org or both
|
||||||
|
{
|
||||||
|
columnNames: ['user_id', 'org_id'],
|
||||||
|
expression: 'COALESCE(user_id, org_id) IS NOT NULL'
|
||||||
|
},
|
||||||
|
]
|
||||||
|
}));
|
||||||
|
// Having trouble convincing TypeORM to create an index on expressions.
|
||||||
|
// Luckily, the SQL is identical for Sqlite and Postgres:
|
||||||
|
await queryRunner.manager.query(
|
||||||
|
'CREATE UNIQUE INDEX "prefs__user_id__org_id" ON "prefs" ' +
|
||||||
|
'(COALESCE(user_id,0), COALESCE(org_id,0))'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async down(queryRunner: QueryRunner): Promise<any> {
|
||||||
|
await queryRunner.dropTable('prefs');
|
||||||
|
}
|
||||||
|
}
|
Loading…
Reference in New Issue
Block a user