Support user and team preferences (#4081)
* feat: support user preferences * feat: support team preferences * fix: update snapshots * feat: update last visited url by user * fix: update snapshots * fix: use path instead of complete url * fix: do not expose preferences to other users with the exception of admin * feat: support defaultDocumentStatus as a team preference * feat: allow edit even when collaborative editing is enabled * Revert "feat: allow edit even when collaborative editing is enabled" This reverts commit a22a02a406d01eb418dab32249b8b846bf77c59b. * Revert "feat: support defaultDocumentStatus as a team preference" This reverts commit 4928cffe5c682952b1e469a3e50a1a34d05dcc58. * fix: keep preference as a boolean
This commit is contained in:
14
server/migrations/20220907132304-user-preferences.js
Normal file
14
server/migrations/20220907132304-user-preferences.js
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
'use strict';
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
async up (queryInterface, Sequelize) {
|
||||||
|
return queryInterface.addColumn("users", "preferences", {
|
||||||
|
type: Sequelize.JSONB,
|
||||||
|
allowNull: true,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
async down (queryInterface, Sequelize) {
|
||||||
|
return queryInterface.removeColumn("users", "preferences");
|
||||||
|
}
|
||||||
|
};
|
||||||
14
server/migrations/20220907140227-team-preferences.js
Normal file
14
server/migrations/20220907140227-team-preferences.js
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
"use strict";
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
async up(queryInterface, Sequelize) {
|
||||||
|
return queryInterface.addColumn("teams", "preferences", {
|
||||||
|
type: Sequelize.JSONB,
|
||||||
|
allowNull: true,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
async down(queryInterface, Sequelize) {
|
||||||
|
return queryInterface.removeColumn("teams", "preferences");
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -36,6 +36,8 @@ import NotContainsUrl from "./validators/NotContainsUrl";
|
|||||||
|
|
||||||
const readFile = util.promisify(fs.readFile);
|
const readFile = util.promisify(fs.readFile);
|
||||||
|
|
||||||
|
export type TeamPreferences = Record<string, unknown>;
|
||||||
|
|
||||||
@Scopes(() => ({
|
@Scopes(() => ({
|
||||||
withDomains: {
|
withDomains: {
|
||||||
include: [{ model: TeamDomain }],
|
include: [{ model: TeamDomain }],
|
||||||
@@ -124,6 +126,10 @@ class Team extends ParanoidModel {
|
|||||||
@Column
|
@Column
|
||||||
defaultUserRole: string;
|
defaultUserRole: string;
|
||||||
|
|
||||||
|
@AllowNull
|
||||||
|
@Column(DataType.JSONB)
|
||||||
|
preferences: TeamPreferences | null;
|
||||||
|
|
||||||
// getters
|
// getters
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -54,6 +54,12 @@ export enum UserRole {
|
|||||||
Viewer = "viewer",
|
Viewer = "viewer",
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export enum UserPreference {
|
||||||
|
RememberLastPath = "rememberLastPath",
|
||||||
|
}
|
||||||
|
|
||||||
|
export type UserPreferences = { [key in UserPreference]?: boolean };
|
||||||
|
|
||||||
@Scopes(() => ({
|
@Scopes(() => ({
|
||||||
withAuthentications: {
|
withAuthentications: {
|
||||||
include: [
|
include: [
|
||||||
@@ -152,6 +158,10 @@ class User extends ParanoidModel {
|
|||||||
@Column(DataType.JSONB)
|
@Column(DataType.JSONB)
|
||||||
flags: { [key in UserFlag]?: number } | null;
|
flags: { [key in UserFlag]?: number } | null;
|
||||||
|
|
||||||
|
@AllowNull
|
||||||
|
@Column(DataType.JSONB)
|
||||||
|
preferences: UserPreferences | null;
|
||||||
|
|
||||||
@Default(env.DEFAULT_LANGUAGE)
|
@Default(env.DEFAULT_LANGUAGE)
|
||||||
@IsIn([languages])
|
@IsIn([languages])
|
||||||
@Column
|
@Column
|
||||||
@@ -290,6 +300,35 @@ class User extends ParanoidModel {
|
|||||||
return this.flags;
|
return this.flags;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Preferences set by the user that decide application behavior and ui.
|
||||||
|
*
|
||||||
|
* @param preference The user preference to set
|
||||||
|
* @param value Sets the preference value
|
||||||
|
* @returns The current user preferences
|
||||||
|
*/
|
||||||
|
public setPreference = (preference: UserPreference, value: boolean) => {
|
||||||
|
if (!this.preferences) {
|
||||||
|
this.preferences = {};
|
||||||
|
}
|
||||||
|
this.preferences[preference] = value;
|
||||||
|
this.changed("preferences", true);
|
||||||
|
|
||||||
|
return this.preferences;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns the passed preference value
|
||||||
|
*
|
||||||
|
* @param preference The user preference to retrieve
|
||||||
|
* @returns The preference value if set, else undefined
|
||||||
|
*/
|
||||||
|
public getPreference = (preference: UserPreference) => {
|
||||||
|
return !!this.preferences && this.preferences[preference]
|
||||||
|
? this.preferences[preference]
|
||||||
|
: undefined;
|
||||||
|
};
|
||||||
|
|
||||||
collectionIds = async (options = {}) => {
|
collectionIds = async (options = {}) => {
|
||||||
const collectionStubs = await Collection.scope({
|
const collectionStubs = await Collection.scope({
|
||||||
method: ["withMembership", this.id],
|
method: ["withMembership", this.id],
|
||||||
@@ -575,7 +614,7 @@ class User extends ParanoidModel {
|
|||||||
|
|
||||||
static getCounts = async function (teamId: string) {
|
static getCounts = async function (teamId: string) {
|
||||||
const countSql = `
|
const countSql = `
|
||||||
SELECT
|
SELECT
|
||||||
COUNT(CASE WHEN "suspendedAt" IS NOT NULL THEN 1 END) as "suspendedCount",
|
COUNT(CASE WHEN "suspendedAt" IS NOT NULL THEN 1 END) as "suspendedCount",
|
||||||
COUNT(CASE WHEN "isAdmin" = true THEN 1 END) as "adminCount",
|
COUNT(CASE WHEN "isAdmin" = true THEN 1 END) as "adminCount",
|
||||||
COUNT(CASE WHEN "isViewer" = true THEN 1 END) as "viewerCount",
|
COUNT(CASE WHEN "isViewer" = true THEN 1 END) as "viewerCount",
|
||||||
|
|||||||
@@ -17,5 +17,6 @@ export default function present(team: Team) {
|
|||||||
defaultUserRole: team.defaultUserRole,
|
defaultUserRole: team.defaultUserRole,
|
||||||
inviteRequired: team.inviteRequired,
|
inviteRequired: team.inviteRequired,
|
||||||
allowedDomains: team.allowedDomains?.map((d) => d.name),
|
allowedDomains: team.allowedDomains?.map((d) => d.name),
|
||||||
|
preferences: team.preferences,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import env from "@server/env";
|
import env from "@server/env";
|
||||||
import { User } from "@server/models";
|
import { User } from "@server/models";
|
||||||
|
import { UserPreferences } from "@server/models/User";
|
||||||
|
|
||||||
type Options = {
|
type Options = {
|
||||||
includeDetails?: boolean;
|
includeDetails?: boolean;
|
||||||
@@ -18,6 +19,7 @@ type UserPresentation = {
|
|||||||
isViewer: boolean;
|
isViewer: boolean;
|
||||||
email?: string | null;
|
email?: string | null;
|
||||||
language?: string;
|
language?: string;
|
||||||
|
preferences?: UserPreferences | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
export default (user: User, options: Options = {}): UserPresentation => {
|
export default (user: User, options: Options = {}): UserPresentation => {
|
||||||
@@ -37,6 +39,7 @@ export default (user: User, options: Options = {}): UserPresentation => {
|
|||||||
if (options.includeDetails) {
|
if (options.includeDetails) {
|
||||||
userData.email = user.email;
|
userData.email = user.email;
|
||||||
userData.language = user.language || env.DEFAULT_LANGUAGE;
|
userData.language = user.language || env.DEFAULT_LANGUAGE;
|
||||||
|
userData.preferences = user.preferences;
|
||||||
}
|
}
|
||||||
|
|
||||||
return userData;
|
return userData;
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ Object {
|
|||||||
"language": "en_US",
|
"language": "en_US",
|
||||||
"lastActiveAt": null,
|
"lastActiveAt": null,
|
||||||
"name": "User 1",
|
"name": "User 1",
|
||||||
|
"preferences": null,
|
||||||
"updatedAt": "2018-01-02T00:00:00.000Z",
|
"updatedAt": "2018-01-02T00:00:00.000Z",
|
||||||
},
|
},
|
||||||
"ok": true,
|
"ok": true,
|
||||||
@@ -69,6 +70,7 @@ Object {
|
|||||||
"language": "en_US",
|
"language": "en_US",
|
||||||
"lastActiveAt": null,
|
"lastActiveAt": null,
|
||||||
"name": "User 1",
|
"name": "User 1",
|
||||||
|
"preferences": null,
|
||||||
"updatedAt": "2018-01-02T00:00:00.000Z",
|
"updatedAt": "2018-01-02T00:00:00.000Z",
|
||||||
},
|
},
|
||||||
"ok": true,
|
"ok": true,
|
||||||
@@ -106,6 +108,7 @@ Object {
|
|||||||
"language": "en_US",
|
"language": "en_US",
|
||||||
"lastActiveAt": null,
|
"lastActiveAt": null,
|
||||||
"name": "User 1",
|
"name": "User 1",
|
||||||
|
"preferences": null,
|
||||||
"updatedAt": "2018-01-02T00:00:00.000Z",
|
"updatedAt": "2018-01-02T00:00:00.000Z",
|
||||||
},
|
},
|
||||||
"ok": true,
|
"ok": true,
|
||||||
@@ -143,6 +146,7 @@ Object {
|
|||||||
"language": "en_US",
|
"language": "en_US",
|
||||||
"lastActiveAt": null,
|
"lastActiveAt": null,
|
||||||
"name": "User 1",
|
"name": "User 1",
|
||||||
|
"preferences": null,
|
||||||
"updatedAt": "2018-01-02T00:00:00.000Z",
|
"updatedAt": "2018-01-02T00:00:00.000Z",
|
||||||
},
|
},
|
||||||
"ok": true,
|
"ok": true,
|
||||||
@@ -198,6 +202,7 @@ Object {
|
|||||||
"language": "en_US",
|
"language": "en_US",
|
||||||
"lastActiveAt": null,
|
"lastActiveAt": null,
|
||||||
"name": "User 1",
|
"name": "User 1",
|
||||||
|
"preferences": null,
|
||||||
"updatedAt": "2018-01-02T00:00:00.000Z",
|
"updatedAt": "2018-01-02T00:00:00.000Z",
|
||||||
},
|
},
|
||||||
"ok": true,
|
"ok": true,
|
||||||
@@ -262,6 +267,7 @@ Object {
|
|||||||
"language": "en_US",
|
"language": "en_US",
|
||||||
"lastActiveAt": null,
|
"lastActiveAt": null,
|
||||||
"name": "User 1",
|
"name": "User 1",
|
||||||
|
"preferences": null,
|
||||||
"updatedAt": "2018-01-02T00:00:00.000Z",
|
"updatedAt": "2018-01-02T00:00:00.000Z",
|
||||||
},
|
},
|
||||||
"ok": true,
|
"ok": true,
|
||||||
|
|||||||
@@ -393,6 +393,46 @@ describe("#users.update", () => {
|
|||||||
expect(body.data.name).toEqual("New name");
|
expect(body.data.name).toEqual("New name");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("should fail upon sending invalid user preference", async () => {
|
||||||
|
const { user } = await seed();
|
||||||
|
const res = await server.post("/api/users.update", {
|
||||||
|
body: {
|
||||||
|
token: user.getJwtToken(),
|
||||||
|
name: "New name",
|
||||||
|
preferences: { invalidPreference: "invalidValue" },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
expect(res.status).toEqual(400);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should fail upon sending invalid user preference value", async () => {
|
||||||
|
const { user } = await seed();
|
||||||
|
const res = await server.post("/api/users.update", {
|
||||||
|
body: {
|
||||||
|
token: user.getJwtToken(),
|
||||||
|
name: "New name",
|
||||||
|
preferences: { rememberLastPath: "invalidValue" },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
expect(res.status).toEqual(400);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should update rememberLastPath user preference", async () => {
|
||||||
|
const { user } = await seed();
|
||||||
|
const res = await server.post("/api/users.update", {
|
||||||
|
body: {
|
||||||
|
token: user.getJwtToken(),
|
||||||
|
name: "New name",
|
||||||
|
preferences: {
|
||||||
|
rememberLastPath: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const body = await res.json();
|
||||||
|
expect(res.status).toEqual(200);
|
||||||
|
expect(body.data.preferences.rememberLastPath).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
it("should require authentication", async () => {
|
it("should require authentication", async () => {
|
||||||
const res = await server.post("/api/users.update");
|
const res = await server.post("/api/users.update");
|
||||||
const body = await res.json();
|
const body = await res.json();
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ import logger from "@server/logging/Logger";
|
|||||||
import auth from "@server/middlewares/authentication";
|
import auth from "@server/middlewares/authentication";
|
||||||
import { rateLimiter } from "@server/middlewares/rateLimiter";
|
import { rateLimiter } from "@server/middlewares/rateLimiter";
|
||||||
import { Event, User, Team } from "@server/models";
|
import { Event, User, Team } from "@server/models";
|
||||||
import { UserFlag, UserRole } from "@server/models/User";
|
import { UserFlag, UserRole, UserPreference } from "@server/models/User";
|
||||||
import { can, authorize } from "@server/policies";
|
import { can, authorize } from "@server/policies";
|
||||||
import { presentUser, presentPolicies } from "@server/presenters";
|
import { presentUser, presentPolicies } from "@server/presenters";
|
||||||
import {
|
import {
|
||||||
@@ -26,6 +26,8 @@ import {
|
|||||||
assertPresent,
|
assertPresent,
|
||||||
assertArray,
|
assertArray,
|
||||||
assertUuid,
|
assertUuid,
|
||||||
|
assertKeysIn,
|
||||||
|
assertBoolean,
|
||||||
} from "@server/validation";
|
} from "@server/validation";
|
||||||
import pagination from "./middlewares/pagination";
|
import pagination from "./middlewares/pagination";
|
||||||
|
|
||||||
@@ -174,7 +176,7 @@ router.post("users.info", auth(), async (ctx) => {
|
|||||||
|
|
||||||
router.post("users.update", auth(), async (ctx) => {
|
router.post("users.update", auth(), async (ctx) => {
|
||||||
const { user } = ctx.state;
|
const { user } = ctx.state;
|
||||||
const { name, avatarUrl, language } = ctx.body;
|
const { name, avatarUrl, language, preferences } = ctx.body;
|
||||||
if (name) {
|
if (name) {
|
||||||
user.name = name;
|
user.name = name;
|
||||||
}
|
}
|
||||||
@@ -184,6 +186,16 @@ router.post("users.update", auth(), async (ctx) => {
|
|||||||
if (language) {
|
if (language) {
|
||||||
user.language = language;
|
user.language = language;
|
||||||
}
|
}
|
||||||
|
if (preferences) {
|
||||||
|
assertKeysIn(preferences, UserPreference);
|
||||||
|
if (preferences.rememberLastPath) {
|
||||||
|
assertBoolean(preferences.rememberLastPath);
|
||||||
|
user.setPreference(
|
||||||
|
UserPreference.RememberLastPath,
|
||||||
|
preferences.rememberLastPath
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
await user.save();
|
await user.save();
|
||||||
await Event.create({
|
await Event.create({
|
||||||
name: "users.update",
|
name: "users.update",
|
||||||
|
|||||||
@@ -33,6 +33,21 @@ export const assertIn = (
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Asserts that an object contains no other keys than specified
|
||||||
|
* by a type
|
||||||
|
*
|
||||||
|
* @param obj The object to check for assertion
|
||||||
|
* @param type The type to check against
|
||||||
|
* @throws {ValidationError}
|
||||||
|
*/
|
||||||
|
export function assertKeysIn(
|
||||||
|
obj: Record<string, unknown>,
|
||||||
|
type: { [key: string]: number | string }
|
||||||
|
) {
|
||||||
|
Object.keys(obj).forEach((key) => assertIn(key, Object.values(type)));
|
||||||
|
}
|
||||||
|
|
||||||
export const assertSort = (
|
export const assertSort = (
|
||||||
value: string,
|
value: string,
|
||||||
model: any,
|
model: any,
|
||||||
@@ -78,6 +93,24 @@ export function assertUrl(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Asserts that the passed value is a valid boolean
|
||||||
|
*
|
||||||
|
* @param value The value to check for assertion
|
||||||
|
* @param [message] The error message to show
|
||||||
|
* @throws {ValidationError}
|
||||||
|
*/
|
||||||
|
export function assertBoolean(
|
||||||
|
value: IncomingValue,
|
||||||
|
message?: string
|
||||||
|
): asserts value {
|
||||||
|
if (typeof value !== "boolean") {
|
||||||
|
throw ValidationError(
|
||||||
|
message ?? `${String(value)} is a ${typeof value}, not a boolean!`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export function assertUuid(
|
export function assertUuid(
|
||||||
value: IncomingValue,
|
value: IncomingValue,
|
||||||
message?: string
|
message?: string
|
||||||
|
|||||||
Reference in New Issue
Block a user