Skip to content

Commit

Permalink
馃洜 #1900 Workspace members invite fixes (#1911)
Browse files Browse the repository at this point in the history
* #1900 Verify that members can invite other users to the company/workspace (and not only the workspace moderators)

* #1901 Add a notice to warn users that the company has reached the maximum number of users it can invite

* #1901 Update workspace-pending-users.spect.ts

* #1901 Update users.spec.ts

* #1901 Update should 200 when company exists in users.spec.ts

* #1901 Update workspace-pending-users.spec.ts

* #1901 Resolve threads
  • Loading branch information
stephanevieira75 committed Feb 7, 2022
1 parent 36ce6cd commit b404273
Show file tree
Hide file tree
Showing 23 changed files with 313 additions and 75 deletions.
32 changes: 19 additions & 13 deletions twake/backend/node/src/services/console/clients/remote.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ import UserServiceAPI from "../../user/api";
import coalesce from "../../../utils/coalesce";
import { logger } from "../../../core/platform/framework/logger";
import _ from "lodash";
import { CompanyFeaturesEnum, CompanyLimitsEnum } from "../../user/web/types";

export class ConsoleRemoteClient implements ConsoleServiceClient {
version: "1";
Expand Down Expand Up @@ -181,32 +182,37 @@ export class ConsoleRemoteClient implements ConsoleServiceClient {
}

if (!company.plan) {
company.plan = { name: "", features: {} };
company.plan = { name: "", limits: undefined, features: undefined };
}

//FIXME this is a hack right now!
let planFeatures: any = {
"chat:guests": true,
"chat:message_history": true,
"chat:multiple_workspaces": true,
"chat:edit_files": true,
"chat:unlimited_storage": true,
[CompanyFeaturesEnum.CHAT_GUESTS]: true,
[CompanyFeaturesEnum.CHAT_MESSAGE_HISTORY]: true,
[CompanyFeaturesEnum.CHAT_MULTIPLE_WORKSPACES]: true,
[CompanyFeaturesEnum.CHAT_EDIT_FILES]: true,
[CompanyFeaturesEnum.CHAT_UNLIMITED_STORAGE]: true,
[CompanyFeaturesEnum.COMPANY_INVITE_MEMBER]: true,
};

if (companyDTO.limits.members < 0 && this.infos.type === "remote") {
//Hack to say this is free version
planFeatures = {
"chat:guests": false,
"chat:message_history": false,
"chat:message_history_limit": 10000,
"chat:multiple_workspaces": false,
"chat:edit_files": false,
"chat:unlimited_storage": false, //Currently inactive
[CompanyFeaturesEnum.CHAT_GUESTS]: false,
[CompanyFeaturesEnum.CHAT_MESSAGE_HISTORY]: false,
[CompanyFeaturesEnum.CHAT_MULTIPLE_WORKSPACES]: false,
[CompanyFeaturesEnum.CHAT_EDIT_FILES]: false,
[CompanyFeaturesEnum.CHAT_UNLIMITED_STORAGE]: false, // Currently inactive
};
company.plan.name = "free";
} else {
company.plan.name = "standard";
}
company.plan.features = { ...planFeatures, ...companyDTO.limits };
company.plan.features = { ...planFeatures };
company.plan.limits = {
[CompanyLimitsEnum.CHAT_MESSAGE_HISTORY_LIMIT]: 10000, // To remove duplicata since we define this in formatCompany function
[CompanyLimitsEnum.COMPANY_MEMBERS_LIMIT]: companyDTO.limits["members"],
};

company.stats = coalesce(companyDTO.stats, company.stats);

Expand Down
2 changes: 1 addition & 1 deletion twake/backend/node/src/services/console/web/controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -83,7 +83,7 @@ export class ConsoleController {
if (!company) {
const newCompany = getCompanyInstance({
name: "Twake",
plan: { name: "Local", features: {} },
plan: { name: "Local", limits: undefined, features: undefined },
});
company = await this.userService.companies.createCompany(newCompany);
}
Expand Down
4 changes: 3 additions & 1 deletion twake/backend/node/src/services/user/entities/company.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { merge } from "lodash";
import { Column, Entity } from "../../../core/platform/services/database/services/orm/decorators";
import { CompanyFeaturesObject, CompanyLimitsObject } from "../web/types";

// backward compatibility with PHP where companies used to be `group_entity`
export const TYPE = "group_entity";
Expand All @@ -21,7 +22,8 @@ export default class Company {
@Column("plan", "encoded_json")
plan?: {
name: string;
features: any;
limits: CompanyLimitsObject;
features: CompanyFeaturesObject;
};

@Column("stats", "encoded_json")
Expand Down
32 changes: 31 additions & 1 deletion twake/backend/node/src/services/user/services/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,8 @@ import { getService as getExternalService } from "./external_links";
import { getService as getWorkspaceService } from "../../workspaces/services/workspace";
import { WorkspaceServiceAPI } from "../../workspaces/api";
import {
CompanyFeaturesEnum,
CompanyLimitsEnum,
CompanyObject,
CompanyShort,
CompanyStatsObject,
Expand Down Expand Up @@ -143,14 +145,42 @@ class Service implements UserServiceAPI {
};

if (companyUserObject) {
res.status = "active"; // FIXME: with real status
res.status = "active"; // FIXME: Deactivated console user are removed from company on twake side
res.role = companyUserObject.role;
}

if (companyStats) {
res.stats = companyStats;
}

res.plan = {
name: res.plan?.name || "free",
limits: res.plan?.limits || {},
features: res.plan?.features || {},
};

res.plan.limits = Object.assign(
{
[CompanyLimitsEnum.CHAT_MESSAGE_HISTORY_LIMIT]: 10000,
[CompanyLimitsEnum.COMPANY_MEMBERS_LIMIT]: -1,
},
res.plan?.limits || {},
);

res.plan.features = Object.assign(
{
[CompanyFeaturesEnum.CHAT_GUESTS]: true,
[CompanyFeaturesEnum.CHAT_MESSAGE_HISTORY]: true,
[CompanyFeaturesEnum.CHAT_MULTIPLE_WORKSPACES]: true,
[CompanyFeaturesEnum.CHAT_EDIT_FILES]: true,
[CompanyFeaturesEnum.CHAT_UNLIMITED_STORAGE]: true,
[CompanyFeaturesEnum.COMPANY_INVITE_MEMBER]:
res.plan?.limits[CompanyLimitsEnum.COMPANY_MEMBERS_LIMIT] <= 0 ||
res.stats.total_members < res.plan?.limits[CompanyLimitsEnum.COMPANY_MEMBERS_LIMIT],
},
res.plan?.features || {},
);

return res;
}
}
26 changes: 18 additions & 8 deletions twake/backend/node/src/services/user/web/schemas.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { webSocketSchema } from "../../../utils/types";
import { CompanyFeaturesEnum, CompanyLimitsEnum } from "./types";

export const userObjectSchema = {
type: "object",
Expand Down Expand Up @@ -60,18 +61,27 @@ const companyObjectSchema = {
type: ["object", "null"],
properties: {
name: { type: "string" },
limits: {
type: ["object", "null"],
properties: {
[CompanyLimitsEnum.CHAT_MESSAGE_HISTORY_LIMIT]: { type: "number" },
[CompanyLimitsEnum.COMPANY_MEMBERS_LIMIT]: { type: "number" },
},
},
features: {
type: "object",
properties: {
"chat:edit_files": { type: "boolean" },
"chat:guests": { type: "boolean" },
"chat:message_history": { type: "boolean" },
"chat:multiple_workspaces": { type: "boolean" },
"chat:unlimited_storage": { type: "boolean" },
guests: { type: "number" },
members: { type: "number" },
storage: { type: "number" },
[CompanyFeaturesEnum.CHAT_EDIT_FILES]: { type: ["boolean"] },
[CompanyFeaturesEnum.CHAT_GUESTS]: { type: ["boolean"] },
[CompanyFeaturesEnum.CHAT_MESSAGE_HISTORY]: { type: "boolean" },
[CompanyFeaturesEnum.CHAT_MULTIPLE_WORKSPACES]: { type: "boolean" },
[CompanyFeaturesEnum.CHAT_UNLIMITED_STORAGE]: { type: "boolean" },
[CompanyFeaturesEnum.COMPANY_INVITE_MEMBER]: { type: "boolean" },
guests: { type: "number" }, // to rename or delete
members: { type: "number" }, // to rename or delete
storage: { type: "number" }, // to rename or delete
},
required: [] as string[],
},
},
},
Expand Down
31 changes: 30 additions & 1 deletion twake/backend/node/src/services/user/web/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -65,9 +65,38 @@ export interface UserObject {
companies?: CompanyUserObject[];
}

export enum CompanyLimitsEnum {
CHAT_MESSAGE_HISTORY_LIMIT = "chat:message_history_limit",
COMPANY_MEMBERS_LIMIT = "company:members_limit", // 100
}

export enum CompanyFeaturesEnum {
CHAT_GUESTS = "chat:guests",
CHAT_MESSAGE_HISTORY = "chat:message_history",
CHAT_MULTIPLE_WORKSPACES = "chat:multiple_workspaces",
CHAT_EDIT_FILES = "chat:edit_files",
CHAT_UNLIMITED_STORAGE = "chat:unlimited_storage",
COMPANY_INVITE_MEMBER = "company:invite_member",
}

export type CompanyFeaturesObject = {
[CompanyFeaturesEnum.CHAT_GUESTS]?: boolean;
[CompanyFeaturesEnum.CHAT_MESSAGE_HISTORY]?: boolean;
[CompanyFeaturesEnum.CHAT_MULTIPLE_WORKSPACES]?: boolean;
[CompanyFeaturesEnum.CHAT_EDIT_FILES]?: boolean;
[CompanyFeaturesEnum.CHAT_UNLIMITED_STORAGE]?: boolean;
[CompanyFeaturesEnum.COMPANY_INVITE_MEMBER]?: boolean;
};

export type CompanyLimitsObject = {
[CompanyLimitsEnum.CHAT_MESSAGE_HISTORY_LIMIT]?: number;
[CompanyLimitsEnum.COMPANY_MEMBERS_LIMIT]?: number;
};

export interface CompanyPlanObject {
name: string;
features: any;
limits?: CompanyLimitsObject;
features?: CompanyFeaturesObject;
}

export interface CompanyStatsObject {
Expand Down
63 changes: 55 additions & 8 deletions twake/backend/node/src/services/workspaces/web/routes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ import { hasWorkspaceAdminLevel, hasWorkspaceMemberLevel } from "../../../utils/
import { WorkspaceInviteTokensCrudController } from "./controllers/workspace-invite-tokens";
import WorkspaceUser from "../entities/workspace_user";
import { RealtimeServiceAPI } from "../../../core/platform/services/realtime/api";
import { hasCompanyMemberLevel } from "../../../utils/company";

const workspacesUrl = "/companies/:company_id/workspaces";
const workspacePendingUsersUrl = "/companies/:company_id/workspaces/:workspace_id/pending";
Expand Down Expand Up @@ -110,11 +111,27 @@ const routes: FastifyPluginCallback<{
return workspaceUser;
};

const checkUserHasCompanyMemberLevel = async (
request: FastifyRequest<{ Params: WorkspaceUsersRequest }>,
) => {
if (!request.currentUser.id) {
throw fastify.httpErrors.forbidden("You must be authenticated");
}
const companyUser = await options.service.companies.getCompanyUser(
{ id: request.params.company_id },
{ id: request.currentUser.id },
);

if (!hasCompanyMemberLevel(companyUser?.role)) {
throw fastify.httpErrors.forbidden("Only company member can perform this action");
}
};

const checkUserIsWorkspaceAdmin = async (
request: FastifyRequest<{ Params: WorkspaceUsersRequest }>,
) => {
if (!request.currentUser.id) {
throw fastify.httpErrors.forbidden("Only workspace moderator can perform this action");
throw fastify.httpErrors.forbidden("You must be authenticated");
}
const workspaceUser = await checkUserWorkspace(request);
const companyUser = await options.service.companies.getCompanyUser(
Expand All @@ -130,7 +147,7 @@ const routes: FastifyPluginCallback<{
request: FastifyRequest<{ Params: WorkspaceUsersRequest }>,
) => {
if (!request.currentUser.id) {
throw fastify.httpErrors.forbidden("Only workspace moderator can perform this action");
throw fastify.httpErrors.forbidden("You must be authenticated");
}
const workspaceUser = await checkUserWorkspace(request);
const companyUser = await options.service.companies.getCompanyUser(
Expand Down Expand Up @@ -240,7 +257,12 @@ const routes: FastifyPluginCallback<{
fastify.route({
method: "POST",
url: `${workspaceUsersUrl}/invite`,
preHandler: [accessControl, companyCheck, checkUserIsWorkspaceAdmin],
preHandler: [
accessControl,
companyCheck,
checkUserHasCompanyMemberLevel,
checkUserIsWorkspaceMember,
],
preValidation: [fastify.authenticate],
schema: inviteWorkspaceUserSchema,
handler: workspaceUsersController.invite.bind(workspaceUsersController),
Expand All @@ -249,7 +271,12 @@ const routes: FastifyPluginCallback<{
fastify.route({
method: "DELETE",
url: `${workspacePendingUsersUrl}/:email`,
preHandler: [accessControl, companyCheck, checkUserIsWorkspaceAdmin],
preHandler: [
accessControl,
companyCheck,
checkUserHasCompanyMemberLevel,
checkUserIsWorkspaceMember,
],
preValidation: [fastify.authenticate],
schema: deleteWorkspacePendingUsersSchema,
handler: workspaceUsersController.deletePending.bind(workspaceUsersController),
Expand All @@ -258,7 +285,12 @@ const routes: FastifyPluginCallback<{
fastify.route({
method: "GET",
url: `${workspacePendingUsersUrl}`,
preHandler: [accessControl, companyCheck, checkUserIsWorkspaceAdmin],
preHandler: [
accessControl,
companyCheck,
checkUserHasCompanyMemberLevel,
checkUserIsWorkspaceMember,
],
preValidation: [fastify.authenticate],
schema: getWorkspacePendingUsersSchema,
handler: workspaceUsersController.listPending.bind(workspaceUsersController),
Expand All @@ -267,7 +299,12 @@ const routes: FastifyPluginCallback<{
fastify.route({
method: "GET",
url: `${workspaceInviteTokensUrl}`,
preHandler: [accessControl, companyCheck, checkUserIsWorkspaceMember],
preHandler: [
accessControl,
companyCheck,
checkUserIsWorkspaceMember,
checkUserHasCompanyMemberLevel,
],
preValidation: [fastify.authenticate],
schema: getWorkspaceInviteTokenSchema,
handler: workspaceInviteTokensController.list.bind(workspaceInviteTokensController),
Expand All @@ -276,7 +313,12 @@ const routes: FastifyPluginCallback<{
fastify.route({
method: "POST",
url: `${workspaceInviteTokensUrl}`,
preHandler: [accessControl, companyCheck, checkUserIsWorkspaceMember],
preHandler: [
accessControl,
companyCheck,
checkUserIsWorkspaceMember,
checkUserHasCompanyMemberLevel,
],
preValidation: [fastify.authenticate],
schema: postWorkspaceInviteTokenSchema,
handler: workspaceInviteTokensController.save.bind(workspaceInviteTokensController),
Expand All @@ -285,7 +327,12 @@ const routes: FastifyPluginCallback<{
fastify.route({
method: "DELETE",
url: `${workspaceInviteTokensUrl}/:token`,
preHandler: [accessControl, companyCheck, checkUserIsWorkspaceMember],
preHandler: [
accessControl,
companyCheck,
checkUserIsWorkspaceMember,
checkUserHasCompanyMemberLevel,
],
preValidation: [fastify.authenticate],
schema: deleteWorkspaceInviteTokenSchema,
handler: workspaceInviteTokensController.delete.bind(workspaceInviteTokensController),
Expand Down
11 changes: 5 additions & 6 deletions twake/backend/node/test/e2e/users/users.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { beforeAll, afterAll, afterEach, beforeEach, describe, expect, it } from
import { init, TestPlatform } from "../setup";
import { TestDbService } from "../utils.prepare.db";
import { v1 as uuidv1 } from "uuid";
import { CompanyLimitsEnum } from "../../../src/services/user/web/types";

describe("The /users API", () => {
const url = "/internal/services/users/v1";
Expand Down Expand Up @@ -285,9 +286,8 @@ describe("The /users API", () => {
expect(resource.plan).toMatchObject({
name: expect.any(String),
limits: expect.objectContaining({
members: expect.any(Number),
guests: expect.any(Number),
storage: expect.any(Number),
[CompanyLimitsEnum.CHAT_MESSAGE_HISTORY_LIMIT]: expect.any(Number || undefined),
[CompanyLimitsEnum.COMPANY_MEMBERS_LIMIT]: expect.any(Number || undefined),
}),
});
}
Expand Down Expand Up @@ -334,9 +334,8 @@ describe("The /users API", () => {
expect(json.resource.plan).toMatchObject({
name: expect.any(String),
limits: expect.objectContaining({
members: expect.any(Number),
guests: expect.any(Number),
storage: expect.any(Number),
[CompanyLimitsEnum.CHAT_MESSAGE_HISTORY_LIMIT]: expect.any(Number || undefined),
[CompanyLimitsEnum.COMPANY_MEMBERS_LIMIT]: expect.any(Number || undefined),
}),
});
}
Expand Down

0 comments on commit b404273

Please sign in to comment.