Finish preliminary capability for adding incomplete users to groups

This commit is contained in:
Tuan Dang
2024-04-18 10:57:25 -07:00
parent d23b39abba
commit fa7587900e
21 changed files with 1510 additions and 391 deletions

View File

@@ -86,6 +86,9 @@ import {
TOrgRoles,
TOrgRolesInsert,
TOrgRolesUpdate,
TPendingGroupAdditions,
TPendingGroupAdditionsInsert,
TPendingGroupAdditionsUpdate,
TProjectBots,
TProjectBotsInsert,
TProjectBotsUpdate,
@@ -212,6 +215,11 @@ declare module "knex/types/tables" {
interface Tables {
[TableName.Users]: Knex.CompositeTableType<TUsers, TUsersInsert, TUsersUpdate>;
[TableName.Groups]: Knex.CompositeTableType<TGroups, TGroupsInsert, TGroupsUpdate>;
[TableName.PendingGroupAddition]: Knex.CompositeTableType<
TPendingGroupAdditions,
TPendingGroupAdditionsInsert,
TPendingGroupAdditionsUpdate
>;
[TableName.UserGroupMembership]: Knex.CompositeTableType<
TUserGroupMembership,
TUserGroupMembershipInsert,

View File

@@ -0,0 +1,25 @@
import { Knex } from "knex";
import { TableName } from "../schemas";
import { createOnUpdateTrigger, dropOnUpdateTrigger } from "../utils";
export async function up(knex: Knex): Promise<void> {
if (!(await knex.schema.hasTable(TableName.PendingGroupAddition))) {
await knex.schema.createTable(TableName.PendingGroupAddition, (t) => {
t.uuid("id", { primaryKey: true }).defaultTo(knex.fn.uuid());
t.uuid("userId").notNullable();
t.foreign("userId").references("id").inTable(TableName.Users).onDelete("CASCADE");
t.uuid("groupId").notNullable();
t.foreign("groupId").references("id").inTable(TableName.Groups).onDelete("CASCADE");
t.unique(["userId", "groupId"]);
t.timestamps(true, true, true);
});
}
await createOnUpdateTrigger(knex, TableName.PendingGroupAddition);
}
export async function down(knex: Knex): Promise<void> {
await knex.schema.dropTableIfExists(TableName.PendingGroupAddition);
await dropOnUpdateTrigger(knex, TableName.PendingGroupAddition);
}

View File

@@ -27,6 +27,7 @@ export * from "./org-bots";
export * from "./org-memberships";
export * from "./org-roles";
export * from "./organizations";
export * from "./pending-group-additions";
export * from "./project-bots";
export * from "./project-environments";
export * from "./project-keys";

View File

@@ -3,6 +3,7 @@ import { z } from "zod";
export enum TableName {
Users = "users",
Groups = "groups",
PendingGroupAddition = "pending_group_additions",
GroupProjectMembership = "group_project_memberships",
GroupProjectMembershipRole = "group_project_membership_roles",
UserGroupMembership = "user_group_membership",

View File

@@ -0,0 +1,20 @@
// Code generated by automation script, DO NOT EDIT.
// Automated by pulling database and generating zod schema
// To update. Just run npm run generate:schema
// Written by akhilmhdh.
import { z } from "zod";
import { TImmutableDBKeys } from "./models";
export const PendingGroupAdditionsSchema = z.object({
id: z.string().uuid(),
userId: z.string().uuid(),
groupId: z.string().uuid(),
createdAt: z.date(),
updatedAt: z.date()
});
export type TPendingGroupAdditions = z.infer<typeof PendingGroupAdditionsSchema>;
export type TPendingGroupAdditionsInsert = Omit<z.input<typeof PendingGroupAdditionsSchema>, TImmutableDBKeys>;
export type TPendingGroupAdditionsUpdate = Partial<Omit<z.input<typeof PendingGroupAdditionsSchema>, TImmutableDBKeys>>;

View File

@@ -192,6 +192,7 @@ export const registerScimRouter = async (server: FastifyZodProvider) => {
},
onRequest: verifyAuth([AuthMode.SCIM_TOKEN]),
handler: async (req) => {
console.log(`GET /Users/${req.params.userId}`);
const user = await req.server.services.scim.getScimUser({
userId: req.params.userId,
orgId: req.permission.orgId
@@ -246,6 +247,7 @@ export const registerScimRouter = async (server: FastifyZodProvider) => {
},
onRequest: verifyAuth([AuthMode.SCIM_TOKEN]),
handler: async (req) => {
console.log(`POST /Users req.body: `, req.body);
const primaryEmail = req.body.emails?.find((email) => email.primary)?.value;
const user = await req.server.services.scim.createScimUser({
@@ -273,6 +275,7 @@ export const registerScimRouter = async (server: FastifyZodProvider) => {
},
onRequest: verifyAuth([AuthMode.SCIM_TOKEN]),
handler: async (req) => {
console.log(`DELETE /Users/${req.params.userId}`);
const user = await req.server.services.scim.deleteScimUser({
userId: req.params.userId,
orgId: req.permission.orgId
@@ -289,14 +292,28 @@ export const registerScimRouter = async (server: FastifyZodProvider) => {
body: z.object({
schemas: z.array(z.string()),
displayName: z.string().trim(),
members: z.array(z.any()).length(0).optional() // okta-specific
members: z
.array(
z.object({
value: z.string(),
display: z.string()
})
)
.optional() // okta-specific
}),
response: {
200: z.object({
schemas: z.array(z.string()),
id: z.string().trim(),
displayName: z.string().trim(),
members: z.array(z.any()).length(0),
members: z
.array(
z.object({
value: z.string(),
display: z.string()
})
)
.optional(),
meta: z.object({
resourceType: z.string().trim()
})
@@ -305,9 +322,10 @@ export const registerScimRouter = async (server: FastifyZodProvider) => {
},
onRequest: verifyAuth([AuthMode.SCIM_TOKEN]),
handler: async (req) => {
console.log(`POST /Groups req.body: `, req.body);
const group = await req.server.services.scim.createScimGroup({
displayName: req.body.displayName,
orgId: req.permission.orgId
orgId: req.permission.orgId,
...req.body
});
return group;
@@ -345,6 +363,7 @@ export const registerScimRouter = async (server: FastifyZodProvider) => {
},
onRequest: verifyAuth([AuthMode.SCIM_TOKEN]),
handler: async (req) => {
console.log(`GET /Groups req.query: `, req.query);
const groups = await req.server.services.scim.listScimGroups({
orgId: req.permission.orgId,
offset: req.query.startIndex,
@@ -381,6 +400,7 @@ export const registerScimRouter = async (server: FastifyZodProvider) => {
},
onRequest: verifyAuth([AuthMode.SCIM_TOKEN]),
handler: async (req) => {
console.log(`GET /Groups/${req.params.groupId}`);
const group = await req.server.services.scim.getScimGroup({
groupId: req.params.groupId,
orgId: req.permission.orgId
@@ -400,7 +420,12 @@ export const registerScimRouter = async (server: FastifyZodProvider) => {
schemas: z.array(z.string()),
id: z.string().trim(),
displayName: z.string().trim(),
members: z.array(z.any()).length(0)
members: z.array(
z.object({
value: z.string(), // infisical userId
display: z.string()
})
) // note: is this where members are added to group?
}),
response: {
200: z.object({
@@ -421,10 +446,11 @@ export const registerScimRouter = async (server: FastifyZodProvider) => {
},
onRequest: verifyAuth([AuthMode.SCIM_TOKEN]),
handler: async (req) => {
console.log(`PUT /Groups/${req.params.groupId} req.body: `, req.body);
const group = await req.server.services.scim.updateScimGroupNamePut({
groupId: req.params.groupId,
orgId: req.permission.orgId,
displayName: req.body.displayName
...req.body
});
return group;
@@ -482,8 +508,7 @@ export const registerScimRouter = async (server: FastifyZodProvider) => {
},
onRequest: verifyAuth([AuthMode.SCIM_TOKEN]),
handler: async (req) => {
// console.log("PATCH /Groups/:groupId req.body: ", req.body);
// console.log("PATCH /Groups/:groupId req.body: ", req.body.Operations[0]);
console.log(`PATCH /Groups/:${req.params.groupId} req.body: `, req.body);
const group = await req.server.services.scim.updateScimGroupNamePatch({
groupId: req.params.groupId,
orgId: req.permission.orgId,
@@ -507,6 +532,7 @@ export const registerScimRouter = async (server: FastifyZodProvider) => {
},
onRequest: verifyAuth([AuthMode.SCIM_TOKEN]),
handler: async (req) => {
console.log(`DELETE /Groups/:${req.params.groupId}`);
const group = await req.server.services.scim.deleteScimGroup({
groupId: req.params.groupId,
orgId: req.permission.orgId
@@ -557,6 +583,7 @@ export const registerScimRouter = async (server: FastifyZodProvider) => {
},
onRequest: verifyAuth([AuthMode.SCIM_TOKEN]),
handler: async (req) => {
console.log(`PUT /Users/:${req.params.userId} req.body: `, req.body);
const user = await req.server.services.scim.replaceScimUser({
userId: req.params.userId,
orgId: req.permission.orgId,

View File

@@ -59,13 +59,13 @@ export const groupDALFactory = (db: TDbClient) => {
}
};
const countAllGroupMembers = async ({ orgId, groupId }: { orgId: string; groupId: string }) => {
const countGroupMembers = async ({ orgId, groupId }: { orgId: string; groupId: string }) => {
try {
interface CountResult {
count: string;
}
const doc = await db<CountResult>(TableName.OrgMembership)
const directCount = await db<CountResult>(TableName.OrgMembership)
.where(`${TableName.OrgMembership}.orgId`, orgId)
.join(TableName.Users, `${TableName.OrgMembership}.userId`, `${TableName.Users}.id`)
.leftJoin(TableName.UserGroupMembership, function () {
@@ -75,13 +75,18 @@ export const groupDALFactory = (db: TDbClient) => {
db.raw("?", [groupId])
);
})
.where({ isGhost: false })
.where({ isGhost: false, isAccepted: true })
.count(`${TableName.Users}.id`)
.first();
return parseInt((doc?.count as string) || "0", 10);
const pendingCount = await db<CountResult>(TableName.PendingGroupAddition)
.where(`${TableName.PendingGroupAddition}.groupId`, groupId)
.count("*")
.first();
return parseInt((directCount?.count as string) || "0", 10) + parseInt((pendingCount?.count as string) || "0", 10);
} catch (err) {
throw new DatabaseError({ error: err, name: "Count all group members" });
throw new DatabaseError({ error: err, name: "Count all direct group members" });
}
};
@@ -110,14 +115,36 @@ export const groupDALFactory = (db: TDbClient) => {
db.raw("?", [groupId])
);
})
.select(
.leftJoin(TableName.PendingGroupAddition, function () {
this.on(`${TableName.PendingGroupAddition}.userId`, "=", `${TableName.Users}.id`).andOn(
`${TableName.PendingGroupAddition}.groupId`,
"=",
db.raw("?", [groupId])
);
})
.select<
{
id: string;
groupId: string;
email: string;
username: string;
firstName: string;
lastName: string;
userId: string;
isPartOfGroup: boolean;
}[]
>(
db.ref("id").withSchema(TableName.OrgMembership),
db.ref("groupId").withSchema(TableName.UserGroupMembership),
db.ref("email").withSchema(TableName.Users),
db.ref("username").withSchema(TableName.Users),
db.ref("firstName").withSchema(TableName.Users),
db.ref("lastName").withSchema(TableName.Users),
db.ref("id").withSchema(TableName.Users).as("userId")
db.ref("id").withSchema(TableName.Users).as("userId"),
db.raw('CASE WHEN ?? IS NOT NULL OR ?? IS NOT NULL THEN TRUE ELSE FALSE END AS "isPartOfGroup"', [
`${TableName.UserGroupMembership}.groupId`,
`${TableName.PendingGroupAddition}.groupId`
])
)
.where({ isGhost: false })
.offset(offset);
@@ -132,16 +159,15 @@ export const groupDALFactory = (db: TDbClient) => {
const members = await query;
return members.map(
({ email, username: memberUsername, firstName, lastName, userId, groupId: memberGroupId }) => ({
id: userId,
email,
username: memberUsername,
firstName,
lastName,
isPartOfGroup: !!memberGroupId
})
);
return members.map(({ email, username: memberUsername, firstName, lastName, userId, isPartOfGroup }) => ({
// TODO: fix type
id: userId,
email,
username: memberUsername,
firstName,
lastName,
isPartOfGroup
}));
} catch (error) {
throw new DatabaseError({ error, name: "Find all org members" });
}
@@ -150,7 +176,7 @@ export const groupDALFactory = (db: TDbClient) => {
return {
findGroups,
findByOrgId,
countAllGroupMembers,
countGroupMembers,
findAllGroupMembers,
...groupOrm
};

View File

@@ -0,0 +1,701 @@
import { Knex } from "knex";
import { SecretKeyEncoding, TUsers } from "@app/db/schemas";
import { decryptAsymmetric, encryptAsymmetric, infisicalSymmetricDecrypt } from "@app/lib/crypto/encryption";
import { BadRequestError, ScimRequestError } from "@app/lib/errors";
import {
TAddUsersToGroupByUserIds,
TAddUsersToGroupDirectly,
TAddUsersToPendingGroupAdditions,
TConvertPendingGroupAdditionsToGroupMemberships,
TRemoveUsersFromGroupByUserIds,
TRemoveUsersFromGroupDirectly,
TRemoveUsersFromPendingGroupAdditions
} from "./group-types";
// TODO: write docstrings
/**
* Add users with usernames [usernames] to group [group]
* @param {group} group - group to add user to
* @param {string[]} usernames - username(s) of user(s) to add to group
* @returns {Promise<TUsers>} - user that was added to group
*/
export const addUsersToGroupDirectly = async ({
group,
usernames,
userDAL,
userGroupMembershipDAL,
orgDAL,
groupProjectDAL,
projectKeyDAL,
projectDAL,
projectBotDAL,
tx: outerTx
}: TAddUsersToGroupDirectly) => {
const processAddition = async (tx: Knex) => {
const users = await userDAL.findUserEncKeyByUsernameBatch(
{
usernames
},
tx
);
const usersUsernamesSet = new Set(users.map((u) => u.username));
usernames.forEach((username) => {
if (!usersUsernamesSet.has(username)) {
throw new BadRequestError({
message: `Failed to find user with username ${username}`
});
}
});
const userIds = users.map((u) => {
if (!u.isAccepted) {
throw new BadRequestError({
message: `User ${u.username} cannot be added to group because they have not confirmed their account`
});
}
return u.userId;
});
// check if user(s) group membership(s) already exists
const existingUserGroupMemberships = await userGroupMembershipDAL.find(
{
groupId: group.id,
$in: {
userId: userIds
}
},
{ tx }
);
if (existingUserGroupMemberships.length) {
throw new BadRequestError({
message: `User(s) are already part of the group ${group.slug}`
});
}
// check if all user(s) are part of the organization
const existingUserOrgMemberships = await orgDAL.findMembership(
{
orgId: group.orgId,
$in: {
userId: userIds
}
},
{ tx }
);
const existingUserOrgMembershipsUsernamesSet = new Set(existingUserOrgMemberships.map((u) => u.username));
usernames.forEach((username) => {
if (!existingUserOrgMembershipsUsernamesSet.has(username))
throw new BadRequestError({
message: `User ${username} is not part of the organization`
});
});
await userGroupMembershipDAL.insertMany(
userIds.map((userId) => ({
userId,
groupId: group.id
})),
tx
);
// check which projects the group is part of
const projectIds = Array.from(
new Set(
(
await groupProjectDAL.find(
{
groupId: group.id
},
{ tx }
)
).map((gp) => gp.projectId)
)
);
const keys = await projectKeyDAL.find(
{
$in: {
projectId: projectIds,
receiverId: userIds
}
},
{ tx }
);
const userKeysSet = new Set(keys.map((k) => `${k.projectId}-${k.receiverId}`));
for await (const projectId of projectIds) {
const usersToAddProjectKeyFor = users.filter((u) => !userKeysSet.has(`${projectId}-${u.userId}`));
if (usersToAddProjectKeyFor.length) {
// there are users who need to be shared keys
// process adding bulk users to projects for each project individually
const ghostUser = await projectDAL.findProjectGhostUser(projectId, tx);
if (!ghostUser) {
throw new BadRequestError({
message: "Failed to find sudo user"
});
}
const ghostUserLatestKey = await projectKeyDAL.findLatestProjectKey(ghostUser.id, projectId, tx);
if (!ghostUserLatestKey) {
throw new BadRequestError({
message: "Failed to find sudo user latest key"
});
}
const bot = await projectBotDAL.findOne({ projectId }, tx);
if (!bot) {
throw new BadRequestError({
message: "Failed to find bot"
});
}
const botPrivateKey = infisicalSymmetricDecrypt({
keyEncoding: bot.keyEncoding as SecretKeyEncoding,
iv: bot.iv,
tag: bot.tag,
ciphertext: bot.encryptedPrivateKey
});
const plaintextProjectKey = decryptAsymmetric({
ciphertext: ghostUserLatestKey.encryptedKey,
nonce: ghostUserLatestKey.nonce,
publicKey: ghostUserLatestKey.sender.publicKey,
privateKey: botPrivateKey
});
const projectKeysToAdd = usersToAddProjectKeyFor.map((user) => {
const { ciphertext: encryptedKey, nonce } = encryptAsymmetric(
plaintextProjectKey,
user.publicKey,
botPrivateKey
);
return {
encryptedKey,
nonce,
senderId: ghostUser.id,
receiverId: user.userId,
projectId
};
});
await projectKeyDAL.insertMany(projectKeysToAdd, tx);
}
}
return users;
};
if (outerTx) {
return processAddition(outerTx);
}
return userDAL.transaction(async (tx) => {
return processAddition(tx);
});
};
export const addUsersToPendingGroupAdditions = async ({
group,
userIds,
pendingGroupAdditionDAL,
userDAL,
orgDAL,
tx: outerTx
}: TAddUsersToPendingGroupAdditions) => {
const processAddition = async (tx: Knex) => {
const users = await userDAL.find(
{
$in: {
id: userIds
}
},
{ tx }
);
const usersUserIdsSet = new Set(users.map((u) => u.id));
userIds.forEach((userId) => {
if (!usersUserIdsSet.has(userId)) {
throw new BadRequestError({
message: `Failed to find user with id ${userId}`
});
}
});
users.map((u) => {
if (u.isAccepted) {
throw new BadRequestError({
message: `User ${u.username} cannot be added to a pending group addition because they have confirmed their account`
});
}
return u.id;
});
// check if user(s) pending group addition(s) already exist
const existingPendingGroupAdditions = await pendingGroupAdditionDAL.find(
{
groupId: group.id,
$in: {
userId: userIds
}
},
{ tx }
);
if (existingPendingGroupAdditions.length) {
throw new BadRequestError({
message: `User(s) are already part of the group ${group.slug}`
});
}
// check if all user(s) are part of the organization
const existingUserOrgMemberships = await orgDAL.findMembership(
{
orgId: group.orgId,
$in: {
userId: userIds
}
},
{ tx }
);
const existingUserOrgMembershipsUserIdsSet = new Set(existingUserOrgMemberships.map((u) => u.userId));
userIds.forEach((userId) => {
if (!existingUserOrgMembershipsUserIdsSet.has(userId))
throw new BadRequestError({
message: `User with id ${userId} is not part of the organization`
});
});
await pendingGroupAdditionDAL.insertMany(
users.map((user) => ({
userId: user.id,
groupId: group.id
})),
tx
);
return users;
};
if (outerTx) {
return processAddition(outerTx);
}
return userDAL.transaction(async (tx) => {
return processAddition(tx);
});
};
export const addUsersToGroupByUserIds = async ({
group,
userIds,
userDAL,
userGroupMembershipDAL,
orgDAL,
groupProjectDAL,
pendingGroupAdditionDAL,
projectKeyDAL,
projectDAL,
projectBotDAL,
tx: outerTx
}: TAddUsersToGroupByUserIds) => {
const processAddition = async (tx: Knex) => {
const foundMembers = await userDAL.find({
$in: {
id: userIds
}
});
const foundMembersIdsSet = new Set(foundMembers.map((member) => member.id));
const isCompleteMatch = userIds.every((userId) => foundMembersIdsSet.has(userId));
if (!isCompleteMatch) {
throw new ScimRequestError({
detail: "Members not found",
status: 404
});
}
const membersToAddToGroupDirectly: TUsers[] = [];
const membersToAddToGroupPending: TUsers[] = [];
foundMembers.forEach((member) => {
if (member.isAccepted) {
// add accepted member to group
membersToAddToGroupDirectly.push(member);
} else {
// add incomplete member to pending group addition
membersToAddToGroupPending.push(member);
}
});
let addedUsers: TUsers[] = [];
if (membersToAddToGroupDirectly.length) {
addedUsers = addedUsers.concat(
await addUsersToGroupDirectly({
group,
usernames: membersToAddToGroupDirectly.map((member) => member.username),
userDAL,
userGroupMembershipDAL,
orgDAL,
groupProjectDAL,
projectKeyDAL,
projectDAL,
projectBotDAL,
tx
})
);
}
if (membersToAddToGroupPending.length) {
addedUsers = addedUsers.concat(
await addUsersToPendingGroupAdditions({
group,
userIds: membersToAddToGroupPending.map((member) => member.id),
pendingGroupAdditionDAL,
userDAL,
orgDAL,
tx
})
);
}
return addedUsers;
};
if (outerTx) {
return processAddition(outerTx);
}
return userDAL.transaction(async (tx) => {
return processAddition(tx);
});
};
export const removeUsersFromGroupDirectly = async ({
group,
userIds,
userDAL,
userGroupMembershipDAL,
groupProjectDAL,
projectKeyDAL,
tx: outerTx
}: TRemoveUsersFromGroupDirectly) => {
const processRemoval = async (tx: Knex) => {
const users = await userDAL.find(
{
$in: {
id: userIds
}
},
{ tx }
);
const usersUserIdsSet = new Set(users.map((u) => u.id));
userIds.forEach((userId) => {
if (!usersUserIdsSet.has(userId)) {
throw new BadRequestError({
message: `Failed to find user with id ${userId}`
});
}
});
// check if user group membership already exists
const existingUserGroupMemberships = await userGroupMembershipDAL.find(
{
groupId: group.id,
$in: {
userId: userIds
}
},
{ tx }
);
const existingUserGroupMembershipsUserIdsSet = new Set(existingUserGroupMemberships.map((u) => u.userId));
userIds.forEach((userId) => {
if (!existingUserGroupMembershipsUserIdsSet.has(userId))
throw new BadRequestError({
message: `User(s) are not part of the group ${group.slug}`
});
});
// check which projects the group is part of
const projectIds = Array.from(
new Set(
(
await groupProjectDAL.find(
{
groupId: group.id
},
{ tx }
)
).map((gp) => gp.projectId)
)
);
// TODO: this part can be optimized
for await (const userId of userIds) {
const t = await userGroupMembershipDAL.filterProjectsByUserMembership(userId, group.id, projectIds, tx);
const projectsToDeleteKeyFor = projectIds.filter((p) => !t.has(p));
if (projectsToDeleteKeyFor.length) {
await projectKeyDAL.delete(
{
receiverId: userId,
$in: {
projectId: projectsToDeleteKeyFor
}
},
tx
);
}
await userGroupMembershipDAL.delete(
{
groupId: group.id,
userId
},
tx
);
}
return users;
};
if (outerTx) {
return processRemoval(outerTx);
}
return userDAL.transaction(async (tx) => {
return processRemoval(tx);
});
};
export const removeUsersFromPendingGroupAdditions = async ({
group,
userIds,
userDAL,
pendingGroupAdditionDAL,
tx: outerTx
}: TRemoveUsersFromPendingGroupAdditions) => {
const processRemoval = async (tx: Knex) => {
const users = await userDAL.find(
{
$in: {
id: userIds
}
},
{ tx }
);
const usersUserIdsSet = new Set(users.map((u) => u.id));
userIds.forEach((userId) => {
if (!usersUserIdsSet.has(userId)) {
throw new BadRequestError({
message: `Failed to find user with id ${userId}`
});
}
});
// check if user pending group addition already exists
const existingPendingGroupAdditions = await pendingGroupAdditionDAL.find(
{
groupId: group.id,
$in: {
userId: userIds
}
},
{ tx }
);
const existingPendingGroupAdditionsUserIdsSet = new Set(existingPendingGroupAdditions.map((u) => u.userId));
userIds.forEach((userId) => {
if (!existingPendingGroupAdditionsUserIdsSet.has(userId))
throw new BadRequestError({
message: `User(s) are not part of the group ${group.slug}`
});
});
await pendingGroupAdditionDAL.delete(
{
groupId: group.id,
$in: {
userId: userIds
}
},
tx
);
return users;
};
if (outerTx) {
return processRemoval(outerTx);
}
return userDAL.transaction(async (tx) => {
return processRemoval(tx);
});
};
export const convertPendingGroupAdditionsToGroupMemberships = async ({
userIds,
userDAL,
pendingGroupAdditionDAL,
userGroupMembershipDAL,
orgDAL,
groupProjectDAL,
projectKeyDAL,
projectDAL,
projectBotDAL,
tx: outerTx
}: TConvertPendingGroupAdditionsToGroupMemberships) => {
const processConversion = async (tx: Knex) => {
const users = await userDAL.find(
{
$in: {
id: userIds
}
},
{ tx }
);
const usersUserIdsSet = new Set(users.map((u) => u.id));
userIds.forEach((userId) => {
if (!usersUserIdsSet.has(userId)) {
throw new BadRequestError({
message: `Failed to find user with id ${userId}`
});
}
});
users.forEach((user) => {
if (!user.isAccepted) {
throw new BadRequestError({
message: `Failed to convert pending group additions to group memberships for user ${user.username} because they have not confirmed their account`
});
}
});
const pendingGroupAdditions = await pendingGroupAdditionDAL.deletePendingGroupAdditionsByUserIds(userIds, tx);
for await (const pendingGroupAddition of pendingGroupAdditions) {
await addUsersToGroupDirectly({
group: pendingGroupAddition.group,
usernames: [pendingGroupAddition.user.username],
userDAL,
userGroupMembershipDAL,
orgDAL,
groupProjectDAL,
projectKeyDAL,
projectDAL,
projectBotDAL,
tx
});
}
};
if (outerTx) {
return processConversion(outerTx);
}
return userDAL.transaction(async (tx) => {
await processConversion(tx);
});
};
export const removeUsersFromGroupByUserIds = async ({
group,
userIds,
userDAL,
userGroupMembershipDAL,
groupProjectDAL,
pendingGroupAdditionDAL,
projectKeyDAL,
tx: outerTx
}: TRemoveUsersFromGroupByUserIds) => {
const processRemoval = async (tx: Knex) => {
const foundMembers = await userDAL.find({
$in: {
id: userIds
}
});
const foundMembersIdsSet = new Set(foundMembers.map((member) => member.id));
const isCompleteMatch = userIds.every((userId) => foundMembersIdsSet.has(userId));
if (!isCompleteMatch) {
throw new ScimRequestError({
detail: "Members not found",
status: 404
});
}
const membersToRemoveFromGroupDirectly: TUsers[] = [];
const membersToRemoveFromGroupPending: TUsers[] = [];
foundMembers.forEach((member) => {
if (member.isAccepted) {
// remove accepted member from group
membersToRemoveFromGroupDirectly.push(member);
} else {
// remove incomplete member from pending group addition
membersToRemoveFromGroupPending.push(member);
}
});
console.log("removeUsersFromGroupByUserIds membersToRemoveFromGroupDirectly: ", membersToRemoveFromGroupDirectly);
console.log("removeUsersFromGroupByUserIds membersToRemoveFromGroupPending: ", membersToRemoveFromGroupPending);
let removedUsers: TUsers[] = [];
if (membersToRemoveFromGroupDirectly.length) {
removedUsers = removedUsers.concat(
await removeUsersFromGroupDirectly({
group,
userIds: membersToRemoveFromGroupDirectly.map((member) => member.id),
userDAL,
userGroupMembershipDAL,
groupProjectDAL,
projectKeyDAL,
tx
})
);
}
if (membersToRemoveFromGroupPending.length) {
removedUsers = removedUsers.concat(
await removeUsersFromPendingGroupAdditions({
group,
userIds: membersToRemoveFromGroupPending.map((member) => member.id),
pendingGroupAdditionDAL,
userDAL,
tx
})
);
}
return removedUsers;
};
if (outerTx) {
return processRemoval(outerTx);
}
return userDAL.transaction(async (tx) => {
return processRemoval(tx);
});
};

View File

@@ -1,22 +1,23 @@
import { ForbiddenError } from "@casl/ability";
import slugify from "@sindresorhus/slugify";
import { OrgMembershipRole, SecretKeyEncoding, TOrgRoles } from "@app/db/schemas";
import { OrgMembershipRole, TOrgRoles } from "@app/db/schemas";
import { TPendingGroupAdditionDALFactory } from "@app/ee/services/group/pending-group-addition-dal";
import { isAtLeastAsPrivileged } from "@app/lib/casl";
import { decryptAsymmetric, encryptAsymmetric, infisicalSymmetricDecrypt } from "@app/lib/crypto/encryption";
import { BadRequestError, ForbiddenRequestError } from "@app/lib/errors";
import { alphaNumericNanoId } from "@app/lib/nanoid";
import { TGroupProjectDALFactory } from "@app/services/group-project/group-project-dal";
import { TOrgDALFactory } from "@app/services/org/org-dal";
import { TProjectDALFactory } from "@app/services/project/project-dal";
import { TProjectBotDALFactory } from "@app/services/project-bot/project-bot-dal";
import { TProjectKeyDALFactory } from "@app/services/project-key/project-key-dal";
import { TUserDALFactory } from "@app/services/user/user-dal";
import { TGroupProjectDALFactory } from "../../../services/group-project/group-project-dal";
import { TOrgDALFactory } from "../../../services/org/org-dal";
import { TProjectDALFactory } from "../../../services/project/project-dal";
import { TProjectBotDALFactory } from "../../../services/project-bot/project-bot-dal";
import { TProjectKeyDALFactory } from "../../../services/project-key/project-key-dal";
import { TUserDALFactory } from "../../../services/user/user-dal";
import { TLicenseServiceFactory } from "../license/license-service";
import { OrgPermissionActions, OrgPermissionSubjects } from "../permission/org-permission";
import { TPermissionServiceFactory } from "../permission/permission-service";
import { TGroupDALFactory } from "./group-dal";
import { addUsersToGroupByUserIds, removeUsersFromGroupByUserIds } from "./group-fns";
import {
TAddUserToGroupDTO,
TCreateGroupDTO,
@@ -28,20 +29,21 @@ import {
import { TUserGroupMembershipDALFactory } from "./user-group-membership-dal";
type TGroupServiceFactoryDep = {
userDAL: Pick<TUserDALFactory, "findOne" | "findUserEncKeyByUsername">;
userDAL: Pick<TUserDALFactory, "find" | "findUserEncKeyByUsernameBatch" | "transaction" | "findOne">;
groupDAL: Pick<
TGroupDALFactory,
"create" | "findOne" | "update" | "delete" | "findAllGroupMembers" | "countAllGroupMembers"
"create" | "findOne" | "update" | "delete" | "findAllGroupMembers" | "countGroupMembers"
>;
groupProjectDAL: Pick<TGroupProjectDALFactory, "find">;
orgDAL: Pick<TOrgDALFactory, "findMembership">;
userGroupMembershipDAL: Pick<
TUserGroupMembershipDALFactory,
"findOne" | "create" | "delete" | "filterProjectsByUserMembership"
"findOne" | "delete" | "filterProjectsByUserMembership" | "transaction" | "insertMany" | "find"
>;
projectDAL: Pick<TProjectDALFactory, "findProjectGhostUser">;
projectBotDAL: Pick<TProjectBotDALFactory, "findOne">;
projectKeyDAL: Pick<TProjectKeyDALFactory, "find" | "create" | "delete" | "findLatestProjectKey">;
projectKeyDAL: Pick<TProjectKeyDALFactory, "find" | "delete" | "findLatestProjectKey" | "insertMany">;
pendingGroupAdditionDAL: TPendingGroupAdditionDALFactory; // remove?
permissionService: Pick<TPermissionServiceFactory, "getOrgPermission" | "getOrgPermissionByRole">;
licenseService: Pick<TLicenseServiceFactory, "getPlan">;
};
@@ -57,6 +59,7 @@ export const groupServiceFactory = ({
projectDAL,
projectBotDAL,
projectKeyDAL,
pendingGroupAdditionDAL,
permissionService,
licenseService
}: TGroupServiceFactoryDep) => {
@@ -227,7 +230,7 @@ export const groupServiceFactory = ({
username
});
const totalCount = await groupDAL.countAllGroupMembers({
const totalCount = await groupDAL.countGroupMembers({
orgId: group.orgId,
groupId: group.id
});
@@ -272,111 +275,23 @@ export const groupServiceFactory = ({
if (!hasRequiredPriviledges)
throw new ForbiddenRequestError({ message: "Failed to add user to more privileged group" });
// get user with username
const user = await userDAL.findUserEncKeyByUsername({
username
const user = await userDAL.findOne({ username });
if (!user) throw new BadRequestError({ message: `Failed to find user with username ${username}` });
const users = await addUsersToGroupByUserIds({
group,
userIds: [user.id],
userDAL,
userGroupMembershipDAL,
orgDAL,
groupProjectDAL,
pendingGroupAdditionDAL,
projectKeyDAL,
projectDAL,
projectBotDAL
});
if (!user)
throw new BadRequestError({
message: `Failed to find user with username ${username}`
});
// check if user group membership already exists
const existingUserGroupMembership = await userGroupMembershipDAL.findOne({
groupId: group.id,
userId: user.userId
});
if (existingUserGroupMembership)
throw new BadRequestError({
message: `User ${username} is already part of the group ${groupSlug}`
});
// check if user is even part of the organization
const existingUserOrgMembership = await orgDAL.findMembership({
userId: user.userId,
orgId: actorOrgId
});
if (!existingUserOrgMembership)
throw new BadRequestError({
message: `User ${username} is not part of the organization`
});
await userGroupMembershipDAL.create({
userId: user.userId,
groupId: group.id
});
// check which projects the group is part of
const projectIds = (
await groupProjectDAL.find({
groupId: group.id
})
).map((gp) => gp.projectId);
const keys = await projectKeyDAL.find({
receiverId: user.userId,
$in: {
projectId: projectIds
}
});
const keysSet = new Set(keys.map((k) => k.projectId));
const projectsToAddKeyFor = projectIds.filter((p) => !keysSet.has(p));
for await (const projectId of projectsToAddKeyFor) {
const ghostUser = await projectDAL.findProjectGhostUser(projectId);
if (!ghostUser) {
throw new BadRequestError({
message: "Failed to find sudo user"
});
}
const ghostUserLatestKey = await projectKeyDAL.findLatestProjectKey(ghostUser.id, projectId);
if (!ghostUserLatestKey) {
throw new BadRequestError({
message: "Failed to find sudo user latest key"
});
}
const bot = await projectBotDAL.findOne({ projectId });
if (!bot) {
throw new BadRequestError({
message: "Failed to find bot"
});
}
const botPrivateKey = infisicalSymmetricDecrypt({
keyEncoding: bot.keyEncoding as SecretKeyEncoding,
iv: bot.iv,
tag: bot.tag,
ciphertext: bot.encryptedPrivateKey
});
const plaintextProjectKey = decryptAsymmetric({
ciphertext: ghostUserLatestKey.encryptedKey,
nonce: ghostUserLatestKey.nonce,
publicKey: ghostUserLatestKey.sender.publicKey,
privateKey: botPrivateKey
});
const { ciphertext: encryptedKey, nonce } = encryptAsymmetric(plaintextProjectKey, user.publicKey, botPrivateKey);
await projectKeyDAL.create({
encryptedKey,
nonce,
senderId: ghostUser.id,
receiverId: user.userId,
projectId
});
}
return user;
return users[0];
};
const removeUserFromGroup = async ({
@@ -416,51 +331,20 @@ export const groupServiceFactory = ({
if (!hasRequiredPriviledges)
throw new ForbiddenRequestError({ message: "Failed to delete user from more privileged group" });
const user = await userDAL.findOne({
username
const user = await userDAL.findOne({ username });
if (!user) throw new BadRequestError({ message: `Failed to find user with username ${username}` });
const users = await removeUsersFromGroupByUserIds({
group,
userIds: [user.id],
userDAL,
userGroupMembershipDAL,
pendingGroupAdditionDAL,
groupProjectDAL,
projectKeyDAL
});
if (!user)
throw new BadRequestError({
message: `Failed to find user with username ${username}`
});
// check if user group membership already exists
const existingUserGroupMembership = await userGroupMembershipDAL.findOne({
groupId: group.id,
userId: user.id
});
if (!existingUserGroupMembership)
throw new BadRequestError({
message: `User ${username} is not part of the group ${groupSlug}`
});
const projectIds = (
await groupProjectDAL.find({
groupId: group.id
})
).map((gp) => gp.projectId);
const t = await userGroupMembershipDAL.filterProjectsByUserMembership(user.id, group.id, projectIds);
const projectsToDeleteKeyFor = projectIds.filter((p) => !t.has(p));
if (projectsToDeleteKeyFor.length) {
await projectKeyDAL.delete({
receiverId: user.id,
$in: {
projectId: projectsToDeleteKeyFor
}
});
}
await userGroupMembershipDAL.delete({
groupId: group.id,
userId: user.id
});
return user;
return users[0];
};
return {

View File

@@ -1,4 +1,15 @@
import { Knex } from "knex";
import { TGroups } from "@app/db/schemas";
import { TPendingGroupAdditionDALFactory } from "@app/ee/services/group/pending-group-addition-dal";
import { TUserGroupMembershipDALFactory } from "@app/ee/services/group/user-group-membership-dal";
import { TGenericPermission } from "@app/lib/types";
import { TGroupProjectDALFactory } from "@app/services/group-project/group-project-dal";
import { TOrgDALFactory } from "@app/services/org/org-dal";
import { TProjectDALFactory } from "@app/services/project/project-dal";
import { TProjectBotDALFactory } from "@app/services/project-bot/project-bot-dal";
import { TProjectKeyDALFactory } from "@app/services/project-key/project-key-dal";
import { TUserDALFactory } from "@app/services/user/user-dal";
export type TCreateGroupDTO = {
name: string;
@@ -35,3 +46,83 @@ export type TRemoveUserFromGroupDTO = {
groupSlug: string;
username: string;
} & TGenericPermission;
// group fns types
export type TAddUsersToGroupByUserIds = {
group: TGroups;
userIds: string[];
userDAL: Pick<TUserDALFactory, "find" | "findUserEncKeyByUsernameBatch" | "transaction">;
userGroupMembershipDAL: Pick<TUserGroupMembershipDALFactory, "find" | "transaction" | "insertMany">;
orgDAL: Pick<TOrgDALFactory, "findMembership">;
groupProjectDAL: Pick<TGroupProjectDALFactory, "find">;
pendingGroupAdditionDAL: Pick<TPendingGroupAdditionDALFactory, "insertMany" | "find">;
projectKeyDAL: Pick<TProjectKeyDALFactory, "find" | "findLatestProjectKey" | "insertMany">;
projectDAL: Pick<TProjectDALFactory, "findProjectGhostUser">;
projectBotDAL: Pick<TProjectBotDALFactory, "findOne">;
tx?: Knex;
};
export type TAddUsersToGroupDirectly = {
group: TGroups;
usernames: string[];
userDAL: Pick<TUserDALFactory, "findUserEncKeyByUsernameBatch" | "transaction">;
userGroupMembershipDAL: Pick<TUserGroupMembershipDALFactory, "find" | "transaction" | "insertMany">;
orgDAL: Pick<TOrgDALFactory, "findMembership">;
groupProjectDAL: Pick<TGroupProjectDALFactory, "find">;
projectKeyDAL: Pick<TProjectKeyDALFactory, "find" | "findLatestProjectKey" | "insertMany">;
projectDAL: Pick<TProjectDALFactory, "findProjectGhostUser">;
projectBotDAL: Pick<TProjectBotDALFactory, "findOne">;
tx?: Knex;
};
export type TAddUsersToPendingGroupAdditions = {
userIds: string[];
group: TGroups;
pendingGroupAdditionDAL: Pick<TPendingGroupAdditionDALFactory, "find" | "insertMany">;
userDAL: Pick<TUserDALFactory, "find" | "transaction">;
orgDAL: Pick<TOrgDALFactory, "findMembership">;
tx?: Knex;
};
export type TRemoveUsersFromGroupByUserIds = {
group: TGroups;
userIds: string[];
userDAL: Pick<TUserDALFactory, "find" | "transaction">;
userGroupMembershipDAL: Pick<TUserGroupMembershipDALFactory, "find" | "filterProjectsByUserMembership" | "delete">;
pendingGroupAdditionDAL: Pick<TPendingGroupAdditionDALFactory, "find" | "delete">;
groupProjectDAL: Pick<TGroupProjectDALFactory, "find">;
projectKeyDAL: Pick<TProjectKeyDALFactory, "delete">;
tx?: Knex;
};
export type TRemoveUsersFromGroupDirectly = {
group: TGroups;
userIds: string[];
userDAL: Pick<TUserDALFactory, "find" | "transaction">;
userGroupMembershipDAL: Pick<TUserGroupMembershipDALFactory, "find" | "filterProjectsByUserMembership" | "delete">;
groupProjectDAL: Pick<TGroupProjectDALFactory, "find">;
projectKeyDAL: Pick<TProjectKeyDALFactory, "delete">;
tx?: Knex;
};
export type TRemoveUsersFromPendingGroupAdditions = {
group: TGroups;
userIds: string[];
pendingGroupAdditionDAL: Pick<TPendingGroupAdditionDALFactory, "find" | "delete">;
userDAL: Pick<TUserDALFactory, "find" | "transaction">;
tx?: Knex;
};
export type TConvertPendingGroupAdditionsToGroupMemberships = {
userIds: string[];
pendingGroupAdditionDAL: Pick<TPendingGroupAdditionDALFactory, "deletePendingGroupAdditionsByUserIds">;
userDAL: Pick<TUserDALFactory, "findUserEncKeyByUsernameBatch" | "transaction" | "find" | "findById">;
userGroupMembershipDAL: Pick<TUserGroupMembershipDALFactory, "find" | "transaction" | "insertMany">;
orgDAL: Pick<TOrgDALFactory, "findMembership">;
groupProjectDAL: Pick<TGroupProjectDALFactory, "find">;
projectKeyDAL: Pick<TProjectKeyDALFactory, "find" | "findLatestProjectKey" | "insertMany">;
projectDAL: Pick<TProjectDALFactory, "findProjectGhostUser">;
projectBotDAL: Pick<TProjectBotDALFactory, "findOne">;
tx?: Knex;
};

View File

@@ -0,0 +1,55 @@
import { Knex } from "knex";
import { TDbClient } from "@app/db";
import { TableName } from "@app/db/schemas";
import { DatabaseError } from "@app/lib/errors";
import { ormify } from "@app/lib/knex";
export type TPendingGroupAdditionDALFactory = ReturnType<typeof pendingGroupAdditionDALFactory>;
export const pendingGroupAdditionDALFactory = (db: TDbClient) => {
const pendingGroupAdditionOrm = ormify(db, TableName.PendingGroupAddition);
// special query
const deletePendingGroupAdditionsByUserIds = async (userIds: string[], tx?: Knex) => {
try {
const pendingGroupAdditions = await (tx || db)(TableName.PendingGroupAddition)
.whereIn(`${TableName.PendingGroupAddition}.userId`, userIds)
.join(TableName.Groups, `${TableName.PendingGroupAddition}.groupId`, `${TableName.Groups}.id`)
.join(TableName.Users, `${TableName.PendingGroupAddition}.userId`, `${TableName.Users}.id`);
await pendingGroupAdditionOrm.delete(
{
$in: {
userId: userIds
}
},
tx
);
return pendingGroupAdditions.map(({ userId, username, groupId, orgId, name, slug, role, roleId }) => ({
user: {
id: userId,
username
},
group: {
id: groupId,
orgId,
name,
slug,
role,
roleId,
createdAt: new Date(),
updatedAt: new Date()
}
}));
} catch (error) {
throw new DatabaseError({ error, name: "Filter projects by user membership" });
}
};
return {
...pendingGroupAdditionOrm,
deletePendingGroupAdditionsByUserIds
};
};

View File

@@ -1,3 +1,5 @@
import { Knex } from "knex";
import { TDbClient } from "@app/db";
import { TableName, TUserEncryptionKeys } from "@app/db/schemas";
import { DatabaseError } from "@app/lib/errors";
@@ -14,24 +16,28 @@ export const userGroupMembershipDALFactory = (db: TDbClient) => {
* - The user is a member of a group that is a member of the project, excluding projects that they are part of
* through the group with id [groupId].
*/
const filterProjectsByUserMembership = async (userId: string, groupId: string, projectIds: string[]) => {
const userProjectMemberships: string[] = await db(TableName.ProjectMembership)
.where(`${TableName.ProjectMembership}.userId`, userId)
.whereIn(`${TableName.ProjectMembership}.projectId`, projectIds)
.pluck(`${TableName.ProjectMembership}.projectId`);
const filterProjectsByUserMembership = async (userId: string, groupId: string, projectIds: string[], tx?: Knex) => {
try {
const userProjectMemberships: string[] = await (tx || db)(TableName.ProjectMembership)
.where(`${TableName.ProjectMembership}.userId`, userId)
.whereIn(`${TableName.ProjectMembership}.projectId`, projectIds)
.pluck(`${TableName.ProjectMembership}.projectId`);
const userGroupMemberships: string[] = await db(TableName.UserGroupMembership)
.where(`${TableName.UserGroupMembership}.userId`, userId)
.whereNot(`${TableName.UserGroupMembership}.groupId`, groupId)
.join(
TableName.GroupProjectMembership,
`${TableName.UserGroupMembership}.groupId`,
`${TableName.GroupProjectMembership}.groupId`
)
.whereIn(`${TableName.GroupProjectMembership}.projectId`, projectIds)
.pluck(`${TableName.GroupProjectMembership}.projectId`);
const userGroupMemberships: string[] = await (tx || db)(TableName.UserGroupMembership)
.where(`${TableName.UserGroupMembership}.userId`, userId)
.whereNot(`${TableName.UserGroupMembership}.groupId`, groupId)
.join(
TableName.GroupProjectMembership,
`${TableName.UserGroupMembership}.groupId`,
`${TableName.GroupProjectMembership}.groupId`
)
.whereIn(`${TableName.GroupProjectMembership}.projectId`, projectIds)
.pluck(`${TableName.GroupProjectMembership}.projectId`);
return new Set(userProjectMemberships.concat(userGroupMemberships));
return new Set(userProjectMemberships.concat(userGroupMemberships));
} catch (error) {
throw new DatabaseError({ error, name: "Filter projects by user membership" });
}
};
// special query
@@ -45,7 +51,7 @@ export const userGroupMembershipDALFactory = (db: TDbClient) => {
)
.join(TableName.Users, `${TableName.UserGroupMembership}.userId`, `${TableName.Users}.id`)
.where(`${TableName.GroupProjectMembership}.projectId`, projectId)
.whereIn(`${TableName.Users}.username`, usernames) // TODO: pluck usernames
.whereIn(`${TableName.Users}.username`, usernames)
.pluck(`${TableName.Users}.id`);
return usernameDocs;

View File

@@ -24,10 +24,10 @@ export const getDefaultOnPremFeatures = (): TFeatureSet => ({
customAlerts: false,
auditLogs: false,
auditLogsRetentionDays: 0,
samlSSO: false,
scim: false,
samlSSO: true,
scim: true,
ldap: false,
groups: false,
groups: true,
status: null,
trial_end: null,
has_used_trial: true,

View File

@@ -40,10 +40,10 @@ export type TFeatureSet = {
customAlerts: false;
auditLogs: false;
auditLogsRetentionDays: 0;
samlSSO: false;
scim: false;
samlSSO: true;
scim: true;
ldap: false;
groups: false;
groups: true;
status: null;
trial_end: null;
has_used_trial: true;

View File

@@ -4,15 +4,21 @@ import jwt from "jsonwebtoken";
import { OrgMembershipRole, OrgMembershipStatus, TableName, TGroups } from "@app/db/schemas";
import { TGroupDALFactory } from "@app/ee/services/group/group-dal";
import { addUsersToGroupByUserIds, removeUsersFromGroupByUserIds } from "@app/ee/services/group/group-fns";
import { TPendingGroupAdditionDALFactory } from "@app/ee/services/group/pending-group-addition-dal";
import { TUserGroupMembershipDALFactory } from "@app/ee/services/group/user-group-membership-dal";
import { TScimDALFactory } from "@app/ee/services/scim/scim-dal";
import { getConfig } from "@app/lib/config/env";
import { BadRequestError, ScimRequestError, UnauthorizedError } from "@app/lib/errors";
import { alphaNumericNanoId } from "@app/lib/nanoid";
import { TOrgPermission } from "@app/lib/types";
import { AuthMethod, AuthTokenType } from "@app/services/auth/auth-type";
import { TGroupProjectDALFactory } from "@app/services/group-project/group-project-dal";
import { TOrgDALFactory } from "@app/services/org/org-dal";
import { deleteOrgMembership } from "@app/services/org/org-fns";
import { TProjectDALFactory } from "@app/services/project/project-dal";
import { TProjectBotDALFactory } from "@app/services/project-bot/project-bot-dal";
import { TProjectKeyDALFactory } from "@app/services/project-key/project-key-dal";
import { TProjectMembershipDALFactory } from "@app/services/project-membership/project-membership-dal";
import { SmtpTemplates, TSmtpService } from "@app/services/smtp/smtp-service";
import { TUserDALFactory } from "@app/services/user/user-dal";
@@ -42,14 +48,22 @@ import {
type TScimServiceFactoryDep = {
scimDAL: Pick<TScimDALFactory, "create" | "find" | "findById" | "deleteById">;
userDAL: Pick<TUserDALFactory, "findOne" | "create" | "transaction">;
userDAL: Pick<TUserDALFactory, "find" | "findOne" | "create" | "transaction" | "findUserEncKeyByUsernameBatch">;
orgDAL: Pick<
TOrgDALFactory,
"createMembership" | "findById" | "findMembership" | "deleteMembershipById" | "transaction"
>;
projectDAL: Pick<TProjectDALFactory, "find">;
projectDAL: Pick<TProjectDALFactory, "find" | "findProjectGhostUser">;
projectMembershipDAL: Pick<TProjectMembershipDALFactory, "find" | "delete">;
groupDAL: Pick<TGroupDALFactory, "create" | "findOne" | "findAllGroupMembers" | "update" | "delete" | "findGroups">;
groupDAL: Pick<
TGroupDALFactory,
"create" | "findOne" | "findAllGroupMembers" | "update" | "delete" | "findGroups" | "transaction"
>;
groupProjectDAL: Pick<TGroupProjectDALFactory, "find">;
userGroupMembershipDAL: TUserGroupMembershipDALFactory; // TODO: Pick
projectKeyDAL: Pick<TProjectKeyDALFactory, "find" | "findLatestProjectKey" | "insertMany" | "delete">;
projectBotDAL: Pick<TProjectBotDALFactory, "findOne">;
pendingGroupAdditionDAL: TPendingGroupAdditionDALFactory; // TODO: Pick
licenseService: Pick<TLicenseServiceFactory, "getPlan">;
permissionService: Pick<TPermissionServiceFactory, "getOrgPermission">;
smtpService: TSmtpService;
@@ -65,6 +79,11 @@ export const scimServiceFactory = ({
projectDAL,
projectMembershipDAL,
groupDAL,
groupProjectDAL,
userGroupMembershipDAL,
projectKeyDAL,
projectBotDAL,
pendingGroupAdditionDAL,
permissionService,
smtpService
}: TScimServiceFactoryDep) => {
@@ -473,7 +492,19 @@ export const scimServiceFactory = ({
};
const listScimGroups = async ({ orgId, offset, limit }: TListScimGroupsDTO) => {
const plan = await licenseService.getPlan(orgId);
if (!plan.groups)
throw new BadRequestError({
message: "Failed to list SCIM groups due to plan restriction. Upgrade plan to list SCIM groups."
});
const org = await orgDAL.findById(orgId);
if (!org) {
throw new ScimRequestError({
detail: "Organization Not Found",
status: 404
});
}
if (!org.scimEnabled)
throw new ScimRequestError({
@@ -500,30 +531,77 @@ export const scimServiceFactory = ({
});
};
const createScimGroup = async ({ displayName, orgId }: TCreateScimGroupDTO) => {
const createScimGroup = async ({ displayName, orgId, members }: TCreateScimGroupDTO) => {
const plan = await licenseService.getPlan(orgId);
if (!plan.groups)
throw new BadRequestError({
message: "Failed to create a SCIM group due to plan restriction. Upgrade plan to create a SCIM group."
});
const org = await orgDAL.findById(orgId);
if (!org) {
throw new ScimRequestError({
detail: "Organization Not Found",
status: 404
});
}
if (!org.scimEnabled)
throw new ScimRequestError({
detail: "SCIM is disabled for the organization",
status: 403
});
const group = await groupDAL.create({
name: displayName,
slug: slugify(`${displayName}-${alphaNumericNanoId(4)}`),
orgId,
role: OrgMembershipRole.NoAccess
const newGroup = await groupDAL.transaction(async (tx) => {
const group = await groupDAL.create(
{
name: displayName,
slug: slugify(`${displayName}-${alphaNumericNanoId(4)}`),
orgId,
role: OrgMembershipRole.NoAccess
},
tx
);
if (members && members.length) {
const newMembers = await addUsersToGroupByUserIds({
group,
userIds: members.map((member) => member.value),
userDAL,
userGroupMembershipDAL,
orgDAL,
groupProjectDAL,
pendingGroupAdditionDAL,
projectKeyDAL,
projectDAL,
projectBotDAL,
tx
});
return { group, newMembers };
}
return { group, newMembers: [] };
});
return buildScimGroup({
groupId: group.id,
name: group.name,
members: []
groupId: newGroup.group.id,
name: newGroup.group.name,
members: newGroup.newMembers.map((member) => ({
value: member.id,
display: `${member.firstName} ${member.lastName}`
}))
});
};
const getScimGroup = async ({ groupId, orgId }: TGetScimGroupDTO) => {
const plan = await licenseService.getPlan(orgId);
if (!plan.groups)
throw new BadRequestError({
message: "Failed to get SCIM group due to plan restriction. Upgrade plan to get SCIM group."
});
const group = await groupDAL.findOne({
id: groupId,
orgId
@@ -536,6 +614,7 @@ export const scimServiceFactory = ({
});
}
// TODO: update to include pending group additions
const users = await groupDAL.findAllGroupMembers({
orgId: group.orgId,
groupId: group.id
@@ -553,35 +632,130 @@ export const scimServiceFactory = ({
});
};
const updateScimGroupNamePut = async ({ groupId, orgId, displayName }: TUpdateScimGroupNamePutDTO) => {
const [group] = await groupDAL.update(
{
id: groupId,
orgId
},
{
name: displayName
}
);
const updateScimGroupNamePut = async ({ groupId, orgId, displayName, members }: TUpdateScimGroupNamePutDTO) => {
console.log("updateScimGroupNamePut args: ", {
groupId,
orgId,
displayName,
members
});
if (!group) {
const plan = await licenseService.getPlan(orgId);
if (!plan.groups)
throw new BadRequestError({
message: "Failed to update SCIM group due to plan restriction. Upgrade plan to update SCIM group."
});
const org = await orgDAL.findById(orgId);
if (!org) {
throw new ScimRequestError({
detail: "Group Not Found",
detail: "Organization Not Found",
status: 404
});
}
if (!org.scimEnabled)
throw new ScimRequestError({
detail: "SCIM is disabled for the organization",
status: 403
});
const updatedGroup = await groupDAL.transaction(async (tx) => {
const [group] = await groupDAL.update(
{
id: groupId,
orgId
},
{
name: displayName
}
);
if (!group) {
throw new ScimRequestError({
detail: "Group Not Found",
status: 404
});
}
if (members) {
const membersIdsSet = new Set(members.map((member) => member.value));
const directMemberUserIds = (
await userGroupMembershipDAL.find({
groupId: group.id
})
).map((membership) => membership.userId);
const pendingGroupAdditionsUserIds = (
await pendingGroupAdditionDAL.find({
groupId: group.id
})
).map((pendingGroupAddition) => pendingGroupAddition.userId);
const allMembersUserIds = directMemberUserIds.concat(pendingGroupAdditionsUserIds);
const allMembersUserIdsSet = new Set(allMembersUserIds);
const toAddUserIds = members.filter((member) => !allMembersUserIdsSet.has(member.value));
const toRemoveUserIds = allMembersUserIds.filter((userId) => !membersIdsSet.has(userId));
if (toAddUserIds.length) {
await addUsersToGroupByUserIds({
group,
userIds: toAddUserIds.map((member) => member.value),
userDAL,
userGroupMembershipDAL,
orgDAL,
groupProjectDAL,
pendingGroupAdditionDAL,
projectKeyDAL,
projectDAL,
projectBotDAL,
tx
});
}
if (toRemoveUserIds.length) {
await removeUsersFromGroupByUserIds({
group,
userIds: toRemoveUserIds,
userDAL,
userGroupMembershipDAL,
groupProjectDAL,
pendingGroupAdditionDAL,
projectKeyDAL,
tx
});
}
}
return group;
});
return buildScimGroup({
groupId: group.id,
name: group.name,
members: []
groupId: updatedGroup.id,
name: updatedGroup.name,
members
});
};
// TODO: add support for add/remove op
const updateScimGroupNamePatch = async ({ groupId, orgId, operations }: TUpdateScimGroupNamePatchDTO) => {
const plan = await licenseService.getPlan(orgId);
if (!plan.groups)
throw new BadRequestError({
message: "Failed to update SCIM group due to plan restriction. Upgrade plan to update SCIM group."
});
const org = await orgDAL.findById(orgId);
if (!org) {
throw new ScimRequestError({
detail: "Organization Not Found",
status: 404
});
}
if (!org.scimEnabled)
throw new ScimRequestError({
detail: "SCIM is disabled for the organization",
@@ -635,6 +809,26 @@ export const scimServiceFactory = ({
};
const deleteScimGroup = async ({ groupId, orgId }: TDeleteScimGroupDTO) => {
const plan = await licenseService.getPlan(orgId);
if (!plan.groups)
throw new BadRequestError({
message: "Failed to delete SCIM group due to plan restriction. Upgrade plan to delete SCIM group."
});
const org = await orgDAL.findById(orgId);
if (!org) {
throw new ScimRequestError({
detail: "Organization Not Found",
status: 404
});
}
if (!org.scimEnabled)
throw new ScimRequestError({
detail: "SCIM is disabled for the organization",
status: 403
});
const [group] = await groupDAL.delete({
id: groupId,
orgId

View File

@@ -81,6 +81,11 @@ export type TListScimGroups = {
export type TCreateScimGroupDTO = {
displayName: string;
orgId: string;
members?: {
// TODO: account for members with value and display (is this optional?)
value: string;
display: string;
}[];
};
export type TGetScimGroupDTO = {
@@ -92,6 +97,10 @@ export type TUpdateScimGroupNamePutDTO = {
groupId: string;
orgId: string;
displayName: string;
members: {
value: string;
display: string;
}[];
};
export type TUpdateScimGroupNamePatchDTO = {

View File

@@ -13,6 +13,7 @@ import { dynamicSecretLeaseQueueServiceFactory } from "@app/ee/services/dynamic-
import { dynamicSecretLeaseServiceFactory } from "@app/ee/services/dynamic-secret-lease/dynamic-secret-lease-service";
import { groupDALFactory } from "@app/ee/services/group/group-dal";
import { groupServiceFactory } from "@app/ee/services/group/group-service";
import { pendingGroupAdditionDALFactory } from "@app/ee/services/group/pending-group-addition-dal";
import { userGroupMembershipDALFactory } from "@app/ee/services/group/user-group-membership-dal";
import { identityProjectAdditionalPrivilegeDALFactory } from "@app/ee/services/identity-project-additional-privilege/identity-project-additional-privilege-dal";
import { identityProjectAdditionalPrivilegeServiceFactory } from "@app/ee/services/identity-project-additional-privilege/identity-project-additional-privilege-service";
@@ -217,6 +218,7 @@ export const registerRoutes = async (
const groupProjectDAL = groupProjectDALFactory(db);
const groupProjectMembershipRoleDAL = groupProjectMembershipRoleDALFactory(db);
const userGroupMembershipDAL = userGroupMembershipDALFactory(db);
const pendingGroupAdditionDAL = pendingGroupAdditionDALFactory(db);
const secretScanningDAL = secretScanningDALFactory(db);
const licenseDAL = licenseDALFactory(db);
const dynamicSecretDAL = dynamicSecretDALFactory(db);
@@ -268,6 +270,7 @@ export const registerRoutes = async (
projectDAL,
projectBotDAL,
projectKeyDAL,
pendingGroupAdditionDAL,
permissionService,
licenseService
});
@@ -290,6 +293,11 @@ export const registerRoutes = async (
projectDAL,
projectMembershipDAL,
groupDAL,
groupProjectDAL,
userGroupMembershipDAL,
projectKeyDAL,
projectBotDAL,
pendingGroupAdditionDAL,
permissionService,
smtpService
});
@@ -344,6 +352,12 @@ export const registerRoutes = async (
smtpService,
authDAL,
userDAL,
pendingGroupAdditionDAL,
userGroupMembershipDAL,
projectKeyDAL,
projectDAL,
projectBotDAL,
groupProjectDAL,
orgDAL,
orgService,
licenseService

View File

@@ -1,10 +1,17 @@
import jwt from "jsonwebtoken";
import { OrgMembershipStatus } from "@app/db/schemas";
import { convertPendingGroupAdditionsToGroupMemberships } from "@app/ee/services/group/group-fns";
import { TPendingGroupAdditionDALFactory } from "@app/ee/services/group/pending-group-addition-dal";
import { TUserGroupMembershipDALFactory } from "@app/ee/services/group/user-group-membership-dal";
import { TLicenseServiceFactory } from "@app/ee/services/license/license-service";
import { getConfig } from "@app/lib/config/env";
import { BadRequestError } from "@app/lib/errors";
import { isDisposableEmail } from "@app/lib/validator";
import { TGroupProjectDALFactory } from "@app/services/group-project/group-project-dal";
import { TProjectDALFactory } from "@app/services/project/project-dal";
import { TProjectBotDALFactory } from "@app/services/project-bot/project-bot-dal";
import { TProjectKeyDALFactory } from "@app/services/project-key/project-key-dal";
import { TAuthTokenServiceFactory } from "../auth-token/auth-token-service";
import { TokenType } from "../auth-token/auth-token-types";
@@ -20,6 +27,12 @@ import { AuthMethod, AuthTokenType } from "./auth-type";
type TAuthSignupDep = {
authDAL: TAuthDALFactory;
userDAL: TUserDALFactory;
pendingGroupAdditionDAL: Pick<TPendingGroupAdditionDALFactory, "deletePendingGroupAdditionsByUserIds">;
userGroupMembershipDAL: Pick<TUserGroupMembershipDALFactory, "find" | "transaction" | "insertMany">;
projectKeyDAL: Pick<TProjectKeyDALFactory, "find" | "findLatestProjectKey" | "insertMany">;
projectDAL: Pick<TProjectDALFactory, "findProjectGhostUser">;
projectBotDAL: Pick<TProjectBotDALFactory, "findOne">;
groupProjectDAL: Pick<TGroupProjectDALFactory, "find">;
orgService: Pick<TOrgServiceFactory, "createOrganization">;
orgDAL: TOrgDALFactory;
tokenService: TAuthTokenServiceFactory;
@@ -31,6 +44,12 @@ export type TAuthSignupFactory = ReturnType<typeof authSignupServiceFactory>;
export const authSignupServiceFactory = ({
authDAL,
userDAL,
pendingGroupAdditionDAL,
userGroupMembershipDAL,
projectKeyDAL,
projectDAL,
projectBotDAL,
groupProjectDAL,
tokenService,
smtpService,
orgService,
@@ -168,6 +187,20 @@ export const authSignupServiceFactory = ({
const uniqueOrgId = [...new Set(updatedMembersips.map(({ orgId }) => orgId))];
await Promise.allSettled(uniqueOrgId.map((orgId) => licenseService.updateSubscriptionOrgMemberCount(orgId)));
console.log("conv A");
await convertPendingGroupAdditionsToGroupMemberships({
userIds: [user.id],
userDAL,
pendingGroupAdditionDAL,
userGroupMembershipDAL,
orgDAL,
groupProjectDAL,
projectKeyDAL,
projectDAL,
projectBotDAL
});
console.log("conv B");
const tokenSession = await tokenService.getUserTokenSession({
userAgent,
ip,
@@ -225,13 +258,16 @@ export const authSignupServiceFactory = ({
encryptedPrivateKeyTag,
authorization
}: TCompleteAccountInviteDTO) => {
console.log("conv 0");
const user = await userDAL.findUserByUsername(email);
if (!user || (user && user.isAccepted)) {
throw new Error("Failed to complete account for complete user");
}
console.log("conv 1");
validateSignUpAuthorization(authorization, user.id);
console.log("conv 2");
const [orgMembership] = await orgDAL.findMembership({
inviteEmail: email,
status: OrgMembershipStatus.Invited
@@ -242,9 +278,12 @@ export const authSignupServiceFactory = ({
name: "complete account invite"
});
console.log("conv 3");
const updateduser = await authDAL.transaction(async (tx) => {
console.log("conv 4");
const us = await userDAL.updateById(user.id, { firstName, lastName, isAccepted: true }, tx);
if (!us) throw new Error("User not found");
console.log("conv 5");
const userEncKey = await userDAL.upsertUserEncryptionKey(
us.id,
{
@@ -261,15 +300,33 @@ export const authSignupServiceFactory = ({
},
tx
);
console.log("conv 6");
const updatedMembersips = await orgDAL.updateMembership(
{ inviteEmail: email, status: OrgMembershipStatus.Invited },
{ userId: us.id, status: OrgMembershipStatus.Accepted },
tx
);
console.log("conv 7");
const uniqueOrgId = [...new Set(updatedMembersips.map(({ orgId }) => orgId))];
console.log("conv 8");
await Promise.allSettled(uniqueOrgId.map((orgId) => licenseService.updateSubscriptionOrgMemberCount(orgId)));
console.log("conv AA");
await convertPendingGroupAdditionsToGroupMemberships({
userIds: [user.id],
userDAL,
pendingGroupAdditionDAL,
userGroupMembershipDAL,
orgDAL,
groupProjectDAL,
projectKeyDAL,
projectDAL,
projectBotDAL,
tx
});
console.log("conv BB");
return { info: us, key: userEncKey };
});

View File

@@ -81,9 +81,9 @@ export const projectDALFactory = (db: TDbClient) => {
}
};
const findProjectGhostUser = async (projectId: string) => {
const findProjectGhostUser = async (projectId: string, tx?: Knex) => {
try {
const ghostUser = await db(TableName.ProjectMembership)
const ghostUser = await (tx || db)(TableName.ProjectMembership)
.where({ projectId })
.join(TableName.Users, `${TableName.ProjectMembership}.userId`, `${TableName.Users}.id`)
.select(selectAllTableCols(TableName.Users))

View File

@@ -34,6 +34,19 @@ export const userDALFactory = (db: TDbClient) => {
}
};
const findUserEncKeyByUsernameBatch = async ({ usernames }: { usernames: string[] }, tx?: Knex) => {
try {
return await (tx || db)(TableName.Users)
.where({
isGhost: false
})
.whereIn("username", usernames)
.join(TableName.UserEncryptionKey, `${TableName.Users}.id`, `${TableName.UserEncryptionKey}.userId`);
} catch (error) {
throw new DatabaseError({ error, name: "Find user enc by email batch" });
}
};
const findUserEncKeyByUserId = async (userId: string) => {
try {
const user = await db(TableName.Users)
@@ -123,6 +136,7 @@ export const userDALFactory = (db: TDbClient) => {
...userOrm,
findUserByUsername,
findUserEncKeyByUsername,
findUserEncKeyByUsernameBatch, // TODO: if successful, replace findUserEncKeyByUsername with this
findUserEncKeyByUserId,
updateUserEncryptionByUserId,
findUserByProjectMembershipId,

View File

@@ -1,32 +1,27 @@
import { useState } from "react";
import { faMagnifyingGlass,faUsers } from "@fortawesome/free-solid-svg-icons";
import { faMagnifyingGlass, faUsers } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { createNotification } from "@app/components/notifications";
import { OrgPermissionCan } from "@app/components/permissions";
import {
Button,
EmptyState,
Input,
Modal,
ModalContent,
Pagination,
Table,
TableContainer,
TableSkeleton,
TBody,
Td,
Th,
THead,
Tr} from "@app/components/v2";
import {
OrgPermissionActions,
OrgPermissionSubjects
} from "@app/context";
import {
useAddUserToGroup,
useListGroupUsers,
useRemoveUserFromGroup} from "@app/hooks/api";
Button,
EmptyState,
Input,
Modal,
ModalContent,
Pagination,
Table,
TableContainer,
TableSkeleton,
TBody,
Td,
Th,
THead,
Tr
} from "@app/components/v2";
import { OrgPermissionActions, OrgPermissionSubjects } from "@app/context";
import { useAddUserToGroup, useListGroupUsers, useRemoveUserFromGroup } from "@app/hooks/api";
import { UsePopUpState } from "@app/hooks/usePopUp";
type Props = {
@@ -34,136 +29,127 @@ type Props = {
handlePopUpToggle: (popUpName: keyof UsePopUpState<["groupMembers"]>, state?: boolean) => void;
};
export const OrgGroupMembersModal = ({
popUp,
handlePopUpToggle
}: Props) => {
const [page, setPage] = useState(1);
const [perPage, setPerPage] = useState(10);
const [searchMemberFilter, setSearchMemberFilter] = useState("");
const popUpData = popUp?.groupMembers?.data as {
slug: string;
};
const { data, isLoading } = useListGroupUsers({
groupSlug: popUpData?.slug,
offset: (page - 1) * perPage,
limit: perPage,
username: searchMemberFilter
});
const { mutateAsync: assignMutateAsync } = useAddUserToGroup();
const { mutateAsync: unassignMutateAsync } = useRemoveUserFromGroup();
const handleAssignment = async (username: string, assign: boolean) => {
try {
if (!popUpData?.slug) return;
if (assign) {
await assignMutateAsync({
username,
slug: popUpData.slug
});
} else {
await unassignMutateAsync({
username,
slug: popUpData.slug
});
}
export const OrgGroupMembersModal = ({ popUp, handlePopUpToggle }: Props) => {
const [page, setPage] = useState(1);
const [perPage, setPerPage] = useState(10);
const [searchMemberFilter, setSearchMemberFilter] = useState("");
createNotification({
text: `Successfully ${assign ? "assigned" : "removed "} user ${assign ? "to" : "from"} group`,
type: "success"
});
} catch (err) {
createNotification({
text: `Failed to ${assign ? "assigned" : "remove"} user ${assign ? "to" : "from"} group`,
type: "error"
});
}
const popUpData = popUp?.groupMembers?.data as {
slug: string;
};
const { data, isLoading } = useListGroupUsers({
groupSlug: popUpData?.slug,
offset: (page - 1) * perPage,
limit: perPage,
username: searchMemberFilter
});
const { mutateAsync: assignMutateAsync } = useAddUserToGroup();
const { mutateAsync: unassignMutateAsync } = useRemoveUserFromGroup();
const handleAssignment = async (username: string, assign: boolean) => {
try {
if (!popUpData?.slug) return;
if (assign) {
await assignMutateAsync({
username,
slug: popUpData.slug
});
} else {
await unassignMutateAsync({
username,
slug: popUpData.slug
});
}
createNotification({
text: `Successfully ${assign ? "assigned" : "removed "} user ${
assign ? "to" : "from"
} group`,
type: "success"
});
} catch (err) {
createNotification({
text: `Failed to ${assign ? "assigned" : "remove"} user ${assign ? "to" : "from"} group`,
type: "error"
});
}
return (
<Modal
isOpen={popUp?.groupMembers?.isOpen}
onOpenChange={(isOpen) => {
handlePopUpToggle("groupMembers", isOpen);
}}
>
<ModalContent title="Manage Group Members">
<Input
value={searchMemberFilter}
onChange={(e) => setSearchMemberFilter(e.target.value)}
leftIcon={<FontAwesomeIcon icon={faMagnifyingGlass} />}
placeholder="Search members..."
/>
<TableContainer className="mt-4">
<Table>
<THead>
<Tr>
<Th>User</Th>
<Th />
</Tr>
</THead>
<TBody>
{isLoading && <TableSkeleton columns={2} innerKey="group-users" />}
{!isLoading && data?.users?.map(({
id,
firstName,
lastName,
username,
isPartOfGroup
}) => {
return (
<Tr className="items-center" key={`group-user-${id}`}>
<Td>
<p>{`${firstName} ${lastName}`}</p>
<p>{username}</p>
</Td>
<Td className="flex justify-end">
<OrgPermissionCan
I={OrgPermissionActions.Edit}
a={OrgPermissionSubjects.Groups}
>
{(isAllowed) => {
return (
<Button
isLoading={isLoading}
isDisabled={!isAllowed}
colorSchema="primary"
variant="outline_bg"
type="submit"
onClick={() => handleAssignment(username, !isPartOfGroup)}
>
{isPartOfGroup ? "Unassign" : "Assign"}
</Button>
);
}}
</OrgPermissionCan>
</Td>
</Tr>
);
})}
</TBody>
</Table>
{!isLoading && data?.totalCount !== undefined && (
<Pagination
count={data.totalCount}
page={page}
perPage={perPage}
onChangePage={(newPage) => setPage(newPage)}
onChangePerPage={(newPerPage) => setPerPage(newPerPage)}
/>
)}
{!isLoading && !data?.users?.length && (
<EmptyState
title="No users found"
icon={faUsers}
/>
)}
</TableContainer>
</ModalContent>
</Modal>
);
}
};
return (
<Modal
isOpen={popUp?.groupMembers?.isOpen}
onOpenChange={(isOpen) => {
handlePopUpToggle("groupMembers", isOpen);
}}
>
<ModalContent title="Manage Group Members">
<Input
value={searchMemberFilter}
onChange={(e) => setSearchMemberFilter(e.target.value)}
leftIcon={<FontAwesomeIcon icon={faMagnifyingGlass} />}
placeholder="Search members..."
/>
<TableContainer className="mt-4">
<Table>
<THead>
<Tr>
<Th>User</Th>
<Th />
</Tr>
</THead>
<TBody>
{isLoading && <TableSkeleton columns={2} innerKey="group-users" />}
{!isLoading &&
data?.users?.map(({ id, firstName, lastName, username, isPartOfGroup }) => {
return (
<Tr className="items-center" key={`group-user-${id}`}>
<Td>
<p>{`${firstName ?? "-"} ${lastName ?? ""}`}</p>
<p>{username}</p>
</Td>
<Td className="flex justify-end">
<OrgPermissionCan
I={OrgPermissionActions.Edit}
a={OrgPermissionSubjects.Groups}
>
{(isAllowed) => {
return (
<Button
isLoading={isLoading}
isDisabled={!isAllowed}
colorSchema="primary"
variant="outline_bg"
type="submit"
onClick={() => handleAssignment(username, !isPartOfGroup)}
>
{isPartOfGroup ? "Unassign" : "Assign"}
</Button>
);
}}
</OrgPermissionCan>
</Td>
</Tr>
);
})}
</TBody>
</Table>
{!isLoading && data?.totalCount !== undefined && (
<Pagination
count={data.totalCount}
page={page}
perPage={perPage}
onChangePage={(newPage) => setPage(newPage)}
onChangePerPage={(newPerPage) => setPerPage(newPerPage)}
/>
)}
{!isLoading && !data?.users?.length && (
<EmptyState title="No users found" icon={faUsers} />
)}
</TableContainer>
</ModalContent>
</Modal>
);
};