Merge pull request #4995 from Infisical/feat/machine-identity-groups
feature: machine identity groups [ENG-4237]
8
backend/src/@types/knex.d.ts
vendored
@@ -170,6 +170,9 @@ import {
|
||||
TIdentityGcpAuths,
|
||||
TIdentityGcpAuthsInsert,
|
||||
TIdentityGcpAuthsUpdate,
|
||||
TIdentityGroupMembership,
|
||||
TIdentityGroupMembershipInsert,
|
||||
TIdentityGroupMembershipUpdate,
|
||||
TIdentityJwtAuths,
|
||||
TIdentityJwtAuthsInsert,
|
||||
TIdentityJwtAuthsUpdate,
|
||||
@@ -857,6 +860,11 @@ declare module "knex/types/tables" {
|
||||
TUserGroupMembershipInsert,
|
||||
TUserGroupMembershipUpdate
|
||||
>;
|
||||
[TableName.IdentityGroupMembership]: KnexOriginal.CompositeTableType<
|
||||
TIdentityGroupMembership,
|
||||
TIdentityGroupMembershipInsert,
|
||||
TIdentityGroupMembershipUpdate
|
||||
>;
|
||||
[TableName.GroupProjectMembership]: KnexOriginal.CompositeTableType<
|
||||
TGroupProjectMemberships,
|
||||
TGroupProjectMembershipsInsert,
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
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.IdentityGroupMembership))) {
|
||||
await knex.schema.createTable(TableName.IdentityGroupMembership, (t) => {
|
||||
t.uuid("id", { primaryKey: true }).defaultTo(knex.fn.uuid());
|
||||
t.uuid("identityId").notNullable();
|
||||
t.foreign("identityId").references("id").inTable(TableName.Identity).onDelete("CASCADE");
|
||||
t.uuid("groupId").notNullable();
|
||||
t.foreign("groupId").references("id").inTable(TableName.Groups).onDelete("CASCADE");
|
||||
t.timestamps(true, true, true);
|
||||
|
||||
t.unique(["identityId", "groupId"]);
|
||||
});
|
||||
}
|
||||
|
||||
await createOnUpdateTrigger(knex, TableName.IdentityGroupMembership);
|
||||
}
|
||||
|
||||
export async function down(knex: Knex): Promise<void> {
|
||||
if (await knex.schema.hasTable(TableName.IdentityGroupMembership)) {
|
||||
await knex.schema.dropTable(TableName.IdentityGroupMembership);
|
||||
await dropOnUpdateTrigger(knex, TableName.IdentityGroupMembership);
|
||||
}
|
||||
}
|
||||
22
backend/src/db/schemas/identity-group-membership.ts
Normal file
@@ -0,0 +1,22 @@
|
||||
// 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 IdentityGroupMembershipSchema = z.object({
|
||||
id: z.string().uuid(),
|
||||
identityId: z.string().uuid(),
|
||||
groupId: z.string().uuid(),
|
||||
createdAt: z.date(),
|
||||
updatedAt: z.date()
|
||||
});
|
||||
|
||||
export type TIdentityGroupMembership = z.infer<typeof IdentityGroupMembershipSchema>;
|
||||
export type TIdentityGroupMembershipInsert = Omit<z.input<typeof IdentityGroupMembershipSchema>, TImmutableDBKeys>;
|
||||
export type TIdentityGroupMembershipUpdate = Partial<
|
||||
Omit<z.input<typeof IdentityGroupMembershipSchema>, TImmutableDBKeys>
|
||||
>;
|
||||
@@ -55,6 +55,7 @@ export * from "./identity-alicloud-auths";
|
||||
export * from "./identity-aws-auths";
|
||||
export * from "./identity-azure-auths";
|
||||
export * from "./identity-gcp-auths";
|
||||
export * from "./identity-group-membership";
|
||||
export * from "./identity-jwt-auths";
|
||||
export * from "./identity-kubernetes-auths";
|
||||
export * from "./identity-metadata";
|
||||
|
||||
@@ -42,6 +42,7 @@ export enum TableName {
|
||||
GroupProjectMembershipRole = "group_project_membership_roles",
|
||||
ExternalGroupOrgRoleMapping = "external_group_org_role_mappings",
|
||||
UserGroupMembership = "user_group_membership",
|
||||
IdentityGroupMembership = "identity_group_membership",
|
||||
UserAliases = "user_aliases",
|
||||
UserEncryptionKey = "user_encryption_keys",
|
||||
AuthTokens = "auth_tokens",
|
||||
|
||||
@@ -1,18 +1,27 @@
|
||||
import { z } from "zod";
|
||||
|
||||
import { GroupsSchema, OrgMembershipRole, ProjectsSchema, UsersSchema } from "@app/db/schemas";
|
||||
import { GroupsSchema, IdentitiesSchema, OrgMembershipRole, ProjectsSchema, UsersSchema } from "@app/db/schemas";
|
||||
import {
|
||||
EFilterReturnedProjects,
|
||||
EFilterReturnedUsers,
|
||||
EGroupProjectsOrderBy
|
||||
FilterMemberType,
|
||||
FilterReturnedMachineIdentities,
|
||||
FilterReturnedProjects,
|
||||
FilterReturnedUsers,
|
||||
GroupMembersOrderBy,
|
||||
GroupProjectsOrderBy
|
||||
} from "@app/ee/services/group/group-types";
|
||||
import { ApiDocsTags, GROUPS } from "@app/lib/api-docs";
|
||||
import { OrderByDirection } from "@app/lib/types";
|
||||
import { CharacterType, characterValidator } from "@app/lib/validator/validate-string";
|
||||
import { readLimit, writeLimit } from "@app/server/config/rateLimiter";
|
||||
import { slugSchema } from "@app/server/lib/schemas";
|
||||
import { verifyAuth } from "@app/server/plugins/auth/verify-auth";
|
||||
import { AuthMode } from "@app/services/auth/auth-type";
|
||||
|
||||
const GroupIdentityResponseSchema = IdentitiesSchema.pick({
|
||||
id: true,
|
||||
name: true
|
||||
});
|
||||
|
||||
export const registerGroupRouter = async (server: FastifyZodProvider) => {
|
||||
server.route({
|
||||
url: "/",
|
||||
@@ -190,8 +199,15 @@ export const registerGroupRouter = async (server: FastifyZodProvider) => {
|
||||
offset: z.coerce.number().min(0).default(0).describe(GROUPS.LIST_USERS.offset),
|
||||
limit: z.coerce.number().min(1).max(100).default(10).describe(GROUPS.LIST_USERS.limit),
|
||||
username: z.string().trim().optional().describe(GROUPS.LIST_USERS.username),
|
||||
search: z.string().trim().optional().describe(GROUPS.LIST_USERS.search),
|
||||
filter: z.nativeEnum(EFilterReturnedUsers).optional().describe(GROUPS.LIST_USERS.filterUsers)
|
||||
search: z
|
||||
.string()
|
||||
.trim()
|
||||
.refine((val) => characterValidator([CharacterType.AlphaNumeric, CharacterType.Hyphen])(val), {
|
||||
message: "Invalid pattern: only alphanumeric characters, - are allowed."
|
||||
})
|
||||
.optional()
|
||||
.describe(GROUPS.LIST_USERS.search),
|
||||
filter: z.nativeEnum(FilterReturnedUsers).optional().describe(GROUPS.LIST_USERS.filterUsers)
|
||||
}),
|
||||
response: {
|
||||
200: z.object({
|
||||
@@ -202,12 +218,10 @@ export const registerGroupRouter = async (server: FastifyZodProvider) => {
|
||||
lastName: true,
|
||||
id: true
|
||||
})
|
||||
.merge(
|
||||
z.object({
|
||||
isPartOfGroup: z.boolean(),
|
||||
joinedGroupAt: z.date().nullable()
|
||||
})
|
||||
)
|
||||
.extend({
|
||||
isPartOfGroup: z.boolean(),
|
||||
joinedGroupAt: z.date().nullable()
|
||||
})
|
||||
.array(),
|
||||
totalCount: z.number()
|
||||
})
|
||||
@@ -227,6 +241,134 @@ export const registerGroupRouter = async (server: FastifyZodProvider) => {
|
||||
}
|
||||
});
|
||||
|
||||
server.route({
|
||||
method: "GET",
|
||||
url: "/:id/machine-identities",
|
||||
config: {
|
||||
rateLimit: readLimit
|
||||
},
|
||||
onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]),
|
||||
schema: {
|
||||
hide: false,
|
||||
tags: [ApiDocsTags.Groups],
|
||||
params: z.object({
|
||||
id: z.string().trim().describe(GROUPS.LIST_MACHINE_IDENTITIES.id)
|
||||
}),
|
||||
querystring: z.object({
|
||||
offset: z.coerce.number().min(0).default(0).describe(GROUPS.LIST_MACHINE_IDENTITIES.offset),
|
||||
limit: z.coerce.number().min(1).max(100).default(10).describe(GROUPS.LIST_MACHINE_IDENTITIES.limit),
|
||||
search: z
|
||||
.string()
|
||||
.trim()
|
||||
.refine((val) => characterValidator([CharacterType.AlphaNumeric, CharacterType.Hyphen])(val), {
|
||||
message: "Invalid pattern: only alphanumeric characters, - are allowed."
|
||||
})
|
||||
.optional()
|
||||
.describe(GROUPS.LIST_MACHINE_IDENTITIES.search),
|
||||
filter: z
|
||||
.nativeEnum(FilterReturnedMachineIdentities)
|
||||
.optional()
|
||||
.describe(GROUPS.LIST_MACHINE_IDENTITIES.filterMachineIdentities)
|
||||
}),
|
||||
response: {
|
||||
200: z.object({
|
||||
machineIdentities: GroupIdentityResponseSchema.extend({
|
||||
isPartOfGroup: z.boolean(),
|
||||
joinedGroupAt: z.date().nullable()
|
||||
}).array(),
|
||||
totalCount: z.number()
|
||||
})
|
||||
}
|
||||
},
|
||||
handler: async (req) => {
|
||||
const { machineIdentities, totalCount } = await server.services.group.listGroupMachineIdentities({
|
||||
id: req.params.id,
|
||||
actor: req.permission.type,
|
||||
actorId: req.permission.id,
|
||||
actorAuthMethod: req.permission.authMethod,
|
||||
actorOrgId: req.permission.orgId,
|
||||
...req.query
|
||||
});
|
||||
|
||||
return { machineIdentities, totalCount };
|
||||
}
|
||||
});
|
||||
|
||||
server.route({
|
||||
method: "GET",
|
||||
url: "/:id/members",
|
||||
config: {
|
||||
rateLimit: readLimit
|
||||
},
|
||||
onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]),
|
||||
schema: {
|
||||
hide: false,
|
||||
tags: [ApiDocsTags.Groups],
|
||||
params: z.object({
|
||||
id: z.string().trim().describe(GROUPS.LIST_MEMBERS.id)
|
||||
}),
|
||||
querystring: z.object({
|
||||
offset: z.coerce.number().min(0).default(0).describe(GROUPS.LIST_MEMBERS.offset),
|
||||
limit: z.coerce.number().min(1).max(100).default(10).describe(GROUPS.LIST_MEMBERS.limit),
|
||||
search: z
|
||||
.string()
|
||||
.trim()
|
||||
.refine((val) => characterValidator([CharacterType.AlphaNumeric, CharacterType.Hyphen])(val), {
|
||||
message: "Invalid pattern: only alphanumeric characters, - are allowed."
|
||||
})
|
||||
.optional()
|
||||
.describe(GROUPS.LIST_MEMBERS.search),
|
||||
orderBy: z
|
||||
.nativeEnum(GroupMembersOrderBy)
|
||||
.default(GroupMembersOrderBy.Name)
|
||||
.optional()
|
||||
.describe(GROUPS.LIST_MEMBERS.orderBy),
|
||||
orderDirection: z.nativeEnum(OrderByDirection).optional().describe(GROUPS.LIST_MEMBERS.orderDirection),
|
||||
memberTypeFilter: z
|
||||
.union([z.nativeEnum(FilterMemberType), z.array(z.nativeEnum(FilterMemberType))])
|
||||
.optional()
|
||||
.describe(GROUPS.LIST_MEMBERS.memberTypeFilter)
|
||||
.transform((val) => {
|
||||
if (!val) return undefined;
|
||||
return Array.isArray(val) ? val : [val];
|
||||
})
|
||||
}),
|
||||
response: {
|
||||
200: z.object({
|
||||
members: z
|
||||
.discriminatedUnion("type", [
|
||||
z.object({
|
||||
id: z.string(),
|
||||
joinedGroupAt: z.date().nullable(),
|
||||
type: z.literal("user"),
|
||||
user: UsersSchema.pick({ id: true, firstName: true, lastName: true, email: true, username: true })
|
||||
}),
|
||||
z.object({
|
||||
id: z.string(),
|
||||
joinedGroupAt: z.date().nullable(),
|
||||
type: z.literal("machineIdentity"),
|
||||
machineIdentity: GroupIdentityResponseSchema
|
||||
})
|
||||
])
|
||||
.array(),
|
||||
totalCount: z.number()
|
||||
})
|
||||
}
|
||||
},
|
||||
handler: async (req) => {
|
||||
const { members, totalCount } = await server.services.group.listGroupMembers({
|
||||
id: req.params.id,
|
||||
actor: req.permission.type,
|
||||
actorId: req.permission.id,
|
||||
actorAuthMethod: req.permission.authMethod,
|
||||
actorOrgId: req.permission.orgId,
|
||||
...req.query
|
||||
});
|
||||
|
||||
return { members, totalCount };
|
||||
}
|
||||
});
|
||||
|
||||
server.route({
|
||||
method: "GET",
|
||||
url: "/:id/projects",
|
||||
@@ -243,11 +385,18 @@ export const registerGroupRouter = async (server: FastifyZodProvider) => {
|
||||
querystring: z.object({
|
||||
offset: z.coerce.number().min(0).default(0).describe(GROUPS.LIST_PROJECTS.offset),
|
||||
limit: z.coerce.number().min(1).max(100).default(10).describe(GROUPS.LIST_PROJECTS.limit),
|
||||
search: z.string().trim().optional().describe(GROUPS.LIST_PROJECTS.search),
|
||||
filter: z.nativeEnum(EFilterReturnedProjects).optional().describe(GROUPS.LIST_PROJECTS.filterProjects),
|
||||
search: z
|
||||
.string()
|
||||
.trim()
|
||||
.refine((val) => characterValidator([CharacterType.AlphaNumeric, CharacterType.Hyphen])(val), {
|
||||
message: "Invalid pattern: only alphanumeric characters, - are allowed."
|
||||
})
|
||||
.optional()
|
||||
.describe(GROUPS.LIST_PROJECTS.search),
|
||||
filter: z.nativeEnum(FilterReturnedProjects).optional().describe(GROUPS.LIST_PROJECTS.filterProjects),
|
||||
orderBy: z
|
||||
.nativeEnum(EGroupProjectsOrderBy)
|
||||
.default(EGroupProjectsOrderBy.Name)
|
||||
.nativeEnum(GroupProjectsOrderBy)
|
||||
.default(GroupProjectsOrderBy.Name)
|
||||
.describe(GROUPS.LIST_PROJECTS.orderBy),
|
||||
orderDirection: z
|
||||
.nativeEnum(OrderByDirection)
|
||||
@@ -263,11 +412,9 @@ export const registerGroupRouter = async (server: FastifyZodProvider) => {
|
||||
description: true,
|
||||
type: true
|
||||
})
|
||||
.merge(
|
||||
z.object({
|
||||
joinedGroupAt: z.date().nullable()
|
||||
})
|
||||
)
|
||||
.extend({
|
||||
joinedGroupAt: z.date().nullable()
|
||||
})
|
||||
.array(),
|
||||
totalCount: z.number()
|
||||
})
|
||||
@@ -325,6 +472,40 @@ export const registerGroupRouter = async (server: FastifyZodProvider) => {
|
||||
}
|
||||
});
|
||||
|
||||
server.route({
|
||||
method: "POST",
|
||||
url: "/:id/machine-identities/:machineIdentityId",
|
||||
config: {
|
||||
rateLimit: writeLimit
|
||||
},
|
||||
onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]),
|
||||
schema: {
|
||||
hide: false,
|
||||
tags: [ApiDocsTags.Groups],
|
||||
params: z.object({
|
||||
id: z.string().trim().describe(GROUPS.ADD_MACHINE_IDENTITY.id),
|
||||
machineIdentityId: z.string().trim().describe(GROUPS.ADD_MACHINE_IDENTITY.machineIdentityId)
|
||||
}),
|
||||
response: {
|
||||
200: z.object({
|
||||
id: z.string()
|
||||
})
|
||||
}
|
||||
},
|
||||
handler: async (req) => {
|
||||
const machineIdentity = await server.services.group.addMachineIdentityToGroup({
|
||||
id: req.params.id,
|
||||
identityId: req.params.machineIdentityId,
|
||||
actor: req.permission.type,
|
||||
actorId: req.permission.id,
|
||||
actorAuthMethod: req.permission.authMethod,
|
||||
actorOrgId: req.permission.orgId
|
||||
});
|
||||
|
||||
return machineIdentity;
|
||||
}
|
||||
});
|
||||
|
||||
server.route({
|
||||
method: "DELETE",
|
||||
url: "/:id/users/:username",
|
||||
@@ -362,4 +543,38 @@ export const registerGroupRouter = async (server: FastifyZodProvider) => {
|
||||
return user;
|
||||
}
|
||||
});
|
||||
|
||||
server.route({
|
||||
method: "DELETE",
|
||||
url: "/:id/machine-identities/:machineIdentityId",
|
||||
config: {
|
||||
rateLimit: writeLimit
|
||||
},
|
||||
onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]),
|
||||
schema: {
|
||||
hide: false,
|
||||
tags: [ApiDocsTags.Groups],
|
||||
params: z.object({
|
||||
id: z.string().trim().describe(GROUPS.DELETE_MACHINE_IDENTITY.id),
|
||||
machineIdentityId: z.string().trim().describe(GROUPS.DELETE_MACHINE_IDENTITY.machineIdentityId)
|
||||
}),
|
||||
response: {
|
||||
200: z.object({
|
||||
id: z.string()
|
||||
})
|
||||
}
|
||||
},
|
||||
handler: async (req) => {
|
||||
const machineIdentity = await server.services.group.removeMachineIdentityFromGroup({
|
||||
id: req.params.id,
|
||||
identityId: req.params.machineIdentityId,
|
||||
actor: req.permission.type,
|
||||
actorId: req.permission.id,
|
||||
actorAuthMethod: req.permission.authMethod,
|
||||
actorOrgId: req.permission.orgId
|
||||
});
|
||||
|
||||
return machineIdentity;
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
@@ -56,7 +56,7 @@ type TSecretApprovalRequestServiceFactoryDep = {
|
||||
TAccessApprovalRequestReviewerDALFactory,
|
||||
"create" | "find" | "findOne" | "transaction" | "delete"
|
||||
>;
|
||||
groupDAL: Pick<TGroupDALFactory, "findAllGroupPossibleMembers">;
|
||||
groupDAL: Pick<TGroupDALFactory, "findAllGroupPossibleUsers">;
|
||||
smtpService: Pick<TSmtpService, "sendMail">;
|
||||
userDAL: Pick<
|
||||
TUserDALFactory,
|
||||
@@ -182,7 +182,7 @@ export const accessApprovalRequestServiceFactory = ({
|
||||
await Promise.all(
|
||||
approverGroupIds.map((groupApproverId) =>
|
||||
groupDAL
|
||||
.findAllGroupPossibleMembers({
|
||||
.findAllGroupPossibleUsers({
|
||||
orgId: actorOrgId,
|
||||
groupId: groupApproverId
|
||||
})
|
||||
|
||||
@@ -6,7 +6,14 @@ import { DatabaseError } from "@app/lib/errors";
|
||||
import { buildFindFilter, ormify, selectAllTableCols, TFindFilter, TFindOpt } from "@app/lib/knex";
|
||||
import { OrderByDirection } from "@app/lib/types";
|
||||
|
||||
import { EFilterReturnedProjects, EFilterReturnedUsers, EGroupProjectsOrderBy } from "./group-types";
|
||||
import {
|
||||
FilterMemberType,
|
||||
FilterReturnedMachineIdentities,
|
||||
FilterReturnedProjects,
|
||||
FilterReturnedUsers,
|
||||
GroupMembersOrderBy,
|
||||
GroupProjectsOrderBy
|
||||
} from "./group-types";
|
||||
|
||||
export type TGroupDALFactory = ReturnType<typeof groupDALFactory>;
|
||||
|
||||
@@ -70,7 +77,7 @@ export const groupDALFactory = (db: TDbClient) => {
|
||||
};
|
||||
|
||||
// special query
|
||||
const findAllGroupPossibleMembers = async ({
|
||||
const findAllGroupPossibleUsers = async ({
|
||||
orgId,
|
||||
groupId,
|
||||
offset = 0,
|
||||
@@ -85,7 +92,7 @@ export const groupDALFactory = (db: TDbClient) => {
|
||||
limit?: number;
|
||||
username?: string;
|
||||
search?: string;
|
||||
filter?: EFilterReturnedUsers;
|
||||
filter?: FilterReturnedUsers;
|
||||
}) => {
|
||||
try {
|
||||
const query = db
|
||||
@@ -127,11 +134,11 @@ export const groupDALFactory = (db: TDbClient) => {
|
||||
}
|
||||
|
||||
switch (filter) {
|
||||
case EFilterReturnedUsers.EXISTING_MEMBERS:
|
||||
void query.andWhere(`${TableName.UserGroupMembership}.createdAt`, "is not", null);
|
||||
case FilterReturnedUsers.EXISTING_MEMBERS:
|
||||
void query.whereNotNull(`${TableName.UserGroupMembership}.createdAt`);
|
||||
break;
|
||||
case EFilterReturnedUsers.NON_MEMBERS:
|
||||
void query.andWhere(`${TableName.UserGroupMembership}.createdAt`, "is", null);
|
||||
case FilterReturnedUsers.NON_MEMBERS:
|
||||
void query.whereNull(`${TableName.UserGroupMembership}.createdAt`);
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
@@ -155,7 +162,7 @@ export const groupDALFactory = (db: TDbClient) => {
|
||||
username: memberUsername,
|
||||
firstName,
|
||||
lastName,
|
||||
isPartOfGroup: !!memberGroupId,
|
||||
isPartOfGroup: Boolean(memberGroupId),
|
||||
joinedGroupAt
|
||||
})
|
||||
),
|
||||
@@ -167,6 +174,256 @@ export const groupDALFactory = (db: TDbClient) => {
|
||||
}
|
||||
};
|
||||
|
||||
const findAllGroupPossibleMachineIdentities = async ({
|
||||
orgId,
|
||||
groupId,
|
||||
offset = 0,
|
||||
limit,
|
||||
search,
|
||||
filter
|
||||
}: {
|
||||
orgId: string;
|
||||
groupId: string;
|
||||
offset?: number;
|
||||
limit?: number;
|
||||
search?: string;
|
||||
filter?: FilterReturnedMachineIdentities;
|
||||
}) => {
|
||||
try {
|
||||
const query = db
|
||||
.replicaNode()(TableName.Membership)
|
||||
.where(`${TableName.Membership}.scopeOrgId`, orgId)
|
||||
.where(`${TableName.Membership}.scope`, AccessScope.Organization)
|
||||
.whereNotNull(`${TableName.Membership}.actorIdentityId`)
|
||||
.whereNull(`${TableName.Identity}.projectId`)
|
||||
.join(TableName.Identity, `${TableName.Membership}.actorIdentityId`, `${TableName.Identity}.id`)
|
||||
.leftJoin(TableName.IdentityGroupMembership, (bd) => {
|
||||
bd.on(`${TableName.IdentityGroupMembership}.identityId`, "=", `${TableName.Identity}.id`).andOn(
|
||||
`${TableName.IdentityGroupMembership}.groupId`,
|
||||
"=",
|
||||
db.raw("?", [groupId])
|
||||
);
|
||||
})
|
||||
.select(
|
||||
db.ref("id").withSchema(TableName.Membership),
|
||||
db.ref("groupId").withSchema(TableName.IdentityGroupMembership),
|
||||
db.ref("createdAt").withSchema(TableName.IdentityGroupMembership).as("joinedGroupAt"),
|
||||
db.ref("name").withSchema(TableName.Identity),
|
||||
db.ref("id").withSchema(TableName.Identity).as("identityId"),
|
||||
db.raw(`count(*) OVER() as total_count`)
|
||||
)
|
||||
.offset(offset)
|
||||
.orderBy("name", "asc");
|
||||
|
||||
if (limit) {
|
||||
void query.limit(limit);
|
||||
}
|
||||
|
||||
if (search) {
|
||||
void query.andWhereRaw(`LOWER("${TableName.Identity}"."name") ilike ?`, `%${search}%`);
|
||||
}
|
||||
|
||||
switch (filter) {
|
||||
case FilterReturnedMachineIdentities.ASSIGNED_MACHINE_IDENTITIES:
|
||||
void query.whereNotNull(`${TableName.IdentityGroupMembership}.createdAt`);
|
||||
break;
|
||||
case FilterReturnedMachineIdentities.NON_ASSIGNED_MACHINE_IDENTITIES:
|
||||
void query.whereNull(`${TableName.IdentityGroupMembership}.createdAt`);
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
|
||||
const machineIdentities = await query;
|
||||
|
||||
return {
|
||||
machineIdentities: machineIdentities.map(({ name, identityId, joinedGroupAt, groupId: identityGroupId }) => ({
|
||||
id: identityId,
|
||||
name,
|
||||
isPartOfGroup: Boolean(identityGroupId),
|
||||
joinedGroupAt
|
||||
})),
|
||||
// @ts-expect-error col select is raw and not strongly typed
|
||||
totalCount: Number(machineIdentities?.[0]?.total_count ?? 0)
|
||||
};
|
||||
} catch (error) {
|
||||
throw new DatabaseError({ error, name: "Find all group identities" });
|
||||
}
|
||||
};
|
||||
|
||||
const findAllGroupPossibleMembers = async ({
|
||||
orgId,
|
||||
groupId,
|
||||
offset = 0,
|
||||
limit,
|
||||
search,
|
||||
orderBy = GroupMembersOrderBy.Name,
|
||||
orderDirection = OrderByDirection.ASC,
|
||||
memberTypeFilter
|
||||
}: {
|
||||
orgId: string;
|
||||
groupId: string;
|
||||
offset?: number;
|
||||
limit?: number;
|
||||
search?: string;
|
||||
orderBy?: GroupMembersOrderBy;
|
||||
orderDirection?: OrderByDirection;
|
||||
memberTypeFilter?: FilterMemberType[];
|
||||
}) => {
|
||||
try {
|
||||
const includeUsers =
|
||||
!memberTypeFilter || memberTypeFilter.length === 0 || memberTypeFilter.includes(FilterMemberType.USERS);
|
||||
const includeMachineIdentities =
|
||||
!memberTypeFilter ||
|
||||
memberTypeFilter.length === 0 ||
|
||||
memberTypeFilter.includes(FilterMemberType.MACHINE_IDENTITIES);
|
||||
|
||||
const query = db
|
||||
.replicaNode()(TableName.Membership)
|
||||
.where(`${TableName.Membership}.scopeOrgId`, orgId)
|
||||
.where(`${TableName.Membership}.scope`, AccessScope.Organization)
|
||||
.leftJoin(TableName.Users, `${TableName.Membership}.actorUserId`, `${TableName.Users}.id`)
|
||||
.leftJoin(TableName.Identity, `${TableName.Membership}.actorIdentityId`, `${TableName.Identity}.id`)
|
||||
.leftJoin(TableName.UserGroupMembership, (bd) => {
|
||||
bd.on(`${TableName.UserGroupMembership}.userId`, "=", `${TableName.Users}.id`).andOn(
|
||||
`${TableName.UserGroupMembership}.groupId`,
|
||||
"=",
|
||||
db.raw("?", [groupId])
|
||||
);
|
||||
})
|
||||
.leftJoin(TableName.IdentityGroupMembership, (bd) => {
|
||||
bd.on(`${TableName.IdentityGroupMembership}.identityId`, "=", `${TableName.Identity}.id`).andOn(
|
||||
`${TableName.IdentityGroupMembership}.groupId`,
|
||||
"=",
|
||||
db.raw("?", [groupId])
|
||||
);
|
||||
})
|
||||
.where((qb) => {
|
||||
void qb
|
||||
.where((innerQb) => {
|
||||
void innerQb
|
||||
.whereNotNull(`${TableName.Membership}.actorUserId`)
|
||||
.whereNotNull(`${TableName.UserGroupMembership}.createdAt`)
|
||||
.where(`${TableName.Users}.isGhost`, false);
|
||||
})
|
||||
.orWhere((innerQb) => {
|
||||
void innerQb
|
||||
.whereNotNull(`${TableName.Membership}.actorIdentityId`)
|
||||
.whereNotNull(`${TableName.IdentityGroupMembership}.createdAt`)
|
||||
.whereNull(`${TableName.Identity}.projectId`);
|
||||
});
|
||||
})
|
||||
.select(
|
||||
db.raw(
|
||||
`CASE WHEN "${TableName.Membership}"."actorUserId" IS NOT NULL THEN "${TableName.UserGroupMembership}"."createdAt" ELSE "${TableName.IdentityGroupMembership}"."createdAt" END as "joinedGroupAt"`
|
||||
),
|
||||
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.raw(`"${TableName.Users}"."id"::text as "userId"`),
|
||||
db.raw(`"${TableName.Identity}"."id"::text as "identityId"`),
|
||||
db.ref("name").withSchema(TableName.Identity).as("identityName"),
|
||||
db.raw(
|
||||
`CASE WHEN "${TableName.Membership}"."actorUserId" IS NOT NULL THEN 'user' ELSE 'machineIdentity' END as "member_type"`
|
||||
),
|
||||
db.raw(`count(*) OVER() as total_count`)
|
||||
);
|
||||
|
||||
void query.andWhere((qb) => {
|
||||
if (includeUsers) {
|
||||
void qb.whereNotNull(`${TableName.Membership}.actorUserId`);
|
||||
}
|
||||
|
||||
if (includeMachineIdentities) {
|
||||
void qb[includeUsers ? "orWhere" : "where"]((innerQb) => {
|
||||
void innerQb.whereNotNull(`${TableName.Membership}.actorIdentityId`);
|
||||
});
|
||||
}
|
||||
|
||||
if (!includeUsers && !includeMachineIdentities) {
|
||||
void qb.whereRaw("FALSE");
|
||||
}
|
||||
});
|
||||
|
||||
if (search) {
|
||||
void query.andWhere((qb) => {
|
||||
void qb
|
||||
.whereRaw(
|
||||
`CONCAT_WS(' ', "${TableName.Users}"."firstName", "${TableName.Users}"."lastName", lower("${TableName.Users}"."username")) ilike ?`,
|
||||
[`%${search}%`]
|
||||
)
|
||||
.orWhereRaw(`LOWER("${TableName.Identity}"."name") ilike ?`, [`%${search}%`]);
|
||||
});
|
||||
}
|
||||
|
||||
if (orderBy === GroupMembersOrderBy.Name) {
|
||||
const orderDirectionClause = orderDirection === OrderByDirection.ASC ? "ASC" : "DESC";
|
||||
|
||||
// This order by clause is used to sort the members by name.
|
||||
// It first checks if the full name (first name and last name) is not empty, then the username, then the email, then the identity name. If all of these are empty, it returns null.
|
||||
void query.orderByRaw(
|
||||
`LOWER(COALESCE(NULLIF(TRIM(CONCAT_WS(' ', "${TableName.Users}"."firstName", "${TableName.Users}"."lastName")), ''), "${TableName.Users}"."username", "${TableName.Users}"."email", "${TableName.Identity}"."name")) ${orderDirectionClause}`
|
||||
);
|
||||
}
|
||||
|
||||
if (offset) {
|
||||
void query.offset(offset);
|
||||
}
|
||||
if (limit) {
|
||||
void query.limit(limit);
|
||||
}
|
||||
|
||||
const results = (await query) as unknown as {
|
||||
email: string;
|
||||
username: string;
|
||||
firstName: string;
|
||||
lastName: string;
|
||||
userId: string;
|
||||
identityId: string;
|
||||
identityName: string;
|
||||
member_type: "user" | "machineIdentity";
|
||||
joinedGroupAt: Date;
|
||||
total_count: string;
|
||||
}[];
|
||||
|
||||
const members = results.map(
|
||||
({ email, username, firstName, lastName, userId, identityId, identityName, member_type, joinedGroupAt }) => {
|
||||
if (member_type === "user") {
|
||||
return {
|
||||
id: userId,
|
||||
joinedGroupAt,
|
||||
type: "user" as const,
|
||||
user: {
|
||||
id: userId,
|
||||
email,
|
||||
username,
|
||||
firstName,
|
||||
lastName
|
||||
}
|
||||
};
|
||||
}
|
||||
return {
|
||||
id: identityId,
|
||||
joinedGroupAt,
|
||||
type: "machineIdentity" as const,
|
||||
machineIdentity: {
|
||||
id: identityId,
|
||||
name: identityName
|
||||
}
|
||||
};
|
||||
}
|
||||
);
|
||||
|
||||
return {
|
||||
members,
|
||||
totalCount: Number(results?.[0]?.total_count ?? 0)
|
||||
};
|
||||
} catch (error) {
|
||||
throw new DatabaseError({ error, name: "Find all group possible members" });
|
||||
}
|
||||
};
|
||||
|
||||
const findAllGroupProjects = async ({
|
||||
orgId,
|
||||
groupId,
|
||||
@@ -182,8 +439,8 @@ export const groupDALFactory = (db: TDbClient) => {
|
||||
offset?: number;
|
||||
limit?: number;
|
||||
search?: string;
|
||||
filter?: EFilterReturnedProjects;
|
||||
orderBy?: EGroupProjectsOrderBy;
|
||||
filter?: FilterReturnedProjects;
|
||||
orderBy?: GroupProjectsOrderBy;
|
||||
orderDirection?: OrderByDirection;
|
||||
}) => {
|
||||
try {
|
||||
@@ -225,10 +482,10 @@ export const groupDALFactory = (db: TDbClient) => {
|
||||
}
|
||||
|
||||
switch (filter) {
|
||||
case EFilterReturnedProjects.ASSIGNED_PROJECTS:
|
||||
case FilterReturnedProjects.ASSIGNED_PROJECTS:
|
||||
void query.whereNotNull(`${TableName.Membership}.id`);
|
||||
break;
|
||||
case EFilterReturnedProjects.UNASSIGNED_PROJECTS:
|
||||
case FilterReturnedProjects.UNASSIGNED_PROJECTS:
|
||||
void query.whereNull(`${TableName.Membership}.id`);
|
||||
break;
|
||||
default:
|
||||
@@ -313,6 +570,8 @@ export const groupDALFactory = (db: TDbClient) => {
|
||||
...groupOrm,
|
||||
findGroups,
|
||||
findByOrgId,
|
||||
findAllGroupPossibleUsers,
|
||||
findAllGroupPossibleMachineIdentities,
|
||||
findAllGroupPossibleMembers,
|
||||
findAllGroupProjects,
|
||||
findGroupsByProjectId,
|
||||
|
||||
@@ -5,9 +5,11 @@ import { crypto } from "@app/lib/crypto/cryptography";
|
||||
import { BadRequestError, ForbiddenRequestError, NotFoundError, ScimRequestError } from "@app/lib/errors";
|
||||
|
||||
import {
|
||||
TAddIdentitiesToGroup,
|
||||
TAddUsersToGroup,
|
||||
TAddUsersToGroupByUserIds,
|
||||
TConvertPendingGroupAdditionsToGroupMemberships,
|
||||
TRemoveIdentitiesFromGroup,
|
||||
TRemoveUsersFromGroupByUserIds
|
||||
} from "./group-types";
|
||||
|
||||
@@ -285,6 +287,70 @@ export const addUsersToGroupByUserIds = async ({
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* Add identities with identity ids [identityIds] to group [group].
|
||||
* @param {group} group - group to add identity(s) to
|
||||
* @param {string[]} identityIds - id(s) of organization scoped identity(s) to add to group
|
||||
* @returns {Promise<{ id: string }[]>} - id(s) of added identity(s)
|
||||
*/
|
||||
export const addIdentitiesToGroup = async ({
|
||||
group,
|
||||
identityIds,
|
||||
identityDAL,
|
||||
identityGroupMembershipDAL,
|
||||
membershipDAL
|
||||
}: TAddIdentitiesToGroup) => {
|
||||
const identityIdsSet = new Set(identityIds);
|
||||
const identityIdsArray = Array.from(identityIdsSet);
|
||||
|
||||
// ensure all identities exist and belong to the org via org scoped membership
|
||||
const foundIdentitiesMemberships = await membershipDAL.find({
|
||||
scope: AccessScope.Organization,
|
||||
scopeOrgId: group.orgId,
|
||||
$in: {
|
||||
actorIdentityId: identityIdsArray
|
||||
}
|
||||
});
|
||||
|
||||
const existingIdentityOrgMembershipsIdentityIdsSet = new Set(
|
||||
foundIdentitiesMemberships.map((u) => u.actorIdentityId as string)
|
||||
);
|
||||
|
||||
identityIdsArray.forEach((identityId) => {
|
||||
if (!existingIdentityOrgMembershipsIdentityIdsSet.has(identityId)) {
|
||||
throw new ForbiddenRequestError({
|
||||
message: `Identity with id ${identityId} is not part of the organization`
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// check if identity group membership already exists
|
||||
const existingIdentityGroupMemberships = await identityGroupMembershipDAL.find({
|
||||
groupId: group.id,
|
||||
$in: {
|
||||
identityId: identityIdsArray
|
||||
}
|
||||
});
|
||||
|
||||
if (existingIdentityGroupMemberships.length) {
|
||||
throw new BadRequestError({
|
||||
message: `${identityIdsArray.length > 1 ? `Identities are` : `Identity is`} already part of the group ${group.slug}`
|
||||
});
|
||||
}
|
||||
|
||||
return identityDAL.transaction(async (tx) => {
|
||||
await identityGroupMembershipDAL.insertMany(
|
||||
foundIdentitiesMemberships.map((membership) => ({
|
||||
identityId: membership.actorIdentityId as string,
|
||||
groupId: group.id
|
||||
})),
|
||||
tx
|
||||
);
|
||||
|
||||
return identityIdsArray.map((identityId) => ({ id: identityId }));
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* Remove users with user ids [userIds] from group [group].
|
||||
* - Users may be part of the group (non-pending + pending);
|
||||
@@ -421,6 +487,75 @@ export const removeUsersFromGroupByUserIds = async ({
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* Remove identities with identity ids [identityIds] from group [group].
|
||||
* @param {group} group - group to remove identity(s) from
|
||||
* @param {string[]} identityIds - id(s) of identity(s) to remove from group
|
||||
* @returns {Promise<{ id: string }[]>} - id(s) of removed identity(s)
|
||||
*/
|
||||
export const removeIdentitiesFromGroup = async ({
|
||||
group,
|
||||
identityIds,
|
||||
identityDAL,
|
||||
membershipDAL,
|
||||
identityGroupMembershipDAL
|
||||
}: TRemoveIdentitiesFromGroup) => {
|
||||
const identityIdsSet = new Set(identityIds);
|
||||
const identityIdsArray = Array.from(identityIdsSet);
|
||||
|
||||
// ensure all identities exist and belong to the org via org scoped membership
|
||||
const foundIdentitiesMemberships = await membershipDAL.find({
|
||||
scope: AccessScope.Organization,
|
||||
scopeOrgId: group.orgId,
|
||||
$in: {
|
||||
actorIdentityId: identityIdsArray
|
||||
}
|
||||
});
|
||||
|
||||
const foundIdentitiesMembershipsIdentityIdsSet = new Set(
|
||||
foundIdentitiesMemberships.map((u) => u.actorIdentityId as string)
|
||||
);
|
||||
|
||||
if (foundIdentitiesMembershipsIdentityIdsSet.size !== identityIdsArray.length) {
|
||||
throw new NotFoundError({
|
||||
message: `Machine identities not found`
|
||||
});
|
||||
}
|
||||
|
||||
// check if identity group membership already exists
|
||||
const existingIdentityGroupMemberships = await identityGroupMembershipDAL.find({
|
||||
groupId: group.id,
|
||||
$in: {
|
||||
identityId: identityIdsArray
|
||||
}
|
||||
});
|
||||
|
||||
const existingIdentityGroupMembershipsIdentityIdsSet = new Set(
|
||||
existingIdentityGroupMemberships.map((u) => u.identityId)
|
||||
);
|
||||
|
||||
identityIdsArray.forEach((identityId) => {
|
||||
if (!existingIdentityGroupMembershipsIdentityIdsSet.has(identityId)) {
|
||||
throw new ForbiddenRequestError({
|
||||
message: `Machine identities are not part of the group ${group.slug}`
|
||||
});
|
||||
}
|
||||
});
|
||||
return identityDAL.transaction(async (tx) => {
|
||||
await identityGroupMembershipDAL.delete(
|
||||
{
|
||||
groupId: group.id,
|
||||
$in: {
|
||||
identityId: identityIdsArray
|
||||
}
|
||||
},
|
||||
tx
|
||||
);
|
||||
|
||||
return identityIdsArray.map((identityId) => ({ id: identityId }));
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* Convert pending group additions for users with ids [userIds] to group memberships.
|
||||
* @param {string[]} userIds - id(s) of user(s) to try to convert pending group additions to group memberships
|
||||
|
||||
@@ -5,6 +5,8 @@ import { AccessScope, OrganizationActionScope, OrgMembershipRole, TRoles } from
|
||||
import { TOidcConfigDALFactory } from "@app/ee/services/oidc/oidc-config-dal";
|
||||
import { BadRequestError, NotFoundError, PermissionBoundaryError, UnauthorizedError } from "@app/lib/errors";
|
||||
import { alphaNumericNanoId } from "@app/lib/nanoid";
|
||||
import { TIdentityDALFactory } from "@app/services/identity/identity-dal";
|
||||
import { TMembershipDALFactory } from "@app/services/membership/membership-dal";
|
||||
import { TMembershipRoleDALFactory } from "@app/services/membership/membership-role-dal";
|
||||
import { TMembershipGroupDALFactory } from "@app/services/membership-group/membership-group-dal";
|
||||
import { TOrgDALFactory } from "@app/services/org/org-dal";
|
||||
@@ -18,33 +20,48 @@ import { OrgPermissionGroupActions, OrgPermissionSubjects } from "../permission/
|
||||
import { constructPermissionErrorMessage, validatePrivilegeChangeOperation } from "../permission/permission-fns";
|
||||
import { TPermissionServiceFactory } from "../permission/permission-service-types";
|
||||
import { TGroupDALFactory } from "./group-dal";
|
||||
import { addUsersToGroupByUserIds, removeUsersFromGroupByUserIds } from "./group-fns";
|
||||
import {
|
||||
addIdentitiesToGroup,
|
||||
addUsersToGroupByUserIds,
|
||||
removeIdentitiesFromGroup,
|
||||
removeUsersFromGroupByUserIds
|
||||
} from "./group-fns";
|
||||
import {
|
||||
TAddMachineIdentityToGroupDTO,
|
||||
TAddUserToGroupDTO,
|
||||
TCreateGroupDTO,
|
||||
TDeleteGroupDTO,
|
||||
TGetGroupByIdDTO,
|
||||
TListGroupMachineIdentitiesDTO,
|
||||
TListGroupMembersDTO,
|
||||
TListGroupProjectsDTO,
|
||||
TListGroupUsersDTO,
|
||||
TRemoveMachineIdentityFromGroupDTO,
|
||||
TRemoveUserFromGroupDTO,
|
||||
TUpdateGroupDTO
|
||||
} from "./group-types";
|
||||
import { TIdentityGroupMembershipDALFactory } from "./identity-group-membership-dal";
|
||||
import { TUserGroupMembershipDALFactory } from "./user-group-membership-dal";
|
||||
|
||||
type TGroupServiceFactoryDep = {
|
||||
userDAL: Pick<TUserDALFactory, "find" | "findUserEncKeyByUserIdsBatch" | "transaction" | "findUserByUsername">;
|
||||
identityDAL: Pick<TIdentityDALFactory, "findOne" | "find" | "transaction">;
|
||||
identityGroupMembershipDAL: Pick<TIdentityGroupMembershipDALFactory, "find" | "delete" | "insertMany">;
|
||||
groupDAL: Pick<
|
||||
TGroupDALFactory,
|
||||
| "create"
|
||||
| "findOne"
|
||||
| "update"
|
||||
| "delete"
|
||||
| "findAllGroupPossibleUsers"
|
||||
| "findAllGroupPossibleMachineIdentities"
|
||||
| "findAllGroupPossibleMembers"
|
||||
| "findById"
|
||||
| "transaction"
|
||||
| "findAllGroupProjects"
|
||||
>;
|
||||
membershipGroupDAL: Pick<TMembershipGroupDALFactory, "find" | "findOne" | "create">;
|
||||
membershipDAL: Pick<TMembershipDALFactory, "find" | "findOne">;
|
||||
membershipRoleDAL: Pick<TMembershipRoleDALFactory, "create" | "delete">;
|
||||
orgDAL: Pick<TOrgDALFactory, "findMembership" | "countAllOrgMembers" | "findById">;
|
||||
userGroupMembershipDAL: Pick<
|
||||
@@ -65,6 +82,9 @@ type TGroupServiceFactoryDep = {
|
||||
export type TGroupServiceFactory = ReturnType<typeof groupServiceFactory>;
|
||||
|
||||
export const groupServiceFactory = ({
|
||||
identityDAL,
|
||||
membershipDAL,
|
||||
identityGroupMembershipDAL,
|
||||
userDAL,
|
||||
groupDAL,
|
||||
orgDAL,
|
||||
@@ -362,7 +382,7 @@ export const groupServiceFactory = ({
|
||||
message: `Failed to find group with ID ${id}`
|
||||
});
|
||||
|
||||
const { members, totalCount } = await groupDAL.findAllGroupPossibleMembers({
|
||||
const { members, totalCount } = await groupDAL.findAllGroupPossibleUsers({
|
||||
orgId: group.orgId,
|
||||
groupId: group.id,
|
||||
offset,
|
||||
@@ -375,6 +395,100 @@ export const groupServiceFactory = ({
|
||||
return { users: members, totalCount };
|
||||
};
|
||||
|
||||
const listGroupMachineIdentities = async ({
|
||||
id,
|
||||
offset,
|
||||
limit,
|
||||
actor,
|
||||
actorId,
|
||||
actorAuthMethod,
|
||||
actorOrgId,
|
||||
search,
|
||||
filter
|
||||
}: TListGroupMachineIdentitiesDTO) => {
|
||||
if (!actorOrgId) throw new UnauthorizedError({ message: "No organization ID provided in request" });
|
||||
|
||||
const { permission } = await permissionService.getOrgPermission({
|
||||
scope: OrganizationActionScope.Any,
|
||||
actor,
|
||||
actorId,
|
||||
orgId: actorOrgId,
|
||||
actorAuthMethod,
|
||||
actorOrgId
|
||||
});
|
||||
ForbiddenError.from(permission).throwUnlessCan(OrgPermissionGroupActions.Read, OrgPermissionSubjects.Groups);
|
||||
|
||||
const group = await groupDAL.findOne({
|
||||
orgId: actorOrgId,
|
||||
id
|
||||
});
|
||||
|
||||
if (!group)
|
||||
throw new NotFoundError({
|
||||
message: `Failed to find group with ID ${id}`
|
||||
});
|
||||
|
||||
const { machineIdentities, totalCount } = await groupDAL.findAllGroupPossibleMachineIdentities({
|
||||
orgId: group.orgId,
|
||||
groupId: group.id,
|
||||
offset,
|
||||
limit,
|
||||
search,
|
||||
filter
|
||||
});
|
||||
|
||||
return { machineIdentities, totalCount };
|
||||
};
|
||||
|
||||
const listGroupMembers = async ({
|
||||
id,
|
||||
offset,
|
||||
limit,
|
||||
search,
|
||||
orderBy,
|
||||
orderDirection,
|
||||
memberTypeFilter,
|
||||
actor,
|
||||
actorId,
|
||||
actorAuthMethod,
|
||||
actorOrgId
|
||||
}: TListGroupMembersDTO) => {
|
||||
if (!actorOrgId) throw new UnauthorizedError({ message: "No organization ID provided in request" });
|
||||
|
||||
const { permission } = await permissionService.getOrgPermission({
|
||||
scope: OrganizationActionScope.Any,
|
||||
actor,
|
||||
actorId,
|
||||
orgId: actorOrgId,
|
||||
actorAuthMethod,
|
||||
actorOrgId
|
||||
});
|
||||
ForbiddenError.from(permission).throwUnlessCan(OrgPermissionGroupActions.Read, OrgPermissionSubjects.Groups);
|
||||
|
||||
const group = await groupDAL.findOne({
|
||||
orgId: actorOrgId,
|
||||
id
|
||||
});
|
||||
|
||||
if (!group)
|
||||
throw new NotFoundError({
|
||||
message: `Failed to find group with ID ${id}`
|
||||
});
|
||||
|
||||
const { members, totalCount } = await groupDAL.findAllGroupPossibleMembers({
|
||||
orgId: group.orgId,
|
||||
groupId: group.id,
|
||||
offset,
|
||||
limit,
|
||||
search,
|
||||
orderBy,
|
||||
orderDirection,
|
||||
memberTypeFilter
|
||||
});
|
||||
|
||||
return { members, totalCount };
|
||||
};
|
||||
|
||||
const listGroupProjects = async ({
|
||||
id,
|
||||
offset,
|
||||
@@ -504,6 +618,81 @@ export const groupServiceFactory = ({
|
||||
return users[0];
|
||||
};
|
||||
|
||||
const addMachineIdentityToGroup = async ({
|
||||
id,
|
||||
identityId,
|
||||
actor,
|
||||
actorId,
|
||||
actorAuthMethod,
|
||||
actorOrgId
|
||||
}: TAddMachineIdentityToGroupDTO) => {
|
||||
if (!actorOrgId) throw new UnauthorizedError({ message: "No organization ID provided in request" });
|
||||
|
||||
const { permission } = await permissionService.getOrgPermission({
|
||||
scope: OrganizationActionScope.Any,
|
||||
actor,
|
||||
actorId,
|
||||
orgId: actorOrgId,
|
||||
actorAuthMethod,
|
||||
actorOrgId
|
||||
});
|
||||
ForbiddenError.from(permission).throwUnlessCan(OrgPermissionGroupActions.Edit, OrgPermissionSubjects.Groups);
|
||||
|
||||
// check if group with slug exists
|
||||
const group = await groupDAL.findOne({
|
||||
orgId: actorOrgId,
|
||||
id
|
||||
});
|
||||
|
||||
if (!group)
|
||||
throw new NotFoundError({
|
||||
message: `Failed to find group with ID ${id}`
|
||||
});
|
||||
|
||||
const [rolePermissionDetails] = await permissionService.getOrgPermissionByRoles([group.role], actorOrgId);
|
||||
const { shouldUseNewPrivilegeSystem } = await orgDAL.findById(actorOrgId);
|
||||
|
||||
// check if user has broader or equal to privileges than group
|
||||
const permissionBoundary = validatePrivilegeChangeOperation(
|
||||
shouldUseNewPrivilegeSystem,
|
||||
OrgPermissionGroupActions.AddIdentities,
|
||||
OrgPermissionSubjects.Groups,
|
||||
permission,
|
||||
rolePermissionDetails.permission
|
||||
);
|
||||
|
||||
if (!permissionBoundary.isValid)
|
||||
throw new PermissionBoundaryError({
|
||||
message: constructPermissionErrorMessage(
|
||||
"Failed to add identity to more privileged group",
|
||||
shouldUseNewPrivilegeSystem,
|
||||
OrgPermissionGroupActions.AddIdentities,
|
||||
OrgPermissionSubjects.Groups
|
||||
),
|
||||
details: { missingPermissions: permissionBoundary.missingPermissions }
|
||||
});
|
||||
|
||||
const identityMembership = await membershipDAL.findOne({
|
||||
scope: AccessScope.Organization,
|
||||
scopeOrgId: group.orgId,
|
||||
actorIdentityId: identityId
|
||||
});
|
||||
|
||||
if (!identityMembership) {
|
||||
throw new NotFoundError({ message: `Identity with id ${identityId} is not part of the organization` });
|
||||
}
|
||||
|
||||
const identities = await addIdentitiesToGroup({
|
||||
group,
|
||||
identityIds: [identityId],
|
||||
identityDAL,
|
||||
membershipDAL,
|
||||
identityGroupMembershipDAL
|
||||
});
|
||||
|
||||
return identities[0];
|
||||
};
|
||||
|
||||
const removeUserFromGroup = async ({
|
||||
id,
|
||||
username,
|
||||
@@ -587,14 +776,91 @@ export const groupServiceFactory = ({
|
||||
return users[0];
|
||||
};
|
||||
|
||||
const removeMachineIdentityFromGroup = async ({
|
||||
id,
|
||||
identityId,
|
||||
actor,
|
||||
actorId,
|
||||
actorAuthMethod,
|
||||
actorOrgId
|
||||
}: TRemoveMachineIdentityFromGroupDTO) => {
|
||||
if (!actorOrgId) throw new UnauthorizedError({ message: "No organization ID provided in request" });
|
||||
|
||||
const { permission } = await permissionService.getOrgPermission({
|
||||
scope: OrganizationActionScope.Any,
|
||||
actor,
|
||||
actorId,
|
||||
orgId: actorOrgId,
|
||||
actorAuthMethod,
|
||||
actorOrgId
|
||||
});
|
||||
ForbiddenError.from(permission).throwUnlessCan(OrgPermissionGroupActions.Edit, OrgPermissionSubjects.Groups);
|
||||
|
||||
const group = await groupDAL.findOne({
|
||||
orgId: actorOrgId,
|
||||
id
|
||||
});
|
||||
|
||||
if (!group)
|
||||
throw new NotFoundError({
|
||||
message: `Failed to find group with ID ${id}`
|
||||
});
|
||||
|
||||
const [rolePermissionDetails] = await permissionService.getOrgPermissionByRoles([group.role], actorOrgId);
|
||||
const { shouldUseNewPrivilegeSystem } = await orgDAL.findById(actorOrgId);
|
||||
|
||||
// check if user has broader or equal to privileges than group
|
||||
const permissionBoundary = validatePrivilegeChangeOperation(
|
||||
shouldUseNewPrivilegeSystem,
|
||||
OrgPermissionGroupActions.RemoveIdentities,
|
||||
OrgPermissionSubjects.Groups,
|
||||
permission,
|
||||
rolePermissionDetails.permission
|
||||
);
|
||||
if (!permissionBoundary.isValid)
|
||||
throw new PermissionBoundaryError({
|
||||
message: constructPermissionErrorMessage(
|
||||
"Failed to remove identity from more privileged group",
|
||||
shouldUseNewPrivilegeSystem,
|
||||
OrgPermissionGroupActions.RemoveIdentities,
|
||||
OrgPermissionSubjects.Groups
|
||||
),
|
||||
details: { missingPermissions: permissionBoundary.missingPermissions }
|
||||
});
|
||||
|
||||
const identityMembership = await membershipDAL.findOne({
|
||||
scope: AccessScope.Organization,
|
||||
scopeOrgId: group.orgId,
|
||||
actorIdentityId: identityId
|
||||
});
|
||||
|
||||
if (!identityMembership) {
|
||||
throw new NotFoundError({ message: `Identity with id ${identityId} is not part of the organization` });
|
||||
}
|
||||
|
||||
const identities = await removeIdentitiesFromGroup({
|
||||
group,
|
||||
identityIds: [identityId],
|
||||
identityDAL,
|
||||
membershipDAL,
|
||||
identityGroupMembershipDAL
|
||||
});
|
||||
|
||||
return identities[0];
|
||||
};
|
||||
|
||||
return {
|
||||
createGroup,
|
||||
updateGroup,
|
||||
deleteGroup,
|
||||
listGroupUsers,
|
||||
listGroupMachineIdentities,
|
||||
listGroupMembers,
|
||||
listGroupProjects,
|
||||
addUserToGroup,
|
||||
addMachineIdentityToGroup,
|
||||
removeUserFromGroup,
|
||||
removeMachineIdentityFromGroup,
|
||||
getGroupById
|
||||
};
|
||||
};
|
||||
|
||||
@@ -3,6 +3,8 @@ import { Knex } from "knex";
|
||||
import { TGroups } from "@app/db/schemas";
|
||||
import { TUserGroupMembershipDALFactory } from "@app/ee/services/group/user-group-membership-dal";
|
||||
import { OrderByDirection, TGenericPermission } from "@app/lib/types";
|
||||
import { TIdentityDALFactory } from "@app/services/identity/identity-dal";
|
||||
import { TMembershipDALFactory } from "@app/services/membership/membership-dal";
|
||||
import { TMembershipGroupDALFactory } from "@app/services/membership-group/membership-group-dal";
|
||||
import { TOrgDALFactory } from "@app/services/org/org-dal";
|
||||
import { TProjectDALFactory } from "@app/services/project/project-dal";
|
||||
@@ -10,6 +12,8 @@ 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 { TIdentityGroupMembershipDALFactory } from "./identity-group-membership-dal";
|
||||
|
||||
export type TCreateGroupDTO = {
|
||||
name: string;
|
||||
slug?: string;
|
||||
@@ -39,7 +43,25 @@ export type TListGroupUsersDTO = {
|
||||
limit: number;
|
||||
username?: string;
|
||||
search?: string;
|
||||
filter?: EFilterReturnedUsers;
|
||||
filter?: FilterReturnedUsers;
|
||||
} & TGenericPermission;
|
||||
|
||||
export type TListGroupMachineIdentitiesDTO = {
|
||||
id: string;
|
||||
offset: number;
|
||||
limit: number;
|
||||
search?: string;
|
||||
filter?: FilterReturnedMachineIdentities;
|
||||
} & TGenericPermission;
|
||||
|
||||
export type TListGroupMembersDTO = {
|
||||
id: string;
|
||||
offset: number;
|
||||
limit: number;
|
||||
search?: string;
|
||||
orderBy?: GroupMembersOrderBy;
|
||||
orderDirection?: OrderByDirection;
|
||||
memberTypeFilter?: FilterMemberType[];
|
||||
} & TGenericPermission;
|
||||
|
||||
export type TListGroupProjectsDTO = {
|
||||
@@ -47,8 +69,8 @@ export type TListGroupProjectsDTO = {
|
||||
offset: number;
|
||||
limit: number;
|
||||
search?: string;
|
||||
filter?: EFilterReturnedProjects;
|
||||
orderBy?: EGroupProjectsOrderBy;
|
||||
filter?: FilterReturnedProjects;
|
||||
orderBy?: GroupProjectsOrderBy;
|
||||
orderDirection?: OrderByDirection;
|
||||
} & TGenericPermission;
|
||||
|
||||
@@ -61,11 +83,21 @@ export type TAddUserToGroupDTO = {
|
||||
username: string;
|
||||
} & TGenericPermission;
|
||||
|
||||
export type TAddMachineIdentityToGroupDTO = {
|
||||
id: string;
|
||||
identityId: string;
|
||||
} & TGenericPermission;
|
||||
|
||||
export type TRemoveUserFromGroupDTO = {
|
||||
id: string;
|
||||
username: string;
|
||||
} & TGenericPermission;
|
||||
|
||||
export type TRemoveMachineIdentityFromGroupDTO = {
|
||||
id: string;
|
||||
identityId: string;
|
||||
} & TGenericPermission;
|
||||
|
||||
// group fns types
|
||||
|
||||
export type TAddUsersToGroup = {
|
||||
@@ -93,6 +125,14 @@ export type TAddUsersToGroupByUserIds = {
|
||||
tx?: Knex;
|
||||
};
|
||||
|
||||
export type TAddIdentitiesToGroup = {
|
||||
group: TGroups;
|
||||
identityIds: string[];
|
||||
identityDAL: Pick<TIdentityDALFactory, "transaction">;
|
||||
identityGroupMembershipDAL: Pick<TIdentityGroupMembershipDALFactory, "find" | "insertMany">;
|
||||
membershipDAL: Pick<TMembershipDALFactory, "find">;
|
||||
};
|
||||
|
||||
export type TRemoveUsersFromGroupByUserIds = {
|
||||
group: TGroups;
|
||||
userIds: string[];
|
||||
@@ -103,6 +143,14 @@ export type TRemoveUsersFromGroupByUserIds = {
|
||||
tx?: Knex;
|
||||
};
|
||||
|
||||
export type TRemoveIdentitiesFromGroup = {
|
||||
group: TGroups;
|
||||
identityIds: string[];
|
||||
identityDAL: Pick<TIdentityDALFactory, "find" | "transaction">;
|
||||
membershipDAL: Pick<TMembershipDALFactory, "find">;
|
||||
identityGroupMembershipDAL: Pick<TIdentityGroupMembershipDALFactory, "find" | "delete">;
|
||||
};
|
||||
|
||||
export type TConvertPendingGroupAdditionsToGroupMemberships = {
|
||||
userIds: string[];
|
||||
userDAL: Pick<TUserDALFactory, "findUserEncKeyByUserIdsBatch" | "transaction" | "find" | "findById">;
|
||||
@@ -117,16 +165,30 @@ export type TConvertPendingGroupAdditionsToGroupMemberships = {
|
||||
tx?: Knex;
|
||||
};
|
||||
|
||||
export enum EFilterReturnedUsers {
|
||||
export enum FilterReturnedUsers {
|
||||
EXISTING_MEMBERS = "existingMembers",
|
||||
NON_MEMBERS = "nonMembers"
|
||||
}
|
||||
|
||||
export enum EFilterReturnedProjects {
|
||||
export enum FilterReturnedMachineIdentities {
|
||||
ASSIGNED_MACHINE_IDENTITIES = "assignedMachineIdentities",
|
||||
NON_ASSIGNED_MACHINE_IDENTITIES = "nonAssignedMachineIdentities"
|
||||
}
|
||||
|
||||
export enum FilterReturnedProjects {
|
||||
ASSIGNED_PROJECTS = "assignedProjects",
|
||||
UNASSIGNED_PROJECTS = "unassignedProjects"
|
||||
}
|
||||
|
||||
export enum EGroupProjectsOrderBy {
|
||||
export enum GroupProjectsOrderBy {
|
||||
Name = "name"
|
||||
}
|
||||
|
||||
export enum GroupMembersOrderBy {
|
||||
Name = "name"
|
||||
}
|
||||
|
||||
export enum FilterMemberType {
|
||||
USERS = "users",
|
||||
MACHINE_IDENTITIES = "machineIdentities"
|
||||
}
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
import { TDbClient } from "@app/db";
|
||||
import { TableName } from "@app/db/schemas";
|
||||
import { ormify } from "@app/lib/knex";
|
||||
|
||||
export type TIdentityGroupMembershipDALFactory = ReturnType<typeof identityGroupMembershipDALFactory>;
|
||||
|
||||
export const identityGroupMembershipDALFactory = (db: TDbClient) => {
|
||||
const identityGroupMembershipOrm = ormify(db, TableName.IdentityGroupMembership);
|
||||
|
||||
return {
|
||||
...identityGroupMembershipOrm
|
||||
};
|
||||
};
|
||||
@@ -88,8 +88,10 @@ export enum OrgPermissionGroupActions {
|
||||
Edit = "edit",
|
||||
Delete = "delete",
|
||||
GrantPrivileges = "grant-privileges",
|
||||
AddIdentities = "add-identities",
|
||||
AddMembers = "add-members",
|
||||
RemoveMembers = "remove-members"
|
||||
RemoveMembers = "remove-members",
|
||||
RemoveIdentities = "remove-identities"
|
||||
}
|
||||
|
||||
export enum OrgPermissionBillingActions {
|
||||
@@ -381,8 +383,10 @@ const buildAdminPermission = () => {
|
||||
can(OrgPermissionGroupActions.Edit, OrgPermissionSubjects.Groups);
|
||||
can(OrgPermissionGroupActions.Delete, OrgPermissionSubjects.Groups);
|
||||
can(OrgPermissionGroupActions.GrantPrivileges, OrgPermissionSubjects.Groups);
|
||||
can(OrgPermissionGroupActions.AddIdentities, OrgPermissionSubjects.Groups);
|
||||
can(OrgPermissionGroupActions.AddMembers, OrgPermissionSubjects.Groups);
|
||||
can(OrgPermissionGroupActions.RemoveMembers, OrgPermissionSubjects.Groups);
|
||||
can(OrgPermissionGroupActions.RemoveIdentities, OrgPermissionSubjects.Groups);
|
||||
|
||||
can(OrgPermissionBillingActions.Read, OrgPermissionSubjects.Billing);
|
||||
can(OrgPermissionBillingActions.ManageBilling, OrgPermissionSubjects.Billing);
|
||||
|
||||
@@ -178,6 +178,16 @@ export const permissionDALFactory = (db: TDbClient): TPermissionDALFactory => {
|
||||
.where(`${TableName.UserGroupMembership}.userId`, actorId)
|
||||
.select(db.ref("id").withSchema(TableName.Groups));
|
||||
|
||||
const identityGroupSubquery = (tx || db)(TableName.Groups)
|
||||
.leftJoin(
|
||||
TableName.IdentityGroupMembership,
|
||||
`${TableName.IdentityGroupMembership}.groupId`,
|
||||
`${TableName.Groups}.id`
|
||||
)
|
||||
.where(`${TableName.Groups}.orgId`, scopeData.orgId)
|
||||
.where(`${TableName.IdentityGroupMembership}.identityId`, actorId)
|
||||
.select(db.ref("id").withSchema(TableName.Groups));
|
||||
|
||||
const docs = await (tx || db)
|
||||
.replicaNode()(TableName.Membership)
|
||||
.join(TableName.MembershipRole, `${TableName.Membership}.id`, `${TableName.MembershipRole}.membershipId`)
|
||||
@@ -214,7 +224,9 @@ export const permissionDALFactory = (db: TDbClient): TPermissionDALFactory => {
|
||||
.where(`${TableName.Membership}.actorUserId`, actorId)
|
||||
.orWhereIn(`${TableName.Membership}.actorGroupId`, userGroupSubquery);
|
||||
} else if (actorType === ActorType.IDENTITY) {
|
||||
void qb.where(`${TableName.Membership}.actorIdentityId`, actorId);
|
||||
void qb
|
||||
.where(`${TableName.Membership}.actorIdentityId`, actorId)
|
||||
.orWhereIn(`${TableName.Membership}.actorGroupId`, identityGroupSubquery);
|
||||
}
|
||||
})
|
||||
.where((qb) => {
|
||||
@@ -653,6 +665,15 @@ export const permissionDALFactory = (db: TDbClient): TPermissionDALFactory => {
|
||||
orgId: string
|
||||
) => {
|
||||
try {
|
||||
const identityGroupSubquery = db(TableName.Groups)
|
||||
.leftJoin(
|
||||
TableName.IdentityGroupMembership,
|
||||
`${TableName.IdentityGroupMembership}.groupId`,
|
||||
`${TableName.Groups}.id`
|
||||
)
|
||||
.where(`${TableName.Groups}.orgId`, orgId)
|
||||
.select(db.ref("id").withSchema(TableName.Groups));
|
||||
|
||||
const docs = await db
|
||||
.replicaNode()(TableName.Membership)
|
||||
.join(TableName.MembershipRole, `${TableName.Membership}.id`, `${TableName.MembershipRole}.membershipId`)
|
||||
@@ -668,7 +689,11 @@ export const permissionDALFactory = (db: TDbClient): TPermissionDALFactory => {
|
||||
void queryBuilder.on(`${TableName.Membership}.actorIdentityId`, `${TableName.IdentityMetadata}.identityId`);
|
||||
})
|
||||
.where(`${TableName.Membership}.scopeOrgId`, orgId)
|
||||
.whereNotNull(`${TableName.Membership}.actorIdentityId`)
|
||||
.where((qb) => {
|
||||
void qb
|
||||
.whereNotNull(`${TableName.Membership}.actorIdentityId`)
|
||||
.orWhereIn(`${TableName.Membership}.actorGroupId`, identityGroupSubquery);
|
||||
})
|
||||
.where(`${TableName.Membership}.scope`, AccessScope.Project)
|
||||
.where(`${TableName.Membership}.scopeProjectId`, projectId)
|
||||
.select(selectAllTableCols(TableName.MembershipRole))
|
||||
|
||||
@@ -72,7 +72,7 @@ type TScimServiceFactoryDep = {
|
||||
TGroupDALFactory,
|
||||
| "create"
|
||||
| "findOne"
|
||||
| "findAllGroupPossibleMembers"
|
||||
| "findAllGroupPossibleUsers"
|
||||
| "delete"
|
||||
| "findGroups"
|
||||
| "transaction"
|
||||
@@ -952,7 +952,7 @@ export const scimServiceFactory = ({
|
||||
}
|
||||
|
||||
const users = await groupDAL
|
||||
.findAllGroupPossibleMembers({
|
||||
.findAllGroupPossibleUsers({
|
||||
orgId: group.orgId,
|
||||
groupId: group.id
|
||||
})
|
||||
|
||||
@@ -106,6 +106,25 @@ export const GROUPS = {
|
||||
filterUsers:
|
||||
"Whether to filter the list of returned users. 'existingMembers' will only return existing users in the group, 'nonMembers' will only return users not in the group, undefined will return all users in the organization."
|
||||
},
|
||||
LIST_MACHINE_IDENTITIES: {
|
||||
id: "The ID of the group to list identities for.",
|
||||
offset: "The offset to start from. If you enter 10, it will start from the 10th identity.",
|
||||
limit: "The number of identities to return.",
|
||||
search: "The text string that machine identity name will be filtered by.",
|
||||
filterMachineIdentities:
|
||||
"Whether to filter the list of returned identities. 'assignedMachineIdentities' will only return identities assigned to the group, 'nonAssignedMachineIdentities' will only return identities not assigned to the group, undefined will return all identities in the organization."
|
||||
},
|
||||
LIST_MEMBERS: {
|
||||
id: "The ID of the group to list members for.",
|
||||
offset: "The offset to start from. If you enter 10, it will start from the 10th member.",
|
||||
limit: "The number of members to return.",
|
||||
search:
|
||||
"The text string that member email(in case of users) or name(in case of machine identities) will be filtered by.",
|
||||
orderBy: "The column to order members by.",
|
||||
orderDirection: "The direction to order members in.",
|
||||
memberTypeFilter:
|
||||
"Filter members by type. Can be a single value ('users' or 'machineIdentities') or an array of values. If not specified, both users and machine identities will be returned."
|
||||
},
|
||||
LIST_PROJECTS: {
|
||||
id: "The ID of the group to list projects for.",
|
||||
offset: "The offset to start from. If you enter 10, it will start from the 10th project.",
|
||||
@@ -120,12 +139,20 @@ export const GROUPS = {
|
||||
id: "The ID of the group to add the user to.",
|
||||
username: "The username of the user to add to the group."
|
||||
},
|
||||
ADD_MACHINE_IDENTITY: {
|
||||
id: "The ID of the group to add the machine identity to.",
|
||||
machineIdentityId: "The ID of the machine identity to add to the group."
|
||||
},
|
||||
GET_BY_ID: {
|
||||
id: "The ID of the group to fetch."
|
||||
},
|
||||
DELETE_USER: {
|
||||
id: "The ID of the group to remove the user from.",
|
||||
username: "The username of the user to remove from the group."
|
||||
},
|
||||
DELETE_MACHINE_IDENTITY: {
|
||||
id: "The ID of the group to remove the machine identity from.",
|
||||
machineIdentityId: "The ID of the machine identity to remove from the group."
|
||||
}
|
||||
} as const;
|
||||
|
||||
|
||||
@@ -46,6 +46,7 @@ import { githubOrgSyncDALFactory } from "@app/ee/services/github-org-sync/github
|
||||
import { githubOrgSyncServiceFactory } from "@app/ee/services/github-org-sync/github-org-sync-service";
|
||||
import { groupDALFactory } from "@app/ee/services/group/group-dal";
|
||||
import { groupServiceFactory } from "@app/ee/services/group/group-service";
|
||||
import { identityGroupMembershipDALFactory } from "@app/ee/services/group/identity-group-membership-dal";
|
||||
import { userGroupMembershipDALFactory } from "@app/ee/services/group/user-group-membership-dal";
|
||||
import { isHsmActiveAndEnabled } from "@app/ee/services/hsm/hsm-fns";
|
||||
import { THsmServiceFactory } from "@app/ee/services/hsm/hsm-service";
|
||||
@@ -470,6 +471,7 @@ export const registerRoutes = async (
|
||||
const identityMetadataDAL = identityMetadataDALFactory(db);
|
||||
const identityAccessTokenDAL = identityAccessTokenDALFactory(db);
|
||||
const identityOrgMembershipDAL = identityOrgDALFactory(db);
|
||||
const identityGroupMembershipDAL = identityGroupMembershipDALFactory(db);
|
||||
const identityProjectDAL = identityProjectDALFactory(db);
|
||||
const identityAuthTemplateDAL = identityAuthTemplateDALFactory(db);
|
||||
|
||||
@@ -754,6 +756,9 @@ export const registerRoutes = async (
|
||||
membershipGroupDAL
|
||||
});
|
||||
const groupService = groupServiceFactory({
|
||||
identityDAL,
|
||||
membershipDAL,
|
||||
identityGroupMembershipDAL,
|
||||
userDAL,
|
||||
groupDAL,
|
||||
orgDAL,
|
||||
|
||||
@@ -9,7 +9,7 @@ import {
|
||||
TemporaryPermissionMode,
|
||||
UsersSchema
|
||||
} from "@app/db/schemas";
|
||||
import { EFilterReturnedUsers } from "@app/ee/services/group/group-types";
|
||||
import { FilterReturnedUsers } from "@app/ee/services/group/group-types";
|
||||
import { ApiDocsTags, GROUPS, PROJECTS } from "@app/lib/api-docs";
|
||||
import { ms } from "@app/lib/ms";
|
||||
import { isUuidV4 } from "@app/lib/validator";
|
||||
@@ -355,9 +355,10 @@ export const registerGroupProjectRouter = async (server: FastifyZodProvider) =>
|
||||
rateLimit: readLimit
|
||||
},
|
||||
schema: {
|
||||
hide: false,
|
||||
hide: true,
|
||||
deprecated: true,
|
||||
tags: [ApiDocsTags.ProjectGroups],
|
||||
description: "Return project group users",
|
||||
description: "Return project group users (Deprecated: Use /api/v1/groups/{id}/users instead)",
|
||||
params: z.object({
|
||||
projectId: z.string().trim().describe(GROUPS.LIST_USERS.projectId),
|
||||
groupId: z.string().trim().describe(GROUPS.LIST_USERS.id)
|
||||
@@ -367,7 +368,7 @@ export const registerGroupProjectRouter = async (server: FastifyZodProvider) =>
|
||||
limit: z.coerce.number().min(1).max(100).default(10).describe(GROUPS.LIST_USERS.limit),
|
||||
username: z.string().trim().optional().describe(GROUPS.LIST_USERS.username),
|
||||
search: z.string().trim().optional().describe(GROUPS.LIST_USERS.search),
|
||||
filter: z.nativeEnum(EFilterReturnedUsers).optional().describe(GROUPS.LIST_USERS.filterUsers)
|
||||
filter: z.nativeEnum(FilterReturnedUsers).optional().describe(GROUPS.LIST_USERS.filterUsers)
|
||||
}),
|
||||
response: {
|
||||
200: z.object({
|
||||
|
||||
@@ -9,7 +9,7 @@ import {
|
||||
TemporaryPermissionMode,
|
||||
UsersSchema
|
||||
} from "@app/db/schemas";
|
||||
import { EFilterReturnedUsers } from "@app/ee/services/group/group-types";
|
||||
import { FilterReturnedUsers } from "@app/ee/services/group/group-types";
|
||||
import { ApiDocsTags, GROUPS, PROJECTS } from "@app/lib/api-docs";
|
||||
import { ms } from "@app/lib/ms";
|
||||
import { isUuidV4 } from "@app/lib/validator";
|
||||
@@ -367,7 +367,7 @@ export const registerDeprecatedGroupProjectRouter = async (server: FastifyZodPro
|
||||
limit: z.coerce.number().min(1).max(100).default(10).describe(GROUPS.LIST_USERS.limit),
|
||||
username: z.string().trim().optional().describe(GROUPS.LIST_USERS.username),
|
||||
search: z.string().trim().optional().describe(GROUPS.LIST_USERS.search),
|
||||
filter: z.nativeEnum(EFilterReturnedUsers).optional().describe(GROUPS.LIST_USERS.filterUsers)
|
||||
filter: z.nativeEnum(FilterReturnedUsers).optional().describe(GROUPS.LIST_USERS.filterUsers)
|
||||
}),
|
||||
response: {
|
||||
200: z.object({
|
||||
|
||||
@@ -10,7 +10,7 @@ import { TGroupDALFactory } from "../../ee/services/group/group-dal";
|
||||
import { TProjectDALFactory } from "../project/project-dal";
|
||||
|
||||
type TGroupProjectServiceFactoryDep = {
|
||||
groupDAL: Pick<TGroupDALFactory, "findOne" | "findAllGroupPossibleMembers">;
|
||||
groupDAL: Pick<TGroupDALFactory, "findOne" | "findAllGroupPossibleUsers">;
|
||||
projectDAL: Pick<TProjectDALFactory, "findOne" | "findProjectGhostUser" | "findById">;
|
||||
permissionService: Pick<TPermissionServiceFactory, "getProjectPermission" | "getProjectPermissionByRoles">;
|
||||
};
|
||||
@@ -51,7 +51,7 @@ export const groupProjectServiceFactory = ({
|
||||
});
|
||||
ForbiddenError.from(permission).throwUnlessCan(ProjectPermissionGroupActions.Read, ProjectPermissionSub.Groups);
|
||||
|
||||
const { members, totalCount } = await groupDAL.findAllGroupPossibleMembers({
|
||||
const { members, totalCount } = await groupDAL.findAllGroupPossibleUsers({
|
||||
orgId: project.orgId,
|
||||
groupId: id,
|
||||
offset,
|
||||
|
||||
@@ -25,12 +25,27 @@ export const projectDALFactory = (db: TDbClient) => {
|
||||
|
||||
const findIdentityProjects = async (identityId: string, orgId: string, projectType?: ProjectType) => {
|
||||
try {
|
||||
const identityGroupSubquery = db
|
||||
.replicaNode()(TableName.Groups)
|
||||
.leftJoin(
|
||||
TableName.IdentityGroupMembership,
|
||||
`${TableName.IdentityGroupMembership}.groupId`,
|
||||
`${TableName.Groups}.id`
|
||||
)
|
||||
.where(`${TableName.Groups}.orgId`, orgId)
|
||||
.where(`${TableName.IdentityGroupMembership}.identityId`, identityId)
|
||||
.select(db.ref("id").withSchema(TableName.Groups));
|
||||
|
||||
const workspaces = await db
|
||||
.replicaNode()(TableName.Membership)
|
||||
.where(`${TableName.Membership}.scope`, AccessScope.Project)
|
||||
.where(`${TableName.Membership}.actorIdentityId`, identityId)
|
||||
.join(TableName.Project, `${TableName.Membership}.scopeProjectId`, `${TableName.Project}.id`)
|
||||
.where(`${TableName.Project}.orgId`, orgId)
|
||||
.andWhere((qb) => {
|
||||
void qb
|
||||
.where(`${TableName.Membership}.actorIdentityId`, identityId)
|
||||
.orWhereIn(`${TableName.Membership}.actorGroupId`, identityGroupSubquery);
|
||||
})
|
||||
.andWhere((qb) => {
|
||||
if (projectType) {
|
||||
void qb.where(`${TableName.Project}.type`, projectType);
|
||||
@@ -347,11 +362,25 @@ export const projectDALFactory = (db: TDbClient) => {
|
||||
.where(`${TableName.Groups}.orgId`, dto.orgId)
|
||||
.where(`${TableName.UserGroupMembership}.userId`, dto.actorId)
|
||||
.select(db.ref("id").withSchema(TableName.Groups));
|
||||
|
||||
const identityGroupMembershipSubquery = db
|
||||
.replicaNode()(TableName.Groups)
|
||||
.leftJoin(
|
||||
TableName.IdentityGroupMembership,
|
||||
`${TableName.IdentityGroupMembership}.groupId`,
|
||||
`${TableName.Groups}.id`
|
||||
)
|
||||
.where(`${TableName.Groups}.orgId`, dto.orgId)
|
||||
.where(`${TableName.IdentityGroupMembership}.identityId`, dto.actorId)
|
||||
.select(db.ref("id").withSchema(TableName.Groups));
|
||||
|
||||
const membershipSubQuery = db(TableName.Membership)
|
||||
.where(`${TableName.Membership}.scope`, AccessScope.Project)
|
||||
.where((qb) => {
|
||||
if (dto.actor === ActorType.IDENTITY) {
|
||||
void qb.where(`${TableName.Membership}.actorIdentityId`, dto.actorId);
|
||||
void qb
|
||||
.where(`${TableName.Membership}.actorIdentityId`, dto.actorId)
|
||||
.orWhereIn(`${TableName.Membership}.actorGroupId`, identityGroupMembershipSubquery);
|
||||
} else {
|
||||
void qb
|
||||
.where(`${TableName.Membership}.actorUserId`, dto.actorId)
|
||||
|
||||
@@ -200,6 +200,11 @@ export const secretVersionV2BridgeDALFactory = (db: TDbClient) => {
|
||||
.leftJoin(TableName.Users, `${TableName.Users}.id`, `${TableName.SecretVersionV2}.userActorId`)
|
||||
.leftJoin(TableName.Identity, `${TableName.Identity}.id`, `${TableName.SecretVersionV2}.identityActorId`)
|
||||
.leftJoin(TableName.UserGroupMembership, `${TableName.UserGroupMembership}.userId`, `${TableName.Users}.id`)
|
||||
.leftJoin(
|
||||
TableName.IdentityGroupMembership,
|
||||
`${TableName.IdentityGroupMembership}.identityId`,
|
||||
`${TableName.Identity}.id`
|
||||
)
|
||||
.leftJoin(TableName.Membership, (qb) => {
|
||||
void qb
|
||||
.on(`${TableName.Membership}.scope`, db.raw("?", [AccessScope.Project]))
|
||||
@@ -208,7 +213,8 @@ export const secretVersionV2BridgeDALFactory = (db: TDbClient) => {
|
||||
void sqb
|
||||
.on(`${TableName.Membership}.actorUserId`, `${TableName.SecretVersionV2}.userActorId`)
|
||||
.orOn(`${TableName.Membership}.actorIdentityId`, `${TableName.SecretVersionV2}.identityActorId`)
|
||||
.orOn(`${TableName.Membership}.actorGroupId`, `${TableName.UserGroupMembership}.groupId`);
|
||||
.orOn(`${TableName.Membership}.actorGroupId`, `${TableName.UserGroupMembership}.groupId`)
|
||||
.orOn(`${TableName.Membership}.actorGroupId`, `${TableName.IdentityGroupMembership}.groupId`);
|
||||
});
|
||||
})
|
||||
.leftJoin(TableName.SecretV2, `${TableName.SecretVersionV2}.secretId`, `${TableName.SecretV2}.id`)
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
---
|
||||
title: "Add Machine Identity to Group"
|
||||
openapi: "POST /api/v1/groups/{id}/machine-identities/{machineIdentityId}"
|
||||
---
|
||||
@@ -0,0 +1,4 @@
|
||||
---
|
||||
title: "List Group Machine Identities"
|
||||
openapi: "GET /api/v1/groups/{id}/machine-identities"
|
||||
---
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
title: "List Group Members"
|
||||
openapi: "GET /api/v1/groups/{id}/members"
|
||||
---
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
title: "List Group Projects"
|
||||
openapi: "GET /api/v1/groups/{id}/projects"
|
||||
---
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
---
|
||||
title: "Remove Machine Identity from Group"
|
||||
openapi: "DELETE /api/v1/groups/{id}/machine-identities/{machineIdentityId}"
|
||||
---
|
||||
@@ -886,7 +886,12 @@
|
||||
"api-reference/endpoints/groups/get-by-id",
|
||||
"api-reference/endpoints/groups/add-group-user",
|
||||
"api-reference/endpoints/groups/remove-group-user",
|
||||
"api-reference/endpoints/groups/list-group-users"
|
||||
"api-reference/endpoints/groups/list-group-users",
|
||||
"api-reference/endpoints/groups/add-group-machine-identity",
|
||||
"api-reference/endpoints/groups/remove-group-machine-identity",
|
||||
"api-reference/endpoints/groups/list-group-machine-identities",
|
||||
"api-reference/endpoints/groups/list-group-projects",
|
||||
"api-reference/endpoints/groups/list-group-members"
|
||||
]
|
||||
},
|
||||
{
|
||||
|
||||
@@ -1,29 +1,29 @@
|
||||
---
|
||||
title: "User Groups"
|
||||
description: "Manage user groups in Infisical."
|
||||
title: "Groups"
|
||||
description: "Manage groups containing users and machine identities in Infisical."
|
||||
---
|
||||
|
||||
<Info>
|
||||
User Groups is a paid feature.
|
||||
|
||||
If you're using Infisical Cloud, then it is available under the **Enterprise Tier**. If you're self-hosting Infisical,
|
||||
then you should contact team@infisical.com to purchase an enterprise license to use it.
|
||||
Groups is a paid feature. If you're using Infisical Cloud, then it is
|
||||
available under the **Enterprise Tier**. If you're self-hosting Infisical,
|
||||
then you should contact team@infisical.com to purchase an enterprise license
|
||||
to use it.
|
||||
</Info>
|
||||
|
||||
## Concept
|
||||
|
||||
A (user) group is a collection of users that you can create in an Infisical organization to more efficiently manage permissions and access control for multiple users together. For example, you can have a group called `Developers` with the `Developer` role containing all the developers in your organization.
|
||||
A group is a collection of identities (users and/or machine identities) that you can create in an Infisical organization to more efficiently manage permissions and access control for multiple identities together. For example, you can have a group called `Developers` with the `Developer` role containing all the developers in your organization, or a group called `CI/CD Identities` containing all the machine identities used in your CI/CD pipelines.
|
||||
|
||||
User groups have the following properties:
|
||||
Groups have the following properties:
|
||||
|
||||
- If a group is added to a project under specific role(s), all users in the group will be provisioned access to the project with the role(s). Conversely, if a group is removed from a project, all users in the group will lose access to the project.
|
||||
- If a user is added to a group, they will inherit the access control properties of the group including access to project(s) under the role(s) assigned to the group. Conversely, if a user is removed from a group, they will lose access to project(s) that the group has access to.
|
||||
- If a user was previously added to a project under a role and is later added to a group that has access to the same project under a different role, then the user will now have access to the project under the composite permissions of the two roles. If the group is subsequently removed from the project, the user will not lose access to the project as they were previously added to the project separately.
|
||||
- A user can be part of multiple groups. If a user is part of multiple groups, they will inherit the composite permissions of all the groups that they are part of.
|
||||
- If a group is added to a project under specific role(s), all identities in the group will be provisioned access to the project with the role(s). Conversely, if a group is removed from a project, all identities in the group will lose access to the project.
|
||||
- If an identity is added to a group, they will inherit the access control properties of the group including access to project(s) under the role(s) assigned to the group. Conversely, if an identity is removed from a group, they will lose access to project(s) that the group has access to.
|
||||
- If an identity was previously added to a project under a role and is later added to a group that has access to the same project under a different role, then the identity will now have access to the project under the composite permissions of the two roles. If the group is subsequently removed from the project, the identity will not lose access to the project as they were previously added to the project separately.
|
||||
- An identity can be part of multiple groups. If an identity is part of multiple groups, they will inherit the composite permissions of all the groups that they are part of.
|
||||
|
||||
## Workflow
|
||||
|
||||
In the following steps, we explore how to create and use user groups to provision user access to projects in Infisical.
|
||||
In the following steps, we explore how to create and use groups to provision access to projects in Infisical. Groups can contain both users and machine identities, and the workflow is the same for both types of identities.
|
||||
|
||||
<Steps>
|
||||
<Step title="Creating a group">
|
||||
@@ -32,36 +32,38 @@ In the following steps, we explore how to create and use user groups to provisio
|
||||

|
||||
|
||||
When creating a group, you specify an organization level [role](/documentation/platform/access-controls/role-based-access-controls) for it to assume; you can configure roles in Organization Settings > Access Control > Organization Roles.
|
||||
|
||||
|
||||

|
||||
|
||||
|
||||
Now input a few details for your new group. Here’s some guidance for each field:
|
||||
- Name (required): A friendly name for the group like `Engineering`.
|
||||
- Slug (required): A unique identifier for the group like `engineering`.
|
||||
- Role (required): A role from the Organization Roles tab for the group to assume. The organization role assigned will determine what organization level resources this group can have access to.
|
||||
|
||||
</Step>
|
||||
<Step title="Adding users to the group">
|
||||
Next, you'll want to assign users to the group. To do this, press on the users icon on the group and start assigning users to the group.
|
||||
<Step title="Adding identities to the group">
|
||||
Next, you'll want to assign identities (users and/or machine identities) to the group. To do this, click on the group row to open the group details page and click on the **+** button.
|
||||
|
||||

|
||||

|
||||
|
||||
In this example, we're assigning **Alan Turing** and **Ada Lovelace** to the group **Engineering**.
|
||||
In this example, we're assigning **Alan Turing** and **Ada Lovelace** (users) to the group **Engineering**. You can similarly add machine identities to the group by selecting them from the **Machine Identities** tab in the modal.
|
||||
|
||||

|
||||
</Step>
|
||||
<Step title="Adding the group to a project">
|
||||
To enable the group to access project-level resources such as secrets within a specific project, you should add it to that project.
|
||||
|
||||
To do this, head over to the project you want to add the group to and go to Project Settings > Access Control > Groups and press **Add group**.
|
||||
|
||||
To do this, head over to the project you want to add the group to and go to Project Settings > Access Control > Groups and press **Add Group to Project**.
|
||||
|
||||

|
||||
|
||||
|
||||
Next, select the group you want to add to the project and the project level role you want to allow it to assume. The project role assigned will determine what project level resources this group can have access to.
|
||||
|
||||
|
||||

|
||||
|
||||
|
||||
That's it!
|
||||
|
||||
The users of the group now have access to the project under the role you assigned to the group.
|
||||
|
||||
All identities of the group now have access to the project under the role you assigned to the group.
|
||||
|
||||
</Step>
|
||||
</Steps>
|
||||
</Steps>
|
||||
|
||||
BIN
docs/images/platform/groups/group-details.png
Normal file
|
After Width: | Height: | Size: 221 KiB |
|
Before Width: | Height: | Size: 378 KiB After Width: | Height: | Size: 184 KiB |
|
Before Width: | Height: | Size: 431 KiB After Width: | Height: | Size: 237 KiB |
|
Before Width: | Height: | Size: 636 KiB |
|
Before Width: | Height: | Size: 626 KiB After Width: | Height: | Size: 198 KiB |
|
Before Width: | Height: | Size: 371 KiB After Width: | Height: | Size: 185 KiB |
|
Before Width: | Height: | Size: 615 KiB After Width: | Height: | Size: 203 KiB |
@@ -1,8 +1,15 @@
|
||||
export {
|
||||
useAddIdentityToGroup,
|
||||
useAddUserToGroup,
|
||||
useCreateGroup,
|
||||
useDeleteGroup,
|
||||
useRemoveIdentityFromGroup,
|
||||
useRemoveUserFromGroup,
|
||||
useUpdateGroup
|
||||
} from "./mutations";
|
||||
export { useGetGroupById, useListGroupProjects, useListGroupUsers } from "./queries";
|
||||
export {
|
||||
useGetGroupById,
|
||||
useListGroupMachineIdentities,
|
||||
useListGroupProjects,
|
||||
useListGroupUsers
|
||||
} from "./queries";
|
||||
|
||||
@@ -5,7 +5,7 @@ import { apiRequest } from "@app/config/request";
|
||||
import { organizationKeys } from "../organization/queries";
|
||||
import { userKeys } from "../users/query-keys";
|
||||
import { groupKeys } from "./queries";
|
||||
import { TGroup } from "./types";
|
||||
import { TGroup, TGroupMachineIdentity } from "./types";
|
||||
|
||||
export const useCreateGroup = () => {
|
||||
const queryClient = useQueryClient();
|
||||
@@ -95,6 +95,7 @@ export const useAddUserToGroup = () => {
|
||||
},
|
||||
onSuccess: (_, { slug }) => {
|
||||
queryClient.invalidateQueries({ queryKey: groupKeys.forGroupUserMemberships(slug) });
|
||||
queryClient.invalidateQueries({ queryKey: groupKeys.forGroupMembers(slug) });
|
||||
}
|
||||
});
|
||||
};
|
||||
@@ -119,6 +120,55 @@ export const useRemoveUserFromGroup = () => {
|
||||
onSuccess: (_, { slug, username }) => {
|
||||
queryClient.invalidateQueries({ queryKey: groupKeys.forGroupUserMemberships(slug) });
|
||||
queryClient.invalidateQueries({ queryKey: userKeys.listUserGroupMemberships(username) });
|
||||
queryClient.invalidateQueries({ queryKey: groupKeys.forGroupMembers(slug) });
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
export const useAddIdentityToGroup = () => {
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: async ({
|
||||
groupId,
|
||||
identityId
|
||||
}: {
|
||||
groupId: string;
|
||||
identityId: string;
|
||||
slug: string;
|
||||
}) => {
|
||||
const { data } = await apiRequest.post<Pick<TGroupMachineIdentity, "id" | "name">>(
|
||||
`/api/v1/groups/${groupId}/machine-identities/${identityId}`
|
||||
);
|
||||
|
||||
return data;
|
||||
},
|
||||
onSuccess: (_, { slug }) => {
|
||||
queryClient.invalidateQueries({ queryKey: groupKeys.forGroupIdentitiesMemberships(slug) });
|
||||
queryClient.invalidateQueries({ queryKey: groupKeys.forGroupMembers(slug) });
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
export const useRemoveIdentityFromGroup = () => {
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: async ({
|
||||
groupId,
|
||||
identityId
|
||||
}: {
|
||||
groupId: string;
|
||||
identityId: string;
|
||||
slug: string;
|
||||
}) => {
|
||||
const { data } = await apiRequest.delete<Pick<TGroupMachineIdentity, "id" | "name">>(
|
||||
`/api/v1/groups/${groupId}/machine-identities/${identityId}`
|
||||
);
|
||||
|
||||
return data;
|
||||
},
|
||||
onSuccess: (_, { slug }) => {
|
||||
queryClient.invalidateQueries({ queryKey: groupKeys.forGroupIdentitiesMemberships(slug) });
|
||||
queryClient.invalidateQueries({ queryKey: groupKeys.forGroupMembers(slug) });
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
@@ -4,9 +4,14 @@ import { apiRequest } from "@app/config/request";
|
||||
|
||||
import { OrderByDirection } from "../generic/types";
|
||||
import {
|
||||
EFilterReturnedProjects,
|
||||
EFilterReturnedUsers,
|
||||
FilterMemberType,
|
||||
FilterReturnedMachineIdentities,
|
||||
FilterReturnedProjects,
|
||||
FilterReturnedUsers,
|
||||
GroupMembersOrderBy,
|
||||
TGroup,
|
||||
TGroupMachineIdentity,
|
||||
TGroupMember,
|
||||
TGroupProject,
|
||||
TGroupUser
|
||||
} from "./types";
|
||||
@@ -27,10 +32,12 @@ export const groupKeys = {
|
||||
offset: number;
|
||||
limit: number;
|
||||
search: string;
|
||||
filter?: EFilterReturnedUsers;
|
||||
filter?: FilterReturnedUsers;
|
||||
}) => [...groupKeys.forGroupUserMemberships(slug), { offset, limit, search, filter }] as const,
|
||||
specificProjectGroupUserMemberships: ({
|
||||
projectId,
|
||||
allGroupIdentitiesMemberships: () => ["group-identities-memberships"] as const,
|
||||
forGroupIdentitiesMemberships: (slug: string) =>
|
||||
[...groupKeys.allGroupIdentitiesMemberships(), slug] as const,
|
||||
specificGroupIdentitiesMemberships: ({
|
||||
slug,
|
||||
offset,
|
||||
limit,
|
||||
@@ -38,16 +45,34 @@ export const groupKeys = {
|
||||
filter
|
||||
}: {
|
||||
slug: string;
|
||||
projectId: string;
|
||||
offset: number;
|
||||
limit: number;
|
||||
search: string;
|
||||
filter?: EFilterReturnedUsers;
|
||||
filter?: FilterReturnedMachineIdentities;
|
||||
}) =>
|
||||
[...groupKeys.forGroupIdentitiesMemberships(slug), { offset, limit, search, filter }] as const,
|
||||
allGroupMembers: () => ["group-members"] as const,
|
||||
forGroupMembers: (slug: string) => [...groupKeys.allGroupMembers(), slug] as const,
|
||||
specificGroupMembers: ({
|
||||
slug,
|
||||
offset,
|
||||
limit,
|
||||
search,
|
||||
orderBy,
|
||||
orderDirection,
|
||||
memberTypeFilter
|
||||
}: {
|
||||
slug: string;
|
||||
offset: number;
|
||||
limit: number;
|
||||
search: string;
|
||||
orderBy?: GroupMembersOrderBy;
|
||||
orderDirection?: OrderByDirection;
|
||||
memberTypeFilter?: FilterMemberType[];
|
||||
}) =>
|
||||
[
|
||||
...groupKeys.forGroupUserMemberships(slug),
|
||||
projectId,
|
||||
{ offset, limit, search, filter }
|
||||
...groupKeys.forGroupMembers(slug),
|
||||
{ offset, limit, search, orderBy, orderDirection, memberTypeFilter }
|
||||
] as const,
|
||||
allGroupProjects: () => ["group-projects"] as const,
|
||||
forGroupProjects: (groupId: string) => [...groupKeys.allGroupProjects(), groupId] as const,
|
||||
@@ -64,7 +89,7 @@ export const groupKeys = {
|
||||
offset: number;
|
||||
limit: number;
|
||||
search: string;
|
||||
filter?: EFilterReturnedProjects;
|
||||
filter?: FilterReturnedProjects;
|
||||
orderBy?: string;
|
||||
orderDirection?: OrderByDirection;
|
||||
}) =>
|
||||
@@ -99,7 +124,7 @@ export const useListGroupUsers = ({
|
||||
offset: number;
|
||||
limit: number;
|
||||
search: string;
|
||||
filter?: EFilterReturnedUsers;
|
||||
filter?: FilterReturnedUsers;
|
||||
}) => {
|
||||
return useQuery({
|
||||
queryKey: groupKeys.specificGroupUserMemberships({
|
||||
@@ -115,7 +140,7 @@ export const useListGroupUsers = ({
|
||||
const params = new URLSearchParams({
|
||||
offset: String(offset),
|
||||
limit: String(limit),
|
||||
search,
|
||||
...(search && { search }),
|
||||
...(filter && { filter })
|
||||
});
|
||||
|
||||
@@ -131,9 +156,66 @@ export const useListGroupUsers = ({
|
||||
});
|
||||
};
|
||||
|
||||
export const useListProjectGroupUsers = ({
|
||||
export const useListGroupMembers = ({
|
||||
id,
|
||||
groupSlug,
|
||||
offset = 0,
|
||||
limit = 10,
|
||||
search,
|
||||
orderBy,
|
||||
orderDirection,
|
||||
memberTypeFilter
|
||||
}: {
|
||||
id: string;
|
||||
groupSlug: string;
|
||||
offset: number;
|
||||
limit: number;
|
||||
search: string;
|
||||
orderBy?: GroupMembersOrderBy;
|
||||
orderDirection?: OrderByDirection;
|
||||
memberTypeFilter?: FilterMemberType[];
|
||||
}) => {
|
||||
return useQuery({
|
||||
queryKey: groupKeys.specificGroupMembers({
|
||||
slug: groupSlug,
|
||||
offset,
|
||||
limit,
|
||||
search,
|
||||
orderBy,
|
||||
orderDirection,
|
||||
memberTypeFilter
|
||||
}),
|
||||
enabled: Boolean(groupSlug),
|
||||
placeholderData: (previousData) => previousData,
|
||||
queryFn: async () => {
|
||||
const params = new URLSearchParams({
|
||||
offset: String(offset),
|
||||
limit: String(limit),
|
||||
...(search && { search }),
|
||||
...(orderBy && { orderBy: orderBy.toString() }),
|
||||
...(orderDirection && { orderDirection })
|
||||
});
|
||||
|
||||
if (memberTypeFilter && memberTypeFilter.length > 0) {
|
||||
memberTypeFilter.forEach((filter) => {
|
||||
params.append("memberTypeFilter", filter);
|
||||
});
|
||||
}
|
||||
|
||||
const { data } = await apiRequest.get<{ members: TGroupMember[]; totalCount: number }>(
|
||||
`/api/v1/groups/${id}/members`,
|
||||
{
|
||||
params
|
||||
}
|
||||
);
|
||||
|
||||
return data;
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
export const useListGroupMachineIdentities = ({
|
||||
id,
|
||||
projectId,
|
||||
groupSlug,
|
||||
offset = 0,
|
||||
limit = 10,
|
||||
@@ -142,16 +224,14 @@ export const useListProjectGroupUsers = ({
|
||||
}: {
|
||||
id: string;
|
||||
groupSlug: string;
|
||||
projectId: string;
|
||||
offset: number;
|
||||
limit: number;
|
||||
search: string;
|
||||
filter?: EFilterReturnedUsers;
|
||||
filter?: FilterReturnedMachineIdentities;
|
||||
}) => {
|
||||
return useQuery({
|
||||
queryKey: groupKeys.specificProjectGroupUserMemberships({
|
||||
queryKey: groupKeys.specificGroupIdentitiesMemberships({
|
||||
slug: groupSlug,
|
||||
projectId,
|
||||
offset,
|
||||
limit,
|
||||
search,
|
||||
@@ -163,16 +243,16 @@ export const useListProjectGroupUsers = ({
|
||||
const params = new URLSearchParams({
|
||||
offset: String(offset),
|
||||
limit: String(limit),
|
||||
search,
|
||||
...(search && { search }),
|
||||
...(filter && { filter })
|
||||
});
|
||||
|
||||
const { data } = await apiRequest.get<{ users: TGroupUser[]; totalCount: number }>(
|
||||
`/api/v1/projects/${projectId}/groups/${id}/users`,
|
||||
{
|
||||
params
|
||||
}
|
||||
);
|
||||
const { data } = await apiRequest.get<{
|
||||
machineIdentities: TGroupMachineIdentity[];
|
||||
totalCount: number;
|
||||
}>(`/api/v1/groups/${id}/machine-identities`, {
|
||||
params
|
||||
});
|
||||
|
||||
return data;
|
||||
}
|
||||
@@ -194,7 +274,7 @@ export const useListGroupProjects = ({
|
||||
search: string;
|
||||
orderBy?: string;
|
||||
orderDirection?: OrderByDirection;
|
||||
filter?: EFilterReturnedProjects;
|
||||
filter?: FilterReturnedProjects;
|
||||
}) => {
|
||||
return useQuery({
|
||||
queryKey: groupKeys.specificGroupProjects({
|
||||
@@ -212,7 +292,7 @@ export const useListGroupProjects = ({
|
||||
const params = new URLSearchParams({
|
||||
offset: String(offset),
|
||||
limit: String(limit),
|
||||
search,
|
||||
...(search && { search }),
|
||||
...(filter && { filter }),
|
||||
...(orderBy && { orderBy }),
|
||||
...(orderDirection && { orderDirection })
|
||||
|
||||
@@ -42,16 +42,59 @@ export type TGroupWithProjectMemberships = {
|
||||
orgId: string;
|
||||
};
|
||||
|
||||
export enum GroupMemberType {
|
||||
USER = "user",
|
||||
MACHINE_IDENTITY = "machineIdentity"
|
||||
}
|
||||
|
||||
export type TGroupUser = {
|
||||
id: string;
|
||||
email: string;
|
||||
username: string;
|
||||
firstName: string;
|
||||
lastName: string;
|
||||
isPartOfGroup: boolean;
|
||||
joinedGroupAt: Date;
|
||||
};
|
||||
|
||||
export type TGroupMachineIdentity = {
|
||||
id: string;
|
||||
name: string;
|
||||
joinedGroupAt: Date;
|
||||
};
|
||||
|
||||
export type TGroupMemberUser = {
|
||||
id: string;
|
||||
joinedGroupAt: Date;
|
||||
type: GroupMemberType.USER;
|
||||
user: {
|
||||
email: string;
|
||||
username: string;
|
||||
firstName: string;
|
||||
lastName: string;
|
||||
};
|
||||
};
|
||||
|
||||
export type TGroupMemberMachineIdentity = {
|
||||
id: string;
|
||||
joinedGroupAt: Date;
|
||||
type: GroupMemberType.MACHINE_IDENTITY;
|
||||
machineIdentity: {
|
||||
id: string;
|
||||
name: string;
|
||||
};
|
||||
};
|
||||
|
||||
export type TGroupMember = TGroupMemberUser | TGroupMemberMachineIdentity;
|
||||
|
||||
export enum GroupMembersOrderBy {
|
||||
Name = "name"
|
||||
}
|
||||
|
||||
export enum FilterMemberType {
|
||||
USERS = "users",
|
||||
MACHINE_IDENTITIES = "machineIdentities"
|
||||
}
|
||||
|
||||
export type TGroupProject = {
|
||||
id: string;
|
||||
name: string;
|
||||
@@ -61,12 +104,17 @@ export type TGroupProject = {
|
||||
joinedGroupAt: Date;
|
||||
};
|
||||
|
||||
export enum EFilterReturnedUsers {
|
||||
export enum FilterReturnedUsers {
|
||||
EXISTING_MEMBERS = "existingMembers",
|
||||
NON_MEMBERS = "nonMembers"
|
||||
}
|
||||
|
||||
export enum EFilterReturnedProjects {
|
||||
export enum FilterReturnedMachineIdentities {
|
||||
ASSIGNED_MACHINE_IDENTITIES = "assignedMachineIdentities",
|
||||
NON_ASSIGNED_MACHINE_IDENTITIES = "nonAssignedMachineIdentities"
|
||||
}
|
||||
|
||||
export enum FilterReturnedProjects {
|
||||
ASSIGNED_PROJECTS = "assignedProjects",
|
||||
UNASSIGNED_PROJECTS = "unassignedProjects"
|
||||
}
|
||||
|
||||
@@ -1,39 +1,35 @@
|
||||
import { useState } from "react";
|
||||
import { faMagnifyingGlass, faUsers } from "@fortawesome/free-solid-svg-icons";
|
||||
import { faMagnifyingGlass } from "@fortawesome/free-solid-svg-icons";
|
||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||
import { HardDriveIcon, UserIcon } from "lucide-react";
|
||||
import { twMerge } from "tailwind-merge";
|
||||
|
||||
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 { OrgPermissionGroupActions, OrgPermissionSubjects } from "@app/context";
|
||||
import { useDebounce, useResetPageHelper } from "@app/hooks";
|
||||
import { useAddUserToGroup, useListGroupUsers } from "@app/hooks/api";
|
||||
import { EFilterReturnedUsers } from "@app/hooks/api/groups/types";
|
||||
import { Button, Input, Modal, ModalContent, Tooltip } from "@app/components/v2";
|
||||
import { useDebounce } from "@app/hooks";
|
||||
import { UsePopUpState } from "@app/hooks/usePopUp";
|
||||
|
||||
import { AddGroupIdentitiesTab, AddGroupUsersTab } from "./AddGroupMemberModalTabs";
|
||||
|
||||
enum AddMemberType {
|
||||
Users = "users",
|
||||
MachineIdentities = "machineIdentities"
|
||||
}
|
||||
|
||||
type Props = {
|
||||
popUp: UsePopUpState<["addGroupMembers"]>;
|
||||
handlePopUpToggle: (popUpName: keyof UsePopUpState<["addGroupMembers"]>, state?: boolean) => void;
|
||||
isOidcManageGroupMembershipsEnabled: boolean;
|
||||
};
|
||||
|
||||
export const AddGroupMembersModal = ({ popUp, handlePopUpToggle }: Props) => {
|
||||
const [page, setPage] = useState(1);
|
||||
const [perPage, setPerPage] = useState(10);
|
||||
export const AddGroupMembersModal = ({
|
||||
popUp,
|
||||
handlePopUpToggle,
|
||||
isOidcManageGroupMembershipsEnabled
|
||||
}: Props) => {
|
||||
const [addMemberType, setAddMemberType] = useState<AddMemberType>(
|
||||
isOidcManageGroupMembershipsEnabled ? AddMemberType.MachineIdentities : AddMemberType.Users
|
||||
);
|
||||
|
||||
const [searchMemberFilter, setSearchMemberFilter] = useState("");
|
||||
const [debouncedSearch] = useDebounce(searchMemberFilter);
|
||||
|
||||
@@ -42,47 +38,6 @@ export const AddGroupMembersModal = ({ popUp, handlePopUpToggle }: Props) => {
|
||||
slug: string;
|
||||
};
|
||||
|
||||
const offset = (page - 1) * perPage;
|
||||
const { data, isPending } = useListGroupUsers({
|
||||
id: popUpData?.groupId,
|
||||
groupSlug: popUpData?.slug,
|
||||
offset,
|
||||
limit: perPage,
|
||||
search: debouncedSearch,
|
||||
filter: EFilterReturnedUsers.NON_MEMBERS
|
||||
});
|
||||
|
||||
const { totalCount = 0 } = data ?? {};
|
||||
|
||||
useResetPageHelper({
|
||||
totalCount,
|
||||
offset,
|
||||
setPage
|
||||
});
|
||||
|
||||
const { mutateAsync: addUserToGroupMutateAsync } = useAddUserToGroup();
|
||||
|
||||
const handleAddMember = async (username: string) => {
|
||||
if (!popUpData?.slug) {
|
||||
createNotification({
|
||||
text: "Some data is missing, please refresh the page and try again",
|
||||
type: "error"
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
await addUserToGroupMutateAsync({
|
||||
groupId: popUpData.groupId,
|
||||
username,
|
||||
slug: popUpData.slug
|
||||
});
|
||||
|
||||
createNotification({
|
||||
text: "Successfully assigned user to the group",
|
||||
type: "success"
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal
|
||||
isOpen={popUp?.addGroupMembers?.isOpen}
|
||||
@@ -91,74 +46,78 @@ export const AddGroupMembersModal = ({ popUp, handlePopUpToggle }: Props) => {
|
||||
}}
|
||||
>
|
||||
<ModalContent title="Add 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>
|
||||
{isPending && <TableSkeleton columns={2} innerKey="group-users" />}
|
||||
{!isPending &&
|
||||
data?.users?.map(({ id, firstName, lastName, username }) => {
|
||||
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={OrgPermissionGroupActions.Edit}
|
||||
a={OrgPermissionSubjects.Groups}
|
||||
>
|
||||
{(isAllowed) => {
|
||||
return (
|
||||
<Button
|
||||
isLoading={isPending}
|
||||
isDisabled={!isAllowed}
|
||||
colorSchema="primary"
|
||||
variant="outline_bg"
|
||||
type="submit"
|
||||
onClick={() => handleAddMember(username)}
|
||||
>
|
||||
Assign
|
||||
</Button>
|
||||
);
|
||||
}}
|
||||
</OrgPermissionCan>
|
||||
</Td>
|
||||
</Tr>
|
||||
);
|
||||
})}
|
||||
</TBody>
|
||||
</Table>
|
||||
{!isPending && totalCount > 0 && (
|
||||
<Pagination
|
||||
count={totalCount}
|
||||
page={page}
|
||||
perPage={perPage}
|
||||
onChangePage={(newPage) => setPage(newPage)}
|
||||
onChangePerPage={(newPerPage) => setPerPage(newPerPage)}
|
||||
<div className="mx-auto flex w-3/4 gap-x-0.5 rounded-md border border-mineshaft-600 bg-mineshaft-800 p-1">
|
||||
<Tooltip
|
||||
className="text-center"
|
||||
content={
|
||||
isOidcManageGroupMembershipsEnabled
|
||||
? "OIDC Group Membership Mapping Enabled. Assign users to this group in your OIDC provider."
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
<div className="flex-1">
|
||||
<Button
|
||||
variant="outline_bg"
|
||||
onClick={() => {
|
||||
setAddMemberType(AddMemberType.Users);
|
||||
}}
|
||||
size="xs"
|
||||
isDisabled={isOidcManageGroupMembershipsEnabled}
|
||||
className={twMerge(
|
||||
"w-full min-w-[2.4rem] rounded border-none hover:bg-mineshaft-600",
|
||||
addMemberType === AddMemberType.Users ? "bg-mineshaft-500" : "bg-transparent"
|
||||
)}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<UserIcon size={16} />
|
||||
Users
|
||||
</div>
|
||||
</Button>
|
||||
</div>
|
||||
</Tooltip>
|
||||
<Button
|
||||
variant="outline_bg"
|
||||
onClick={() => {
|
||||
setAddMemberType(AddMemberType.MachineIdentities);
|
||||
}}
|
||||
size="xs"
|
||||
className={twMerge(
|
||||
"min-w-[2.4rem] flex-1 rounded border-none hover:bg-mineshaft-600",
|
||||
addMemberType === AddMemberType.MachineIdentities
|
||||
? "bg-mineshaft-500"
|
||||
: "bg-transparent"
|
||||
)}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<HardDriveIcon size={16} />
|
||||
Machine Identities
|
||||
</div>
|
||||
</Button>
|
||||
</div>
|
||||
<div className="mt-4 mb-4 flex items-center justify-center gap-x-2">
|
||||
<Input
|
||||
value={searchMemberFilter}
|
||||
onChange={(e) => setSearchMemberFilter(e.target.value)}
|
||||
leftIcon={<FontAwesomeIcon icon={faMagnifyingGlass} />}
|
||||
placeholder="Search members..."
|
||||
/>
|
||||
</div>
|
||||
{addMemberType === AddMemberType.Users &&
|
||||
popUpData &&
|
||||
!isOidcManageGroupMembershipsEnabled && (
|
||||
<AddGroupUsersTab
|
||||
groupId={popUpData.groupId}
|
||||
groupSlug={popUpData.slug}
|
||||
search={debouncedSearch}
|
||||
/>
|
||||
)}
|
||||
{!isPending && !data?.users?.length && (
|
||||
<EmptyState
|
||||
title={
|
||||
debouncedSearch ? "No users match search" : "All users are already in the group"
|
||||
}
|
||||
icon={faUsers}
|
||||
/>
|
||||
)}
|
||||
</TableContainer>
|
||||
{addMemberType === AddMemberType.MachineIdentities && popUpData && (
|
||||
<AddGroupIdentitiesTab
|
||||
groupId={popUpData.groupId}
|
||||
groupSlug={popUpData.slug}
|
||||
search={debouncedSearch}
|
||||
/>
|
||||
)}
|
||||
</ModalContent>
|
||||
</Modal>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,143 @@
|
||||
import { useState } from "react";
|
||||
import { faServer } from "@fortawesome/free-solid-svg-icons";
|
||||
|
||||
import { createNotification } from "@app/components/notifications";
|
||||
import { OrgPermissionCan } from "@app/components/permissions";
|
||||
import {
|
||||
Button,
|
||||
EmptyState,
|
||||
Pagination,
|
||||
Table,
|
||||
TableContainer,
|
||||
TableSkeleton,
|
||||
TBody,
|
||||
Td,
|
||||
Th,
|
||||
THead,
|
||||
Tr
|
||||
} from "@app/components/v2";
|
||||
import { OrgPermissionGroupActions, OrgPermissionSubjects } from "@app/context";
|
||||
import { useResetPageHelper } from "@app/hooks";
|
||||
import { useAddIdentityToGroup, useListGroupMachineIdentities } from "@app/hooks/api";
|
||||
import {
|
||||
FilterReturnedMachineIdentities,
|
||||
TGroupMachineIdentity
|
||||
} from "@app/hooks/api/groups/types";
|
||||
|
||||
type Props = {
|
||||
groupId: string;
|
||||
groupSlug: string;
|
||||
search: string;
|
||||
};
|
||||
|
||||
export const AddGroupIdentitiesTab = ({ groupId, groupSlug, search }: Props) => {
|
||||
const [page, setPage] = useState(1);
|
||||
const [perPage, setPerPage] = useState(10);
|
||||
|
||||
const offset = (page - 1) * perPage;
|
||||
const { data, isPending } = useListGroupMachineIdentities({
|
||||
id: groupId,
|
||||
groupSlug,
|
||||
offset,
|
||||
limit: perPage,
|
||||
search,
|
||||
filter: FilterReturnedMachineIdentities.NON_ASSIGNED_MACHINE_IDENTITIES
|
||||
});
|
||||
|
||||
const { totalCount = 0 } = data ?? {};
|
||||
|
||||
useResetPageHelper({
|
||||
totalCount,
|
||||
offset,
|
||||
setPage
|
||||
});
|
||||
|
||||
const { mutateAsync: addIdentityToGroupMutateAsync } = useAddIdentityToGroup();
|
||||
|
||||
const handleAddIdentity = async (identityId: string) => {
|
||||
if (!groupSlug) {
|
||||
createNotification({
|
||||
text: "Some data is missing, please refresh the page and try again",
|
||||
type: "error"
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
await addIdentityToGroupMutateAsync({
|
||||
groupId,
|
||||
identityId,
|
||||
slug: groupSlug
|
||||
});
|
||||
|
||||
createNotification({
|
||||
text: "Successfully assigned machine identity to the group",
|
||||
type: "success"
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<TableContainer className="mt-4">
|
||||
<Table>
|
||||
<THead>
|
||||
<Tr>
|
||||
<Th>Machine Identity</Th>
|
||||
<Th />
|
||||
</Tr>
|
||||
</THead>
|
||||
<TBody>
|
||||
{isPending && <TableSkeleton columns={2} innerKey="group-identities" />}
|
||||
{!isPending &&
|
||||
data?.machineIdentities?.map((identity: TGroupMachineIdentity) => {
|
||||
return (
|
||||
<Tr className="items-center" key={`group-identity-${identity.id}`}>
|
||||
<Td>
|
||||
<p>{identity.name}</p>
|
||||
</Td>
|
||||
<Td className="flex justify-end">
|
||||
<OrgPermissionCan
|
||||
I={OrgPermissionGroupActions.Edit}
|
||||
a={OrgPermissionSubjects.Groups}
|
||||
>
|
||||
{(isAllowed) => {
|
||||
return (
|
||||
<Button
|
||||
isLoading={isPending}
|
||||
isDisabled={!isAllowed}
|
||||
colorSchema="primary"
|
||||
variant="outline_bg"
|
||||
type="submit"
|
||||
onClick={() => handleAddIdentity(identity.id)}
|
||||
>
|
||||
Assign
|
||||
</Button>
|
||||
);
|
||||
}}
|
||||
</OrgPermissionCan>
|
||||
</Td>
|
||||
</Tr>
|
||||
);
|
||||
})}
|
||||
</TBody>
|
||||
</Table>
|
||||
{!isPending && totalCount > 0 && (
|
||||
<Pagination
|
||||
count={totalCount}
|
||||
page={page}
|
||||
perPage={perPage}
|
||||
onChangePage={(newPage) => setPage(newPage)}
|
||||
onChangePerPage={(newPerPage) => setPerPage(newPerPage)}
|
||||
/>
|
||||
)}
|
||||
{!isPending && !data?.machineIdentities?.length && (
|
||||
<EmptyState
|
||||
title={
|
||||
search
|
||||
? "No machine identities match search"
|
||||
: "All machine identities are already in the group"
|
||||
}
|
||||
icon={faServer}
|
||||
/>
|
||||
)}
|
||||
</TableContainer>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,137 @@
|
||||
import { useState } from "react";
|
||||
import { faUsers } from "@fortawesome/free-solid-svg-icons";
|
||||
|
||||
import { createNotification } from "@app/components/notifications";
|
||||
import { OrgPermissionCan } from "@app/components/permissions";
|
||||
import {
|
||||
Button,
|
||||
EmptyState,
|
||||
Pagination,
|
||||
Table,
|
||||
TableContainer,
|
||||
TableSkeleton,
|
||||
TBody,
|
||||
Td,
|
||||
Th,
|
||||
THead,
|
||||
Tr
|
||||
} from "@app/components/v2";
|
||||
import { OrgPermissionGroupActions, OrgPermissionSubjects } from "@app/context";
|
||||
import { useResetPageHelper } from "@app/hooks";
|
||||
import { useAddUserToGroup, useListGroupUsers } from "@app/hooks/api";
|
||||
import { FilterReturnedUsers } from "@app/hooks/api/groups/types";
|
||||
|
||||
type Props = {
|
||||
groupId: string;
|
||||
groupSlug: string;
|
||||
search: string;
|
||||
};
|
||||
|
||||
export const AddGroupUsersTab = ({ groupId, groupSlug, search }: Props) => {
|
||||
const [page, setPage] = useState(1);
|
||||
const [perPage, setPerPage] = useState(10);
|
||||
|
||||
const offset = (page - 1) * perPage;
|
||||
const { data, isPending } = useListGroupUsers({
|
||||
id: groupId,
|
||||
groupSlug,
|
||||
offset,
|
||||
limit: perPage,
|
||||
search,
|
||||
filter: FilterReturnedUsers.NON_MEMBERS
|
||||
});
|
||||
|
||||
const { totalCount = 0 } = data ?? {};
|
||||
|
||||
useResetPageHelper({
|
||||
totalCount,
|
||||
offset,
|
||||
setPage
|
||||
});
|
||||
|
||||
const { mutateAsync: addUserToGroupMutateAsync } = useAddUserToGroup();
|
||||
|
||||
const handleAddUser = async (username: string) => {
|
||||
if (!groupSlug) {
|
||||
createNotification({
|
||||
text: "Some data is missing, please refresh the page and try again",
|
||||
type: "error"
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
await addUserToGroupMutateAsync({
|
||||
groupId,
|
||||
username,
|
||||
slug: groupSlug
|
||||
});
|
||||
|
||||
createNotification({
|
||||
text: "Successfully assigned user to the group",
|
||||
type: "success"
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<TableContainer className="mt-4">
|
||||
<Table>
|
||||
<THead>
|
||||
<Tr>
|
||||
<Th>User</Th>
|
||||
<Th />
|
||||
</Tr>
|
||||
</THead>
|
||||
<TBody>
|
||||
{isPending && <TableSkeleton columns={2} innerKey="group-users" />}
|
||||
{!isPending &&
|
||||
data?.users?.map(({ id, firstName, lastName, username }) => {
|
||||
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={OrgPermissionGroupActions.Edit}
|
||||
a={OrgPermissionSubjects.Groups}
|
||||
>
|
||||
{(isAllowed) => {
|
||||
return (
|
||||
<Button
|
||||
isLoading={isPending}
|
||||
isDisabled={!isAllowed}
|
||||
colorSchema="primary"
|
||||
variant="outline_bg"
|
||||
type="submit"
|
||||
onClick={() => handleAddUser(username)}
|
||||
>
|
||||
Assign
|
||||
</Button>
|
||||
);
|
||||
}}
|
||||
</OrgPermissionCan>
|
||||
</Td>
|
||||
</Tr>
|
||||
);
|
||||
})}
|
||||
</TBody>
|
||||
</Table>
|
||||
{!isPending && totalCount > 0 && (
|
||||
<Pagination
|
||||
count={totalCount}
|
||||
page={page}
|
||||
perPage={perPage}
|
||||
onChangePage={(newPage) => setPage(newPage)}
|
||||
onChangePerPage={(newPerPage) => setPerPage(newPerPage)}
|
||||
/>
|
||||
)}
|
||||
{!isPending && !data?.users?.length && (
|
||||
<EmptyState
|
||||
title={search ? "No users match search" : "All users are already in the group"}
|
||||
icon={faUsers}
|
||||
/>
|
||||
)}
|
||||
</TableContainer>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,2 @@
|
||||
export { AddGroupIdentitiesTab } from "./AddGroupIdentitiesTab";
|
||||
export { AddGroupUsersTab } from "./AddGroupUsersTab";
|
||||
@@ -27,7 +27,7 @@ import {
|
||||
useAddGroupToWorkspace as useAddProjectToGroup,
|
||||
useListGroupProjects
|
||||
} from "@app/hooks/api";
|
||||
import { EFilterReturnedProjects } from "@app/hooks/api/groups/types";
|
||||
import { FilterReturnedProjects } from "@app/hooks/api/groups/types";
|
||||
import { ProjectType } from "@app/hooks/api/projects/types";
|
||||
import { UsePopUpState } from "@app/hooks/usePopUp";
|
||||
|
||||
@@ -57,7 +57,7 @@ export const AddGroupProjectModal = ({ popUp, handlePopUpToggle }: Props) => {
|
||||
offset,
|
||||
limit: perPage,
|
||||
search: debouncedSearch,
|
||||
filter: EFilterReturnedProjects.UNASSIGNED_PROJECTS
|
||||
filter: FilterReturnedProjects.UNASSIGNED_PROJECTS
|
||||
});
|
||||
|
||||
const { totalCount = 0 } = data ?? {};
|
||||
|
||||
@@ -3,9 +3,14 @@ import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||
|
||||
import { createNotification } from "@app/components/notifications";
|
||||
import { OrgPermissionCan } from "@app/components/permissions";
|
||||
import { DeleteActionModal, IconButton, Tooltip } from "@app/components/v2";
|
||||
import { DeleteActionModal, IconButton } from "@app/components/v2";
|
||||
import { OrgPermissionGroupActions, OrgPermissionSubjects, useOrganization } from "@app/context";
|
||||
import { useOidcManageGroupMembershipsEnabled, useRemoveUserFromGroup } from "@app/hooks/api";
|
||||
import {
|
||||
useOidcManageGroupMembershipsEnabled,
|
||||
useRemoveIdentityFromGroup,
|
||||
useRemoveUserFromGroup
|
||||
} from "@app/hooks/api";
|
||||
import { GroupMemberType } from "@app/hooks/api/groups/types";
|
||||
import { usePopUp } from "@app/hooks/usePopUp";
|
||||
|
||||
import { AddGroupMembersModal } from "../AddGroupMemberModal";
|
||||
@@ -16,6 +21,10 @@ type Props = {
|
||||
groupSlug: string;
|
||||
};
|
||||
|
||||
type RemoveMemberData =
|
||||
| { memberType: GroupMemberType.USER; username: string }
|
||||
| { memberType: GroupMemberType.MACHINE_IDENTITY; identityId: string; name: string };
|
||||
|
||||
export const GroupMembersSection = ({ groupId, groupSlug }: Props) => {
|
||||
const { popUp, handlePopUpOpen, handlePopUpToggle } = usePopUp([
|
||||
"addGroupMembers",
|
||||
@@ -28,52 +37,66 @@ export const GroupMembersSection = ({ groupId, groupSlug }: Props) => {
|
||||
useOidcManageGroupMembershipsEnabled(currentOrg.id);
|
||||
|
||||
const { mutateAsync: removeUserFromGroupMutateAsync } = useRemoveUserFromGroup();
|
||||
const handleRemoveUserFromGroup = async (username: string) => {
|
||||
await removeUserFromGroupMutateAsync({
|
||||
groupId,
|
||||
username,
|
||||
slug: groupSlug
|
||||
});
|
||||
const { mutateAsync: removeIdentityFromGroupMutateAsync } = useRemoveIdentityFromGroup();
|
||||
|
||||
createNotification({
|
||||
text: `Successfully removed user ${username} from the group`,
|
||||
type: "success"
|
||||
});
|
||||
const handleRemoveMemberFromGroup = async (memberData: RemoveMemberData) => {
|
||||
if (memberData.memberType === GroupMemberType.USER) {
|
||||
await removeUserFromGroupMutateAsync({
|
||||
groupId,
|
||||
username: memberData.username,
|
||||
slug: groupSlug
|
||||
});
|
||||
|
||||
createNotification({
|
||||
text: `Successfully removed user ${memberData.username} from the group`,
|
||||
type: "success"
|
||||
});
|
||||
} else {
|
||||
await removeIdentityFromGroupMutateAsync({
|
||||
groupId,
|
||||
identityId: memberData.identityId,
|
||||
slug: groupSlug
|
||||
});
|
||||
|
||||
createNotification({
|
||||
text: `Successfully removed identity ${memberData.name} from the group`,
|
||||
type: "success"
|
||||
});
|
||||
}
|
||||
|
||||
handlePopUpToggle("removeMemberFromGroup", false);
|
||||
};
|
||||
|
||||
const getMemberName = (memberData: RemoveMemberData) => {
|
||||
if (!memberData) return "";
|
||||
if (memberData.memberType === GroupMemberType.USER) {
|
||||
return memberData.username;
|
||||
}
|
||||
return memberData.name;
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="w-full rounded-lg border border-mineshaft-600 bg-mineshaft-900 p-4">
|
||||
<div className="flex items-center justify-between border-b border-mineshaft-400 pb-4">
|
||||
<h3 className="text-lg font-medium text-mineshaft-100">Members</h3>
|
||||
<h3 className="text-lg font-medium text-mineshaft-100">Group Members</h3>
|
||||
<OrgPermissionCan I={OrgPermissionGroupActions.Edit} a={OrgPermissionSubjects.Groups}>
|
||||
{(isAllowed) => (
|
||||
<Tooltip
|
||||
className="text-center"
|
||||
content={
|
||||
isOidcManageGroupMembershipsEnabled
|
||||
? "OIDC Group Membership Mapping Enabled. Assign users to this group in your OIDC provider."
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
<div className="mb-4 flex items-center justify-center">
|
||||
<IconButton
|
||||
isDisabled={isOidcManageGroupMembershipsEnabled || !isAllowed}
|
||||
ariaLabel="copy icon"
|
||||
variant="plain"
|
||||
className="group relative"
|
||||
onClick={() => {
|
||||
handlePopUpOpen("addGroupMembers", {
|
||||
groupId,
|
||||
slug: groupSlug
|
||||
});
|
||||
}}
|
||||
>
|
||||
<FontAwesomeIcon icon={faPlus} />
|
||||
</IconButton>
|
||||
</div>
|
||||
</Tooltip>
|
||||
<div className="mb-4 flex items-center justify-center">
|
||||
<IconButton
|
||||
isDisabled={!isAllowed}
|
||||
ariaLabel="copy icon"
|
||||
variant="plain"
|
||||
className="group relative"
|
||||
onClick={() => {
|
||||
handlePopUpOpen("addGroupMembers", {
|
||||
groupId,
|
||||
slug: groupSlug
|
||||
});
|
||||
}}
|
||||
>
|
||||
<FontAwesomeIcon icon={faPlus} />
|
||||
</IconButton>
|
||||
</div>
|
||||
)}
|
||||
</OrgPermissionCan>
|
||||
</div>
|
||||
@@ -84,21 +107,19 @@ export const GroupMembersSection = ({ groupId, groupSlug }: Props) => {
|
||||
handlePopUpOpen={handlePopUpOpen}
|
||||
/>
|
||||
</div>
|
||||
<AddGroupMembersModal popUp={popUp} handlePopUpToggle={handlePopUpToggle} />
|
||||
<AddGroupMembersModal
|
||||
popUp={popUp}
|
||||
handlePopUpToggle={handlePopUpToggle}
|
||||
isOidcManageGroupMembershipsEnabled={isOidcManageGroupMembershipsEnabled}
|
||||
/>
|
||||
<DeleteActionModal
|
||||
isOpen={popUp.removeMemberFromGroup.isOpen}
|
||||
title={`Are you sure you want to remove ${
|
||||
(popUp?.removeMemberFromGroup?.data as { username: string })?.username || ""
|
||||
} from the group?`}
|
||||
title={`Are you sure you want to remove ${getMemberName(popUp?.removeMemberFromGroup?.data)} from the group?`}
|
||||
onChange={(isOpen) => handlePopUpToggle("removeMemberFromGroup", isOpen)}
|
||||
deleteKey="confirm"
|
||||
onDeleteApproved={() => {
|
||||
const userData = popUp?.removeMemberFromGroup?.data as {
|
||||
username: string;
|
||||
id: string;
|
||||
};
|
||||
|
||||
return handleRemoveUserFromGroup(userData.username);
|
||||
const memberData = popUp?.removeMemberFromGroup?.data as RemoveMemberData;
|
||||
return handleRemoveMemberFromGroup(memberData);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -1,16 +1,25 @@
|
||||
import { useMemo } from "react";
|
||||
import { useState } from "react";
|
||||
import {
|
||||
faArrowDown,
|
||||
faArrowUp,
|
||||
faCheckCircle,
|
||||
faFilter,
|
||||
faFolder,
|
||||
faMagnifyingGlass,
|
||||
faSearch
|
||||
} from "@fortawesome/free-solid-svg-icons";
|
||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||
import { HardDriveIcon, UserIcon } from "lucide-react";
|
||||
import { twMerge } from "tailwind-merge";
|
||||
|
||||
import { OrgPermissionCan } from "@app/components/permissions";
|
||||
import {
|
||||
Button,
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuTrigger,
|
||||
EmptyState,
|
||||
IconButton,
|
||||
Input,
|
||||
@@ -31,12 +40,18 @@ import {
|
||||
setUserTablePreference
|
||||
} from "@app/helpers/userTablePreferences";
|
||||
import { usePagination, useResetPageHelper } from "@app/hooks";
|
||||
import { useListGroupUsers, useOidcManageGroupMembershipsEnabled } from "@app/hooks/api";
|
||||
import { useOidcManageGroupMembershipsEnabled } from "@app/hooks/api";
|
||||
import { OrderByDirection } from "@app/hooks/api/generic/types";
|
||||
import { EFilterReturnedUsers } from "@app/hooks/api/groups/types";
|
||||
import { useListGroupMembers } from "@app/hooks/api/groups/queries";
|
||||
import {
|
||||
FilterMemberType,
|
||||
GroupMembersOrderBy,
|
||||
GroupMemberType
|
||||
} from "@app/hooks/api/groups/types";
|
||||
import { UsePopUpState } from "@app/hooks/usePopUp";
|
||||
|
||||
import { GroupMembershipRow } from "./GroupMembershipRow";
|
||||
import { GroupMembershipIdentityRow } from "./GroupMembershipIdentityRow";
|
||||
import { GroupMembershipUserRow } from "./GroupMembershipUserRow";
|
||||
|
||||
type Props = {
|
||||
groupId: string;
|
||||
@@ -47,10 +62,6 @@ type Props = {
|
||||
) => void;
|
||||
};
|
||||
|
||||
enum GroupMembersOrderBy {
|
||||
Name = "name"
|
||||
}
|
||||
|
||||
export const GroupMembersTable = ({ groupId, groupSlug, handlePopUpOpen }: Props) => {
|
||||
const {
|
||||
search,
|
||||
@@ -61,11 +72,14 @@ export const GroupMembersTable = ({ groupId, groupSlug, handlePopUpOpen }: Props
|
||||
setPerPage,
|
||||
offset,
|
||||
orderDirection,
|
||||
toggleOrderDirection
|
||||
toggleOrderDirection,
|
||||
orderBy
|
||||
} = usePagination(GroupMembersOrderBy.Name, {
|
||||
initPerPage: getUserTablePreference("groupMembersTable", PreferenceKey.PerPage, 20)
|
||||
});
|
||||
|
||||
const [memberTypeFilter, setMemberTypeFilter] = useState<FilterMemberType[]>([]);
|
||||
|
||||
const handlePerPageChange = (newPerPage: number) => {
|
||||
setPerPage(newPerPage);
|
||||
setUserTablePreference("groupMembersTable", PreferenceKey.PerPage, newPerPage);
|
||||
@@ -76,66 +90,103 @@ export const GroupMembersTable = ({ groupId, groupSlug, handlePopUpOpen }: Props
|
||||
const { data: isOidcManageGroupMembershipsEnabled = false } =
|
||||
useOidcManageGroupMembershipsEnabled(currentOrg.id);
|
||||
|
||||
const { data: groupMemberships, isPending } = useListGroupUsers({
|
||||
const { data: groupMemberships, isPending } = useListGroupMembers({
|
||||
id: groupId,
|
||||
groupSlug,
|
||||
offset,
|
||||
limit: perPage,
|
||||
search,
|
||||
filter: EFilterReturnedUsers.EXISTING_MEMBERS
|
||||
orderBy,
|
||||
orderDirection,
|
||||
memberTypeFilter: memberTypeFilter.length > 0 ? memberTypeFilter : undefined
|
||||
});
|
||||
|
||||
const filteredGroupMemberships = useMemo(() => {
|
||||
return groupMemberships && groupMemberships?.users
|
||||
? groupMemberships?.users
|
||||
?.filter((membership) => {
|
||||
const userSearchString = `${membership.firstName && membership.firstName} ${
|
||||
membership.lastName && membership.lastName
|
||||
} ${membership.email && membership.email} ${
|
||||
membership.username && membership.username
|
||||
}`;
|
||||
return userSearchString.toLowerCase().includes(search.trim().toLowerCase());
|
||||
})
|
||||
.sort((a, b) => {
|
||||
const [membershipOne, membershipTwo] =
|
||||
orderDirection === OrderByDirection.ASC ? [a, b] : [b, a];
|
||||
|
||||
const membershipOneComparisonString = membershipOne.firstName
|
||||
? membershipOne.firstName
|
||||
: membershipOne.email;
|
||||
|
||||
const membershipTwoComparisonString = membershipTwo.firstName
|
||||
? membershipTwo.firstName
|
||||
: membershipTwo.email;
|
||||
|
||||
const comparison = membershipOneComparisonString
|
||||
.toLowerCase()
|
||||
.localeCompare(membershipTwoComparisonString.toLowerCase());
|
||||
|
||||
return comparison;
|
||||
})
|
||||
: [];
|
||||
}, [groupMemberships, orderDirection, search]);
|
||||
const { members = [], totalCount = 0 } = groupMemberships ?? {};
|
||||
|
||||
useResetPageHelper({
|
||||
totalCount: filteredGroupMemberships?.length,
|
||||
totalCount,
|
||||
offset,
|
||||
setPage
|
||||
});
|
||||
|
||||
const filterOptions = [
|
||||
{
|
||||
icon: <UserIcon size={16} />,
|
||||
label: "Users",
|
||||
value: FilterMemberType.USERS
|
||||
},
|
||||
{
|
||||
icon: <HardDriveIcon size={16} />,
|
||||
label: "Machine Identities",
|
||||
value: FilterMemberType.MACHINE_IDENTITIES
|
||||
}
|
||||
];
|
||||
|
||||
return (
|
||||
<div>
|
||||
<Input
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
leftIcon={<FontAwesomeIcon icon={faMagnifyingGlass} />}
|
||||
placeholder="Search users..."
|
||||
/>
|
||||
<div className="flex gap-2">
|
||||
<Input
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
leftIcon={<FontAwesomeIcon icon={faMagnifyingGlass} />}
|
||||
placeholder="Search members..."
|
||||
/>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<IconButton
|
||||
ariaLabel="Filter Members"
|
||||
variant="plain"
|
||||
size="sm"
|
||||
className={twMerge(
|
||||
"flex h-10 w-11 items-center justify-center overflow-hidden border border-mineshaft-600 bg-mineshaft-800 p-0 transition-all hover:border-primary/60 hover:bg-primary/10",
|
||||
memberTypeFilter.length > 0 && "border-primary/50 text-primary"
|
||||
)}
|
||||
>
|
||||
<FontAwesomeIcon icon={faFilter} />
|
||||
</IconButton>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent
|
||||
sideOffset={2}
|
||||
className="max-h-[70vh] thin-scrollbar overflow-y-auto"
|
||||
align="end"
|
||||
>
|
||||
<DropdownMenuLabel>Filter by Member Type</DropdownMenuLabel>
|
||||
{filterOptions.map((option) => (
|
||||
<DropdownMenuItem
|
||||
key={option.value}
|
||||
className="flex items-center gap-2"
|
||||
iconPos="right"
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
setMemberTypeFilter((prev) => {
|
||||
if (prev.includes(option.value)) {
|
||||
return prev.filter((f) => f !== option.value);
|
||||
}
|
||||
return [...prev, option.value];
|
||||
});
|
||||
setPage(1);
|
||||
}}
|
||||
icon={
|
||||
memberTypeFilter.includes(option.value) && (
|
||||
<FontAwesomeIcon className="text-primary" icon={faCheckCircle} />
|
||||
)
|
||||
}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
{option.icon}
|
||||
{option.label}
|
||||
</div>
|
||||
</DropdownMenuItem>
|
||||
))}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
<TableContainer className="mt-4">
|
||||
<Table>
|
||||
<THead>
|
||||
<Tr>
|
||||
<Th className="w-1/3">
|
||||
<Th className="w-5" />
|
||||
<Th className="w-1/2 pl-2">
|
||||
<div className="flex items-center">
|
||||
Name
|
||||
<IconButton
|
||||
@@ -150,7 +201,6 @@ export const GroupMembersTable = ({ groupId, groupSlug, handlePopUpOpen }: Props
|
||||
</IconButton>
|
||||
</div>
|
||||
</Th>
|
||||
<Th>Email</Th>
|
||||
<Th>Added On</Th>
|
||||
<Th className="w-5" />
|
||||
</Tr>
|
||||
@@ -158,37 +208,43 @@ export const GroupMembersTable = ({ groupId, groupSlug, handlePopUpOpen }: Props
|
||||
<TBody>
|
||||
{isPending && <TableSkeleton columns={4} innerKey="group-user-memberships" />}
|
||||
{!isPending &&
|
||||
filteredGroupMemberships.slice(offset, perPage * page).map((userGroupMembership) => {
|
||||
return (
|
||||
<GroupMembershipRow
|
||||
groupMemberships?.members?.map((userGroupMembership) => {
|
||||
return userGroupMembership.type === GroupMemberType.USER ? (
|
||||
<GroupMembershipUserRow
|
||||
key={`user-group-membership-${userGroupMembership.id}`}
|
||||
user={userGroupMembership}
|
||||
handlePopUpOpen={handlePopUpOpen}
|
||||
/>
|
||||
) : (
|
||||
<GroupMembershipIdentityRow
|
||||
key={`identity-group-membership-${userGroupMembership.id}`}
|
||||
identity={userGroupMembership}
|
||||
handlePopUpOpen={handlePopUpOpen}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</TBody>
|
||||
</Table>
|
||||
{Boolean(filteredGroupMemberships.length) && (
|
||||
{Boolean(totalCount) && (
|
||||
<Pagination
|
||||
count={filteredGroupMemberships.length}
|
||||
count={totalCount}
|
||||
page={page}
|
||||
perPage={perPage}
|
||||
onChangePage={setPage}
|
||||
onChangePerPage={handlePerPageChange}
|
||||
/>
|
||||
)}
|
||||
{!isPending && !filteredGroupMemberships?.length && (
|
||||
{!isPending && !members.length && (
|
||||
<EmptyState
|
||||
title={
|
||||
groupMemberships?.users.length
|
||||
? "No users match this search..."
|
||||
groupMemberships?.members.length
|
||||
? "No members match this search..."
|
||||
: "This group does not have any members yet"
|
||||
}
|
||||
icon={groupMemberships?.users.length ? faSearch : faFolder}
|
||||
icon={groupMemberships?.members.length ? faSearch : faFolder}
|
||||
/>
|
||||
)}
|
||||
{!groupMemberships?.users.length && (
|
||||
{!groupMemberships?.members.length && (
|
||||
<OrgPermissionCan I={OrgPermissionGroupActions.Edit} a={OrgPermissionSubjects.Groups}>
|
||||
{(isAllowed) => (
|
||||
<Tooltip
|
||||
|
||||
@@ -0,0 +1,90 @@
|
||||
import { faEllipsisV, faUserMinus } from "@fortawesome/free-solid-svg-icons";
|
||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||
import { HardDriveIcon } from "lucide-react";
|
||||
|
||||
import { OrgPermissionCan } from "@app/components/permissions";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
IconButton,
|
||||
Td,
|
||||
Tooltip,
|
||||
Tr
|
||||
} from "@app/components/v2";
|
||||
import { OrgPermissionGroupActions, OrgPermissionSubjects } from "@app/context";
|
||||
import { GroupMemberType, TGroupMemberMachineIdentity } from "@app/hooks/api/groups/types";
|
||||
import { UsePopUpState } from "@app/hooks/usePopUp";
|
||||
|
||||
type Props = {
|
||||
identity: TGroupMemberMachineIdentity;
|
||||
handlePopUpOpen: (
|
||||
popUpName: keyof UsePopUpState<["removeMemberFromGroup"]>,
|
||||
data?: object
|
||||
) => void;
|
||||
};
|
||||
|
||||
export const GroupMembershipIdentityRow = ({
|
||||
identity: {
|
||||
machineIdentity: { name },
|
||||
joinedGroupAt,
|
||||
id
|
||||
},
|
||||
handlePopUpOpen
|
||||
}: Props) => {
|
||||
return (
|
||||
<Tr className="items-center" key={`group-user-${id}`}>
|
||||
<Td className="pr-0">
|
||||
<HardDriveIcon size={20} />
|
||||
</Td>
|
||||
<Td className="pl-2">
|
||||
<p>{name}</p>
|
||||
</Td>
|
||||
<Td>
|
||||
<Tooltip content={new Date(joinedGroupAt).toLocaleString()}>
|
||||
<p className="inline-block">{new Date(joinedGroupAt).toLocaleDateString()}</p>
|
||||
</Tooltip>
|
||||
</Td>
|
||||
<Td>
|
||||
<Tooltip className="max-w-sm text-center" content="Options">
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<IconButton
|
||||
ariaLabel="Options"
|
||||
colorSchema="secondary"
|
||||
className="w-6"
|
||||
variant="plain"
|
||||
>
|
||||
<FontAwesomeIcon icon={faEllipsisV} />
|
||||
</IconButton>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent sideOffset={2} align="end">
|
||||
<OrgPermissionCan I={OrgPermissionGroupActions.Edit} a={OrgPermissionSubjects.Groups}>
|
||||
{(isAllowed) => {
|
||||
return (
|
||||
<div>
|
||||
<DropdownMenuItem
|
||||
icon={<FontAwesomeIcon icon={faUserMinus} />}
|
||||
onClick={() =>
|
||||
handlePopUpOpen("removeMemberFromGroup", {
|
||||
memberType: GroupMemberType.MACHINE_IDENTITY,
|
||||
identityId: id,
|
||||
name
|
||||
})
|
||||
}
|
||||
isDisabled={!isAllowed}
|
||||
>
|
||||
Remove Identity From Group
|
||||
</DropdownMenuItem>
|
||||
</div>
|
||||
);
|
||||
}}
|
||||
</OrgPermissionCan>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</Tooltip>
|
||||
</Td>
|
||||
</Tr>
|
||||
);
|
||||
};
|
||||
@@ -1,5 +1,6 @@
|
||||
import { faEllipsisV, faUserMinus } from "@fortawesome/free-solid-svg-icons";
|
||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||
import { UserIcon } from "lucide-react";
|
||||
|
||||
import { OrgPermissionCan } from "@app/components/permissions";
|
||||
import {
|
||||
@@ -14,19 +15,23 @@ import {
|
||||
} from "@app/components/v2";
|
||||
import { OrgPermissionGroupActions, OrgPermissionSubjects, useOrganization } from "@app/context";
|
||||
import { useOidcManageGroupMembershipsEnabled } from "@app/hooks/api";
|
||||
import { TGroupUser } from "@app/hooks/api/groups/types";
|
||||
import { GroupMemberType, TGroupMemberUser } from "@app/hooks/api/groups/types";
|
||||
import { UsePopUpState } from "@app/hooks/usePopUp";
|
||||
|
||||
type Props = {
|
||||
user: TGroupUser;
|
||||
user: TGroupMemberUser;
|
||||
handlePopUpOpen: (
|
||||
popUpName: keyof UsePopUpState<["removeMemberFromGroup"]>,
|
||||
data?: object
|
||||
) => void;
|
||||
};
|
||||
|
||||
export const GroupMembershipRow = ({
|
||||
user: { firstName, lastName, username, joinedGroupAt, email, id },
|
||||
export const GroupMembershipUserRow = ({
|
||||
user: {
|
||||
user: { firstName, lastName, email, username },
|
||||
joinedGroupAt,
|
||||
id
|
||||
},
|
||||
handlePopUpOpen
|
||||
}: Props) => {
|
||||
const { currentOrg } = useOrganization();
|
||||
@@ -36,15 +41,18 @@ export const GroupMembershipRow = ({
|
||||
|
||||
return (
|
||||
<Tr className="items-center" key={`group-user-${id}`}>
|
||||
<Td>
|
||||
<p>{`${firstName ?? "-"} ${lastName ?? ""}`}</p>
|
||||
<Td className="pr-0">
|
||||
<UserIcon size={20} />
|
||||
</Td>
|
||||
<Td>
|
||||
<p>{email}</p>
|
||||
<Td className="pl-2">
|
||||
<p>
|
||||
{`${firstName ?? "-"} ${lastName ?? ""}`}{" "}
|
||||
<span className="text-mineshaft-400">({email})</span>
|
||||
</p>
|
||||
</Td>
|
||||
<Td>
|
||||
<Tooltip content={new Date(joinedGroupAt).toLocaleString()}>
|
||||
<p>{new Date(joinedGroupAt).toLocaleDateString()}</p>
|
||||
<p className="inline-block">{new Date(joinedGroupAt).toLocaleDateString()}</p>
|
||||
</Tooltip>
|
||||
</Td>
|
||||
<Td>
|
||||
@@ -75,7 +83,12 @@ export const GroupMembershipRow = ({
|
||||
<div>
|
||||
<DropdownMenuItem
|
||||
icon={<FontAwesomeIcon icon={faUserMinus} />}
|
||||
onClick={() => handlePopUpOpen("removeMemberFromGroup", { username })}
|
||||
onClick={() =>
|
||||
handlePopUpOpen("removeMemberFromGroup", {
|
||||
memberType: GroupMemberType.USER,
|
||||
username
|
||||
})
|
||||
}
|
||||
isDisabled={!isAllowed || isOidcManageGroupMembershipsEnabled}
|
||||
>
|
||||
Remove User From Group
|
||||
@@ -41,7 +41,7 @@ export const GroupProjectsSection = ({ groupId, groupSlug }: Props) => {
|
||||
return (
|
||||
<div className="w-full rounded-lg border border-mineshaft-600 bg-mineshaft-900 p-4">
|
||||
<div className="flex items-center justify-between border-b border-mineshaft-400 pb-4">
|
||||
<h3 className="text-lg font-medium text-mineshaft-100">Projects</h3>
|
||||
<h3 className="text-lg font-medium text-mineshaft-100">Group Projects</h3>
|
||||
<OrgPermissionCan I={OrgPermissionGroupActions.Edit} a={OrgPermissionSubjects.Groups}>
|
||||
{(isAllowed) => (
|
||||
<IconButton
|
||||
|
||||
@@ -31,7 +31,7 @@ import {
|
||||
import { usePagination, useResetPageHelper } from "@app/hooks";
|
||||
import { useListGroupProjects } from "@app/hooks/api";
|
||||
import { OrderByDirection } from "@app/hooks/api/generic/types";
|
||||
import { EFilterReturnedProjects } from "@app/hooks/api/groups/types";
|
||||
import { FilterReturnedProjects } from "@app/hooks/api/groups/types";
|
||||
import { UsePopUpState } from "@app/hooks/usePopUp";
|
||||
|
||||
import { GroupProjectRow } from "./GroupProjectRow";
|
||||
@@ -78,7 +78,7 @@ export const GroupProjectsTable = ({ groupId, groupSlug, handlePopUpOpen }: Prop
|
||||
search: debouncedSearch,
|
||||
orderBy,
|
||||
orderDirection,
|
||||
filter: EFilterReturnedProjects.ASSIGNED_PROJECTS
|
||||
filter: FilterReturnedProjects.ASSIGNED_PROJECTS
|
||||
});
|
||||
|
||||
const totalCount = groupMemberships?.totalCount ?? 0;
|
||||
|
||||
@@ -1,17 +1,26 @@
|
||||
import { useEffect, useMemo } from "react";
|
||||
import { useEffect, useState } from "react";
|
||||
import {
|
||||
faArrowDown,
|
||||
faArrowUp,
|
||||
faCheckCircle,
|
||||
faFilter,
|
||||
faFolder,
|
||||
faMagnifyingGlass,
|
||||
faSearch
|
||||
} from "@fortawesome/free-solid-svg-icons";
|
||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||
import { useNavigate, useSearch } from "@tanstack/react-router";
|
||||
import { HardDriveIcon, UserIcon } from "lucide-react";
|
||||
import { twMerge } from "tailwind-merge";
|
||||
|
||||
import { createNotification } from "@app/components/notifications";
|
||||
import {
|
||||
ConfirmActionModal,
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuTrigger,
|
||||
EmptyState,
|
||||
IconButton,
|
||||
Input,
|
||||
@@ -35,19 +44,21 @@ import { usePagination, usePopUp, useResetPageHelper } from "@app/hooks";
|
||||
import { useAssumeProjectPrivileges } from "@app/hooks/api";
|
||||
import { ActorType } from "@app/hooks/api/auditLogs/enums";
|
||||
import { OrderByDirection } from "@app/hooks/api/generic/types";
|
||||
import { useListProjectGroupUsers } from "@app/hooks/api/groups/queries";
|
||||
import { EFilterReturnedUsers, TGroupMembership } from "@app/hooks/api/groups/types";
|
||||
import { useListGroupMembers } from "@app/hooks/api/groups/queries";
|
||||
import {
|
||||
FilterMemberType,
|
||||
GroupMembersOrderBy,
|
||||
GroupMemberType,
|
||||
TGroupMembership
|
||||
} from "@app/hooks/api/groups/types";
|
||||
|
||||
import { GroupMembershipRow } from "./GroupMembershipRow";
|
||||
import { GroupMembershipIdentityRow } from "./GroupMembershipIdentityRow";
|
||||
import { GroupMembershipUserRow } from "./GroupMembershipUserRow";
|
||||
|
||||
type Props = {
|
||||
groupMembership: TGroupMembership;
|
||||
};
|
||||
|
||||
enum GroupMembersOrderBy {
|
||||
Name = "name"
|
||||
}
|
||||
|
||||
export const GroupMembersTable = ({ groupMembership }: Props) => {
|
||||
const navigate = useNavigate();
|
||||
const {
|
||||
@@ -59,7 +70,8 @@ export const GroupMembersTable = ({ groupMembership }: Props) => {
|
||||
setPerPage,
|
||||
offset,
|
||||
orderDirection,
|
||||
toggleOrderDirection
|
||||
toggleOrderDirection,
|
||||
orderBy
|
||||
} = usePagination(GroupMembersOrderBy.Name, {
|
||||
initPerPage: getUserTablePreference("projectGroupMembersTable", PreferenceKey.PerPage, 20)
|
||||
});
|
||||
@@ -79,6 +91,8 @@ export const GroupMembersTable = ({ groupMembership }: Props) => {
|
||||
}
|
||||
}, [username]);
|
||||
|
||||
const [memberTypeFilter, setMemberTypeFilter] = useState<FilterMemberType[]>([]);
|
||||
|
||||
const { handlePopUpToggle, popUp, handlePopUpOpen } = usePopUp(["assumePrivileges"] as const);
|
||||
|
||||
const handlePerPageChange = (newPerPage: number) => {
|
||||
@@ -89,50 +103,21 @@ export const GroupMembersTable = ({ groupMembership }: Props) => {
|
||||
const { currentOrg } = useOrganization();
|
||||
const { currentProject } = useProject();
|
||||
|
||||
const { data: groupMemberships, isPending } = useListProjectGroupUsers({
|
||||
const { data: groupMemberships, isPending } = useListGroupMembers({
|
||||
id: groupMembership.group.id,
|
||||
groupSlug: groupMembership.group.slug,
|
||||
projectId: currentProject.id,
|
||||
offset,
|
||||
limit: perPage,
|
||||
search,
|
||||
filter: EFilterReturnedUsers.EXISTING_MEMBERS
|
||||
orderBy,
|
||||
orderDirection,
|
||||
memberTypeFilter: memberTypeFilter.length > 0 ? memberTypeFilter : undefined
|
||||
});
|
||||
|
||||
const filteredGroupMemberships = useMemo(() => {
|
||||
return groupMemberships && groupMemberships?.users
|
||||
? groupMemberships?.users
|
||||
?.filter((membership) => {
|
||||
const userSearchString = `${membership.firstName && membership.firstName} ${
|
||||
membership.lastName && membership.lastName
|
||||
} ${membership.email && membership.email} ${
|
||||
membership.username && membership.username
|
||||
}`;
|
||||
return userSearchString.toLowerCase().includes(search.trim().toLowerCase());
|
||||
})
|
||||
.sort((a, b) => {
|
||||
const [membershipOne, membershipTwo] =
|
||||
orderDirection === OrderByDirection.ASC ? [a, b] : [b, a];
|
||||
|
||||
const membershipOneComparisonString = membershipOne.firstName
|
||||
? membershipOne.firstName
|
||||
: membershipOne.email;
|
||||
|
||||
const membershipTwoComparisonString = membershipTwo.firstName
|
||||
? membershipTwo.firstName
|
||||
: membershipTwo.email;
|
||||
|
||||
const comparison = membershipOneComparisonString
|
||||
.toLowerCase()
|
||||
.localeCompare(membershipTwoComparisonString.toLowerCase());
|
||||
|
||||
return comparison;
|
||||
})
|
||||
: [];
|
||||
}, [groupMemberships, orderDirection, search]);
|
||||
const { members = [], totalCount = 0 } = groupMemberships ?? {};
|
||||
|
||||
useResetPageHelper({
|
||||
totalCount: filteredGroupMemberships?.length,
|
||||
totalCount,
|
||||
offset,
|
||||
setPage
|
||||
});
|
||||
@@ -140,18 +125,24 @@ export const GroupMembersTable = ({ groupMembership }: Props) => {
|
||||
const assumePrivileges = useAssumeProjectPrivileges();
|
||||
|
||||
const handleAssumePrivileges = async () => {
|
||||
const { userId } = popUp?.assumePrivileges?.data as { userId: string };
|
||||
const { actorId, actorType } = popUp?.assumePrivileges?.data as {
|
||||
actorId: string;
|
||||
actorType: ActorType;
|
||||
};
|
||||
assumePrivileges.mutate(
|
||||
{
|
||||
actorId: userId,
|
||||
actorType: ActorType.USER,
|
||||
actorId,
|
||||
actorType,
|
||||
projectId: currentProject.id
|
||||
},
|
||||
{
|
||||
onSuccess: () => {
|
||||
createNotification({
|
||||
type: "success",
|
||||
text: "User privilege assumption has started"
|
||||
text:
|
||||
actorType === ActorType.IDENTITY
|
||||
? "Machine identity privilege assumption has started"
|
||||
: "User privilege assumption has started"
|
||||
});
|
||||
|
||||
const url = getProjectHomePage(currentProject.type, currentProject.environments);
|
||||
@@ -163,19 +154,84 @@ export const GroupMembersTable = ({ groupMembership }: Props) => {
|
||||
);
|
||||
};
|
||||
|
||||
const filterOptions = [
|
||||
{
|
||||
icon: <UserIcon size={16} />,
|
||||
label: "Users",
|
||||
value: FilterMemberType.USERS
|
||||
},
|
||||
{
|
||||
icon: <HardDriveIcon size={16} />,
|
||||
label: "Machine Identities",
|
||||
value: FilterMemberType.MACHINE_IDENTITIES
|
||||
}
|
||||
];
|
||||
|
||||
return (
|
||||
<div>
|
||||
<Input
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
leftIcon={<FontAwesomeIcon icon={faMagnifyingGlass} />}
|
||||
placeholder="Search users..."
|
||||
/>
|
||||
<div className="flex gap-2">
|
||||
<Input
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
leftIcon={<FontAwesomeIcon icon={faMagnifyingGlass} />}
|
||||
placeholder="Search members..."
|
||||
/>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<IconButton
|
||||
ariaLabel="Filter Members"
|
||||
variant="plain"
|
||||
size="sm"
|
||||
className={twMerge(
|
||||
"flex h-10 w-11 items-center justify-center overflow-hidden border border-mineshaft-600 bg-mineshaft-800 p-0 transition-all hover:border-primary/60 hover:bg-primary/10",
|
||||
memberTypeFilter.length > 0 && "border-primary/50 text-primary"
|
||||
)}
|
||||
>
|
||||
<FontAwesomeIcon icon={faFilter} />
|
||||
</IconButton>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent
|
||||
sideOffset={2}
|
||||
className="max-h-[70vh] thin-scrollbar overflow-y-auto"
|
||||
align="end"
|
||||
>
|
||||
<DropdownMenuLabel>Filter by Member Type</DropdownMenuLabel>
|
||||
{filterOptions.map((option) => (
|
||||
<DropdownMenuItem
|
||||
key={option.value}
|
||||
className="flex items-center gap-2"
|
||||
iconPos="right"
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
setMemberTypeFilter((prev) => {
|
||||
if (prev.includes(option.value)) {
|
||||
return prev.filter((f) => f !== option.value);
|
||||
}
|
||||
return [...prev, option.value];
|
||||
});
|
||||
setPage(1);
|
||||
}}
|
||||
icon={
|
||||
memberTypeFilter.includes(option.value) && (
|
||||
<FontAwesomeIcon className="text-primary" icon={faCheckCircle} />
|
||||
)
|
||||
}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
{option.icon}
|
||||
{option.label}
|
||||
</div>
|
||||
</DropdownMenuItem>
|
||||
))}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
<TableContainer className="mt-4">
|
||||
<Table>
|
||||
<THead>
|
||||
<Tr>
|
||||
<Th className="w-1/3">
|
||||
<Th className="w-5" />
|
||||
<Th className="w-1/2 pl-2">
|
||||
<div className="flex items-center">
|
||||
Name
|
||||
<IconButton
|
||||
@@ -190,7 +246,6 @@ export const GroupMembersTable = ({ groupMembership }: Props) => {
|
||||
</IconButton>
|
||||
</div>
|
||||
</Th>
|
||||
<Th>Email</Th>
|
||||
<Th>Added On</Th>
|
||||
<Th className="w-5" />
|
||||
</Tr>
|
||||
@@ -198,42 +253,58 @@ export const GroupMembersTable = ({ groupMembership }: Props) => {
|
||||
<TBody>
|
||||
{isPending && <TableSkeleton columns={4} innerKey="group-user-memberships" />}
|
||||
{!isPending &&
|
||||
filteredGroupMemberships.slice(offset, perPage * page).map((userGroupMembership) => {
|
||||
return (
|
||||
<GroupMembershipRow
|
||||
groupMemberships?.members?.map((userGroupMembership) => {
|
||||
return userGroupMembership.type === GroupMemberType.USER ? (
|
||||
<GroupMembershipUserRow
|
||||
key={`user-group-membership-${userGroupMembership.id}`}
|
||||
user={userGroupMembership}
|
||||
onAssumePrivileges={(userId) => handlePopUpOpen("assumePrivileges", { userId })}
|
||||
onAssumePrivileges={(userId) =>
|
||||
handlePopUpOpen("assumePrivileges", {
|
||||
actorId: userId,
|
||||
actorType: ActorType.USER
|
||||
})
|
||||
}
|
||||
/>
|
||||
) : (
|
||||
<GroupMembershipIdentityRow
|
||||
key={`identity-group-membership-${userGroupMembership.id}`}
|
||||
identity={userGroupMembership}
|
||||
onAssumePrivileges={(identityId) =>
|
||||
handlePopUpOpen("assumePrivileges", {
|
||||
actorId: identityId,
|
||||
actorType: ActorType.IDENTITY
|
||||
})
|
||||
}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</TBody>
|
||||
</Table>
|
||||
{Boolean(filteredGroupMemberships.length) && (
|
||||
{Boolean(totalCount) && (
|
||||
<Pagination
|
||||
count={filteredGroupMemberships.length}
|
||||
count={totalCount}
|
||||
page={page}
|
||||
perPage={perPage}
|
||||
onChangePage={setPage}
|
||||
onChangePerPage={handlePerPageChange}
|
||||
/>
|
||||
)}
|
||||
{!isPending && !filteredGroupMemberships?.length && (
|
||||
{!isPending && !members.length && (
|
||||
<EmptyState
|
||||
title={
|
||||
groupMemberships?.users.length
|
||||
? "No users match this search..."
|
||||
groupMemberships?.members.length
|
||||
? "No members match this search..."
|
||||
: "This group does not have any members yet"
|
||||
}
|
||||
icon={groupMemberships?.users.length ? faSearch : faFolder}
|
||||
icon={groupMemberships?.members.length ? faSearch : faFolder}
|
||||
/>
|
||||
)}
|
||||
</TableContainer>
|
||||
<ConfirmActionModal
|
||||
isOpen={popUp.assumePrivileges.isOpen}
|
||||
confirmKey="assume"
|
||||
title="Do you want to assume privileges of this user?"
|
||||
subTitle="This will set your privileges to those of the user for the next hour."
|
||||
title={`Do you want to assume privileges of this ${popUp.assumePrivileges?.data?.actorType === ActorType.IDENTITY ? "machine identity" : "user"}?`}
|
||||
subTitle={`This will set your privileges to those of the ${popUp.assumePrivileges?.data?.actorType === ActorType.IDENTITY ? "machine identity" : "user"} for the next hour.`}
|
||||
onChange={(isOpen) => handlePopUpToggle("assumePrivileges", isOpen)}
|
||||
onConfirmed={handleAssumePrivileges}
|
||||
buttonText="Confirm"
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
import { faEllipsisV, faUser } from "@fortawesome/free-solid-svg-icons";
|
||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||
import { HardDriveIcon } from "lucide-react";
|
||||
|
||||
import { ProjectPermissionCan } from "@app/components/permissions";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
IconButton,
|
||||
Td,
|
||||
Tooltip,
|
||||
Tr
|
||||
} from "@app/components/v2";
|
||||
import { ProjectPermissionIdentityActions, ProjectPermissionSub } from "@app/context";
|
||||
import { TGroupMemberMachineIdentity } from "@app/hooks/api/groups/types";
|
||||
|
||||
type Props = {
|
||||
identity: TGroupMemberMachineIdentity;
|
||||
onAssumePrivileges: (identityId: string) => void;
|
||||
};
|
||||
|
||||
export const GroupMembershipIdentityRow = ({
|
||||
identity: {
|
||||
machineIdentity: { name },
|
||||
joinedGroupAt,
|
||||
id
|
||||
},
|
||||
onAssumePrivileges
|
||||
}: Props) => {
|
||||
return (
|
||||
<Tr className="items-center" key={`group-identity-${id}`}>
|
||||
<Td className="pr-0">
|
||||
<HardDriveIcon size={20} />
|
||||
</Td>
|
||||
<Td className="pl-2">
|
||||
<p>{name}</p>
|
||||
</Td>
|
||||
<Td>
|
||||
<Tooltip content={new Date(joinedGroupAt).toLocaleString()}>
|
||||
<p className="inline-block">{new Date(joinedGroupAt).toLocaleDateString()}</p>
|
||||
</Tooltip>
|
||||
</Td>
|
||||
<Td>
|
||||
<Tooltip className="max-w-sm text-center" content="Options">
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<IconButton
|
||||
ariaLabel="Options"
|
||||
colorSchema="secondary"
|
||||
className="w-6"
|
||||
variant="plain"
|
||||
>
|
||||
<FontAwesomeIcon icon={faEllipsisV} />
|
||||
</IconButton>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent sideOffset={2} align="end">
|
||||
<ProjectPermissionCan
|
||||
I={ProjectPermissionIdentityActions.AssumePrivileges}
|
||||
a={ProjectPermissionSub.Identity}
|
||||
>
|
||||
{(isAllowed) => {
|
||||
return (
|
||||
<DropdownMenuItem
|
||||
icon={<FontAwesomeIcon icon={faUser} />}
|
||||
onClick={() => onAssumePrivileges(id)}
|
||||
isDisabled={!isAllowed}
|
||||
>
|
||||
Assume Privileges
|
||||
</DropdownMenuItem>
|
||||
);
|
||||
}}
|
||||
</ProjectPermissionCan>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</Tooltip>
|
||||
</Td>
|
||||
</Tr>
|
||||
);
|
||||
};
|
||||
@@ -1,5 +1,6 @@
|
||||
import { faEllipsisV, faUser } from "@fortawesome/free-solid-svg-icons";
|
||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||
import { UserIcon } from "lucide-react";
|
||||
|
||||
import { ProjectPermissionCan } from "@app/components/permissions";
|
||||
import {
|
||||
@@ -13,28 +14,35 @@ import {
|
||||
Tr
|
||||
} from "@app/components/v2";
|
||||
import { ProjectPermissionMemberActions, ProjectPermissionSub } from "@app/context";
|
||||
import { TGroupUser } from "@app/hooks/api/groups/types";
|
||||
import { TGroupMemberUser } from "@app/hooks/api/groups/types";
|
||||
|
||||
type Props = {
|
||||
user: TGroupUser;
|
||||
user: TGroupMemberUser;
|
||||
onAssumePrivileges: (userId: string) => void;
|
||||
};
|
||||
|
||||
export const GroupMembershipRow = ({
|
||||
user: { firstName, lastName, joinedGroupAt, email, id },
|
||||
export const GroupMembershipUserRow = ({
|
||||
user: {
|
||||
user: { firstName, lastName, email },
|
||||
joinedGroupAt,
|
||||
id
|
||||
},
|
||||
onAssumePrivileges
|
||||
}: Props) => {
|
||||
return (
|
||||
<Tr className="items-center" key={`group-user-${id}`}>
|
||||
<Td>
|
||||
<p>{`${firstName ?? "-"} ${lastName ?? ""}`}</p>
|
||||
<Td className="pr-0">
|
||||
<UserIcon size={20} />
|
||||
</Td>
|
||||
<Td>
|
||||
<p>{email}</p>
|
||||
<Td className="pl-2">
|
||||
<p>
|
||||
{`${firstName ?? "-"} ${lastName ?? ""}`}{" "}
|
||||
<span className="text-mineshaft-400">({email})</span>
|
||||
</p>
|
||||
</Td>
|
||||
<Td>
|
||||
<Tooltip content={new Date(joinedGroupAt).toLocaleString()}>
|
||||
<p>{new Date(joinedGroupAt).toLocaleDateString()}</p>
|
||||
<p className="inline-block">{new Date(joinedGroupAt).toLocaleDateString()}</p>
|
||||
</Tooltip>
|
||||
</Td>
|
||||
<Td>
|
||||