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 {isClient} from 'app/common/gristUrls';
|
||||
import {FullUser} 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';
|
||||
|
||||
@ -31,8 +32,13 @@ 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'];
|
||||
export const organizationPropertyKeys = [...commonPropertyKeys, 'domain',
|
||||
'orgPrefs', 'userOrgPrefs', 'userPrefs'];
|
||||
|
||||
// Basic information about an organization, excluding the user's access level
|
||||
export interface OrganizationWithoutAccessInfo extends OrganizationProperties {
|
||||
@ -232,6 +238,7 @@ export interface UserAPI {
|
||||
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
|
||||
@ -384,6 +391,13 @@ export class UserAPIImpl extends BaseAPI implements UserAPI {
|
||||
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',
|
||||
|
@ -1,9 +1,11 @@
|
||||
import {Column, Entity, JoinColumn, ManyToOne, OneToMany, OneToOne,
|
||||
PrimaryGeneratedColumn, RelationId} from "typeorm";
|
||||
|
||||
import {Role} from "app/common/roles";
|
||||
import {OrganizationProperties, organizationPropertyKeys} from "app/common/UserAPI";
|
||||
import {AclRuleOrg} from "./AclRule";
|
||||
import {BillingAccount} from "./BillingAccount";
|
||||
import {Pref} from "./Pref";
|
||||
import {Resource} from "./Resource";
|
||||
import {User} from "./User";
|
||||
import {Workspace} from "./Workspace";
|
||||
@ -68,6 +70,13 @@ export class Organization extends Resource {
|
||||
@Column({name: 'host', type: 'text', nullable: true})
|
||||
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> {
|
||||
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 {Login} from "app/gen-server/entity/Login";
|
||||
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 {User} from "app/gen-server/entity/User";
|
||||
import {Workspace} from "app/gen-server/entity/Workspace";
|
||||
@ -641,6 +642,18 @@ export class HomeDBManager extends EventEmitter {
|
||||
}
|
||||
qb = this._withAccess(qb, effectiveUserId, 'orgs');
|
||||
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);
|
||||
if (result.status === 200) {
|
||||
// Return the only org.
|
||||
@ -1164,19 +1177,45 @@ export class HomeDBManager extends EventEmitter {
|
||||
return orgResult;
|
||||
}
|
||||
|
||||
// If setting anything more than prefs:
|
||||
// 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
|
||||
// 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(
|
||||
scope: Scope,
|
||||
orgKey: string|number,
|
||||
props: Partial<OrganizationProperties>
|
||||
): 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 => {
|
||||
const orgQuery = this.org(scope, orgKey, {
|
||||
manager,
|
||||
markPermissions: Permissions.UPDATE
|
||||
markPermissions,
|
||||
});
|
||||
const queryResult = await verifyIsPermitted(orgQuery);
|
||||
if (queryResult.status !== 200) {
|
||||
@ -1185,14 +1224,33 @@ export class HomeDBManager extends EventEmitter {
|
||||
}
|
||||
// Update the fields and save.
|
||||
const org: Organization = queryResult.data;
|
||||
org.checkProperties(props);
|
||||
if (modifyOrg) {
|
||||
if (props.domain) {
|
||||
if (org.owner) {
|
||||
throw new ApiError('Cannot set a domain for a personal organization', 400);
|
||||
}
|
||||
}
|
||||
org.checkProperties(props);
|
||||
org.updateFromProperties(props);
|
||||
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};
|
||||
});
|
||||
}
|
||||
@ -3135,6 +3193,20 @@ export class HomeDBManager extends EventEmitter {
|
||||
value[key] = managers;
|
||||
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') {
|
||||
value[key] = this._normalizeQueryResults(subValue, childOptions);
|
||||
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