feature: project and org identity pagination, search and sort

This commit is contained in:
Scott Wilson
2024-09-11 07:17:17 -07:00
parent 1321aa712f
commit 16182a9d1d
22 changed files with 1061 additions and 492 deletions

1
.gitignore vendored
View File

@@ -63,6 +63,7 @@ yarn-error.log*
# Editor specific # Editor specific
.vscode/* .vscode/*
.idea/*
frontend-build frontend-build

View File

@@ -363,7 +363,12 @@ export const ORGANIZATIONS = {
membershipId: "The ID of the membership to delete." membershipId: "The ID of the membership to delete."
}, },
LIST_IDENTITY_MEMBERSHIPS: { LIST_IDENTITY_MEMBERSHIPS: {
orgId: "The ID of the organization to get identity memberships from." orgId: "The ID of the organization to get identity memberships from.",
offset: "The offset to start from. If you enter 10, it will start from the 10th identity membership.",
limit: "The number of identity memberships to return.",
orderBy: "The column to order identity memberships by.",
direction: "The direction identity memberships will be sorted in.",
textFilter: "The text string that identity membership names will be filtered by."
}, },
GET_PROJECTS: { GET_PROJECTS: {
organizationId: "The ID of the organization to get projects from." organizationId: "The ID of the organization to get projects from."
@@ -470,7 +475,12 @@ export const PROJECT_USERS = {
export const PROJECT_IDENTITIES = { export const PROJECT_IDENTITIES = {
LIST_IDENTITY_MEMBERSHIPS: { LIST_IDENTITY_MEMBERSHIPS: {
projectId: "The ID of the project to get identity memberships from." projectId: "The ID of the project to get identity memberships from.",
offset: "The offset to start from. If you enter 10, it will start from the 10th identity membership.",
limit: "The number of identity memberships to return.",
orderBy: "The column to order identity memberships by.",
direction: "The direction identity memberships will be sorted in.",
textFilter: "The text string that identity membership names will be filtered by."
}, },
GET_IDENTITY_MEMBERSHIP_BY_ID: { GET_IDENTITY_MEMBERSHIP_BY_ID: {
identityId: "The ID of the identity to get the membership for.", identityId: "The ID of the identity to get the membership for.",

View File

@@ -52,3 +52,8 @@ export enum SecretSharingAccessType {
Anyone = "anyone", Anyone = "anyone",
Organization = "organization" Organization = "organization"
} }
export enum OrderByDirection {
ASC = "asc",
DESC = "desc"
}

View File

@@ -246,12 +246,13 @@ export const registerIdentityRouter = async (server: FastifyZodProvider) => {
description: true description: true
}).optional(), }).optional(),
identity: IdentitiesSchema.pick({ name: true, id: true, authMethod: true }) identity: IdentitiesSchema.pick({ name: true, id: true, authMethod: true })
}).array() }).array(),
totalCount: z.number()
}) })
} }
}, },
handler: async (req) => { handler: async (req) => {
const identities = await server.services.identity.listOrgIdentities({ const { identityMemberships, totalCount } = await server.services.identity.listOrgIdentities({
actor: req.permission.type, actor: req.permission.type,
actorId: req.permission.id, actorId: req.permission.id,
actorAuthMethod: req.permission.authMethod, actorAuthMethod: req.permission.authMethod,
@@ -259,7 +260,7 @@ export const registerIdentityRouter = async (server: FastifyZodProvider) => {
orgId: req.query.orgId orgId: req.query.orgId
}); });
return { identities }; return { identities: identityMemberships, totalCount };
} }
}); });

View File

@@ -2,9 +2,11 @@ import { z } from "zod";
import { IdentitiesSchema, IdentityOrgMembershipsSchema, OrgRolesSchema } from "@app/db/schemas"; import { IdentitiesSchema, IdentityOrgMembershipsSchema, OrgRolesSchema } from "@app/db/schemas";
import { ORGANIZATIONS } from "@app/lib/api-docs"; import { ORGANIZATIONS } from "@app/lib/api-docs";
import { OrderByDirection } from "@app/lib/types";
import { readLimit } from "@app/server/config/rateLimiter"; import { readLimit } from "@app/server/config/rateLimiter";
import { verifyAuth } from "@app/server/plugins/auth/verify-auth"; import { verifyAuth } from "@app/server/plugins/auth/verify-auth";
import { AuthMode } from "@app/services/auth/auth-type"; import { AuthMode } from "@app/services/auth/auth-type";
import { OrgIdentityOrderBy } from "@app/services/identity/identity-types";
export const registerIdentityOrgRouter = async (server: FastifyZodProvider) => { export const registerIdentityOrgRouter = async (server: FastifyZodProvider) => {
server.route({ server.route({
@@ -24,6 +26,32 @@ export const registerIdentityOrgRouter = async (server: FastifyZodProvider) => {
params: z.object({ params: z.object({
orgId: z.string().trim().describe(ORGANIZATIONS.LIST_IDENTITY_MEMBERSHIPS.orgId) orgId: z.string().trim().describe(ORGANIZATIONS.LIST_IDENTITY_MEMBERSHIPS.orgId)
}), }),
querystring: z.object({
offset: z.coerce.number().min(0).default(0).describe(ORGANIZATIONS.LIST_IDENTITY_MEMBERSHIPS.offset).optional(),
limit: z.coerce
.number()
.min(1)
.max(20000) // TODO: temp limit until combobox added to add identity to project modal, reduce once added
.default(100)
.describe(ORGANIZATIONS.LIST_IDENTITY_MEMBERSHIPS.limit)
.optional(),
orderBy: z
.nativeEnum(OrgIdentityOrderBy)
.default(OrgIdentityOrderBy.Name)
.describe(ORGANIZATIONS.LIST_IDENTITY_MEMBERSHIPS.orderBy)
.optional(),
direction: z
.nativeEnum(OrderByDirection)
.default(OrderByDirection.ASC)
.describe(ORGANIZATIONS.LIST_IDENTITY_MEMBERSHIPS.direction)
.optional(),
textFilter: z
.string()
.trim()
.default("")
.describe(ORGANIZATIONS.LIST_IDENTITY_MEMBERSHIPS.textFilter)
.optional()
}),
response: { response: {
200: z.object({ 200: z.object({
identityMemberships: IdentityOrgMembershipsSchema.merge( identityMemberships: IdentityOrgMembershipsSchema.merge(
@@ -37,20 +65,26 @@ export const registerIdentityOrgRouter = async (server: FastifyZodProvider) => {
}).optional(), }).optional(),
identity: IdentitiesSchema.pick({ name: true, id: true, authMethod: true }) identity: IdentitiesSchema.pick({ name: true, id: true, authMethod: true })
}) })
).array() ).array(),
totalCount: z.number()
}) })
} }
}, },
handler: async (req) => { handler: async (req) => {
const identityMemberships = await server.services.identity.listOrgIdentities({ const { identityMemberships, totalCount } = await server.services.identity.listOrgIdentities({
actor: req.permission.type, actor: req.permission.type,
actorId: req.permission.id, actorId: req.permission.id,
actorAuthMethod: req.permission.authMethod, actorAuthMethod: req.permission.authMethod,
actorOrgId: req.permission.orgId, actorOrgId: req.permission.orgId,
orgId: req.params.orgId orgId: req.params.orgId,
limit: req.query.limit,
offset: req.query.offset,
orderBy: req.query.orderBy,
direction: req.query.direction,
textFilter: req.query.textFilter
}); });
return { identityMemberships }; return { identityMemberships, totalCount };
} }
}); });
}; };

View File

@@ -7,11 +7,13 @@ import {
ProjectMembershipRole, ProjectMembershipRole,
ProjectUserMembershipRolesSchema ProjectUserMembershipRolesSchema
} from "@app/db/schemas"; } from "@app/db/schemas";
import { PROJECT_IDENTITIES } from "@app/lib/api-docs"; import { ORGANIZATIONS, PROJECT_IDENTITIES } from "@app/lib/api-docs";
import { BadRequestError } from "@app/lib/errors"; import { BadRequestError } from "@app/lib/errors";
import { OrderByDirection } from "@app/lib/types";
import { readLimit, writeLimit } from "@app/server/config/rateLimiter"; import { readLimit, writeLimit } from "@app/server/config/rateLimiter";
import { verifyAuth } from "@app/server/plugins/auth/verify-auth"; import { verifyAuth } from "@app/server/plugins/auth/verify-auth";
import { AuthMode } from "@app/services/auth/auth-type"; import { AuthMode } from "@app/services/auth/auth-type";
import { ProjectIdentityOrderBy } from "@app/services/identity-project/identity-project-types";
import { ProjectUserMembershipTemporaryMode } from "@app/services/project-membership/project-membership-types"; import { ProjectUserMembershipTemporaryMode } from "@app/services/project-membership/project-membership-types";
import { SanitizedProjectSchema } from "../sanitizedSchemas"; import { SanitizedProjectSchema } from "../sanitizedSchemas";
@@ -214,6 +216,37 @@ export const registerIdentityProjectRouter = async (server: FastifyZodProvider)
params: z.object({ params: z.object({
projectId: z.string().trim().describe(PROJECT_IDENTITIES.LIST_IDENTITY_MEMBERSHIPS.projectId) projectId: z.string().trim().describe(PROJECT_IDENTITIES.LIST_IDENTITY_MEMBERSHIPS.projectId)
}), }),
querystring: z.object({
offset: z.coerce
.number()
.min(0)
.default(0)
.describe(PROJECT_IDENTITIES.LIST_IDENTITY_MEMBERSHIPS.offset)
.optional(),
limit: z.coerce
.number()
.min(1)
.max(20000) // TODO: temp limit until combobox added to add identity to project modal, reduce once added
.default(100)
.describe(PROJECT_IDENTITIES.LIST_IDENTITY_MEMBERSHIPS.limit)
.optional(),
orderBy: z
.nativeEnum(ProjectIdentityOrderBy)
.default(ProjectIdentityOrderBy.Name)
.describe(ORGANIZATIONS.LIST_IDENTITY_MEMBERSHIPS.orderBy)
.optional(),
direction: z
.nativeEnum(OrderByDirection)
.default(OrderByDirection.ASC)
.describe(ORGANIZATIONS.LIST_IDENTITY_MEMBERSHIPS.direction)
.optional(),
textFilter: z
.string()
.trim()
.default("")
.describe(PROJECT_IDENTITIES.LIST_IDENTITY_MEMBERSHIPS.textFilter)
.optional()
}),
response: { response: {
200: z.object({ 200: z.object({
identityMemberships: z identityMemberships: z
@@ -239,19 +272,25 @@ export const registerIdentityProjectRouter = async (server: FastifyZodProvider)
identity: IdentitiesSchema.pick({ name: true, id: true, authMethod: true }), identity: IdentitiesSchema.pick({ name: true, id: true, authMethod: true }),
project: SanitizedProjectSchema.pick({ name: true, id: true }) project: SanitizedProjectSchema.pick({ name: true, id: true })
}) })
.array() .array(),
totalCount: z.number()
}) })
} }
}, },
handler: async (req) => { handler: async (req) => {
const identityMemberships = await server.services.identityProject.listProjectIdentities({ const { identityMemberships, totalCount } = await server.services.identityProject.listProjectIdentities({
actor: req.permission.type, actor: req.permission.type,
actorId: req.permission.id, actorId: req.permission.id,
actorAuthMethod: req.permission.authMethod, actorAuthMethod: req.permission.authMethod,
actorOrgId: req.permission.orgId, actorOrgId: req.permission.orgId,
projectId: req.params.projectId projectId: req.params.projectId,
limit: req.query.limit,
offset: req.query.offset,
orderBy: req.query.orderBy,
direction: req.query.direction,
textFilter: req.query.textFilter
}); });
return { identityMemberships }; return { identityMemberships, totalCount };
} }
}); });

View File

@@ -4,6 +4,7 @@ import { TDbClient } from "@app/db";
import { TableName } from "@app/db/schemas"; import { TableName } from "@app/db/schemas";
import { DatabaseError } from "@app/lib/errors"; import { DatabaseError } from "@app/lib/errors";
import { ormify, sqlNestRelationships } from "@app/lib/knex"; import { ormify, sqlNestRelationships } from "@app/lib/knex";
import { TListProjectIdentityDTO } from "@app/services/identity-project/identity-project-types";
export type TIdentityProjectDALFactory = ReturnType<typeof identityProjectDALFactory>; export type TIdentityProjectDALFactory = ReturnType<typeof identityProjectDALFactory>;
@@ -107,9 +108,16 @@ export const identityProjectDALFactory = (db: TDbClient) => {
} }
}; };
const findByProjectId = async (projectId: string, filter: { identityId?: string } = {}, tx?: Knex) => { const findByProjectId = async (
projectId: string,
filter: { identityId?: string } & Pick<
TListProjectIdentityDTO,
"limit" | "offset" | "textFilter" | "orderBy" | "direction"
> = {},
tx?: Knex
) => {
try { try {
const docs = await (tx || db.replicaNode())(TableName.IdentityProjectMembership) const query = (tx || db.replicaNode())(TableName.IdentityProjectMembership)
.where(`${TableName.IdentityProjectMembership}.projectId`, projectId) .where(`${TableName.IdentityProjectMembership}.projectId`, projectId)
.join(TableName.Project, `${TableName.IdentityProjectMembership}.projectId`, `${TableName.Project}.id`) .join(TableName.Project, `${TableName.IdentityProjectMembership}.projectId`, `${TableName.Project}.id`)
.join(TableName.Identity, `${TableName.IdentityProjectMembership}.identityId`, `${TableName.Identity}.id`) .join(TableName.Identity, `${TableName.IdentityProjectMembership}.identityId`, `${TableName.Identity}.id`)
@@ -117,6 +125,10 @@ export const identityProjectDALFactory = (db: TDbClient) => {
if (filter.identityId) { if (filter.identityId) {
void qb.where("identityId", filter.identityId); void qb.where("identityId", filter.identityId);
} }
if (filter.textFilter) {
void qb.whereILike(`${TableName.Identity}.name`, `%${filter.textFilter}%`);
}
}) })
.join( .join(
TableName.IdentityProjectMembershipRole, TableName.IdentityProjectMembershipRole,
@@ -154,6 +166,22 @@ export const identityProjectDALFactory = (db: TDbClient) => {
db.ref("name").as("projectName").withSchema(TableName.Project) db.ref("name").as("projectName").withSchema(TableName.Project)
); );
if (filter.limit) {
void query.offset(filter.offset ?? 0).limit(filter.limit);
}
if (filter.orderBy) {
switch (filter.orderBy) {
case "name":
void query.orderBy(`${TableName.Identity}.${filter.orderBy}`, filter.direction);
break;
default:
// do nothing
}
}
const docs = await query;
const members = sqlNestRelationships({ const members = sqlNestRelationships({
data: docs, data: docs,
parentMapper: ({ identityId, identityName, identityAuthMethod, id, createdAt, updatedAt, projectName }) => ({ parentMapper: ({ identityId, identityName, identityAuthMethod, id, createdAt, updatedAt, projectName }) => ({
@@ -208,9 +236,36 @@ export const identityProjectDALFactory = (db: TDbClient) => {
} }
}; };
const getCountByProjectId = async (
projectId: string,
filter: { identityId?: string } & Pick<TListProjectIdentityDTO, "textFilter"> = {},
tx?: Knex
) => {
try {
const identities = await (tx || db.replicaNode())(TableName.IdentityProjectMembership)
.where(`${TableName.IdentityProjectMembership}.projectId`, projectId)
.join(TableName.Project, `${TableName.IdentityProjectMembership}.projectId`, `${TableName.Project}.id`)
.join(TableName.Identity, `${TableName.IdentityProjectMembership}.identityId`, `${TableName.Identity}.id`)
.where((qb) => {
if (filter.identityId) {
void qb.where("identityId", filter.identityId);
}
if (filter.textFilter) {
void qb.whereILike(`${TableName.Identity}.name`, `%${filter.textFilter}%`);
}
});
return identities.length;
} catch (error) {
throw new DatabaseError({ error, name: "GetCountByProjectId" });
}
};
return { return {
...identityProjectOrm, ...identityProjectOrm,
findByIdentityId, findByIdentityId,
findByProjectId findByProjectId,
getCountByProjectId
}; };
}; };

View File

@@ -268,7 +268,12 @@ export const identityProjectServiceFactory = ({
actor, actor,
actorId, actorId,
actorAuthMethod, actorAuthMethod,
actorOrgId actorOrgId,
limit,
offset,
orderBy,
direction,
textFilter
}: TListProjectIdentityDTO) => { }: TListProjectIdentityDTO) => {
const { permission } = await permissionService.getProjectPermission( const { permission } = await permissionService.getProjectPermission(
actor, actor,
@@ -279,8 +284,17 @@ export const identityProjectServiceFactory = ({
); );
ForbiddenError.from(permission).throwUnlessCan(ProjectPermissionActions.Read, ProjectPermissionSub.Identity); ForbiddenError.from(permission).throwUnlessCan(ProjectPermissionActions.Read, ProjectPermissionSub.Identity);
const identityMemberships = await identityProjectDAL.findByProjectId(projectId); const identityMemberships = await identityProjectDAL.findByProjectId(projectId, {
return identityMemberships; limit,
offset,
orderBy,
direction,
textFilter
});
const totalCount = await identityProjectDAL.getCountByProjectId(projectId, { textFilter });
return { identityMemberships, totalCount };
}; };
const getProjectIdentityByIdentityId = async ({ const getProjectIdentityByIdentityId = async ({

View File

@@ -1,4 +1,4 @@
import { TProjectPermission } from "@app/lib/types"; import { OrderByDirection, TProjectPermission } from "@app/lib/types";
import { ProjectUserMembershipTemporaryMode } from "../project-membership/project-membership-types"; import { ProjectUserMembershipTemporaryMode } from "../project-membership/project-membership-types";
@@ -40,8 +40,18 @@ export type TDeleteProjectIdentityDTO = {
identityId: string; identityId: string;
} & TProjectPermission; } & TProjectPermission;
export type TListProjectIdentityDTO = TProjectPermission; export type TListProjectIdentityDTO = {
limit?: number;
offset?: number;
orderBy?: ProjectIdentityOrderBy;
direction?: OrderByDirection;
textFilter?: string;
} & TProjectPermission;
export type TGetProjectIdentityByIdentityIdDTO = { export type TGetProjectIdentityByIdentityIdDTO = {
identityId: string; identityId: string;
} & TProjectPermission; } & TProjectPermission;
export enum ProjectIdentityOrderBy {
Name = "name"
}

View File

@@ -4,6 +4,8 @@ import { TDbClient } from "@app/db";
import { TableName, TIdentityOrgMemberships } from "@app/db/schemas"; import { TableName, TIdentityOrgMemberships } from "@app/db/schemas";
import { DatabaseError } from "@app/lib/errors"; import { DatabaseError } from "@app/lib/errors";
import { ormify, selectAllTableCols } from "@app/lib/knex"; import { ormify, selectAllTableCols } from "@app/lib/knex";
import { OrderByDirection } from "@app/lib/types";
import { TListOrgIdentitiesByOrgIdDTO } from "@app/services/identity/identity-types";
export type TIdentityOrgDALFactory = ReturnType<typeof identityOrgDALFactory>; export type TIdentityOrgDALFactory = ReturnType<typeof identityOrgDALFactory>;
@@ -27,9 +29,20 @@ export const identityOrgDALFactory = (db: TDbClient) => {
} }
}; };
const find = async (filter: Partial<TIdentityOrgMemberships>, tx?: Knex) => { const find = async (
{
limit,
offset = 0,
orderBy,
direction = OrderByDirection.ASC,
textFilter,
...filter
}: Partial<TIdentityOrgMemberships> &
Pick<TListOrgIdentitiesByOrgIdDTO, "offset" | "limit" | "orderBy" | "direction" | "textFilter">,
tx?: Knex
) => {
try { try {
const docs = await (tx || db.replicaNode())(TableName.IdentityOrgMembership) const query = (tx || db.replicaNode())(TableName.IdentityOrgMembership)
.where(filter) .where(filter)
.join(TableName.Identity, `${TableName.IdentityOrgMembership}.identityId`, `${TableName.Identity}.id`) .join(TableName.Identity, `${TableName.IdentityOrgMembership}.identityId`, `${TableName.Identity}.id`)
.leftJoin(TableName.OrgRoles, `${TableName.IdentityOrgMembership}.roleId`, `${TableName.OrgRoles}.id`) .leftJoin(TableName.OrgRoles, `${TableName.IdentityOrgMembership}.roleId`, `${TableName.OrgRoles}.id`)
@@ -44,6 +57,30 @@ export const identityOrgDALFactory = (db: TDbClient) => {
.select(db.ref("id").as("identityId").withSchema(TableName.Identity)) .select(db.ref("id").as("identityId").withSchema(TableName.Identity))
.select(db.ref("name").as("identityName").withSchema(TableName.Identity)) .select(db.ref("name").as("identityName").withSchema(TableName.Identity))
.select(db.ref("authMethod").as("identityAuthMethod").withSchema(TableName.Identity)); .select(db.ref("authMethod").as("identityAuthMethod").withSchema(TableName.Identity));
if (limit) {
void query.offset(offset).limit(limit);
}
if (orderBy) {
switch (orderBy) {
case "name":
void query.orderBy(`${TableName.Identity}.${orderBy}`, direction);
break;
case "role":
void query.orderBy(`${TableName.IdentityOrgMembership}.${orderBy}`, direction);
break;
default:
// do nothing
}
}
if (textFilter?.length) {
void query.whereILike(`${TableName.Identity}.name`, `%${textFilter}%`);
}
const docs = await query;
return docs.map( return docs.map(
({ ({
crId, crId,
@@ -79,5 +116,26 @@ export const identityOrgDALFactory = (db: TDbClient) => {
} }
}; };
return { ...identityOrgOrm, find, findOne }; const countAllOrgIdentities = async (
{ textFilter, ...filter }: Partial<TIdentityOrgMemberships> & Pick<TListOrgIdentitiesByOrgIdDTO, "textFilter">,
tx?: Knex
) => {
try {
const query = (tx || db.replicaNode())(TableName.IdentityOrgMembership)
.where(filter)
.join(TableName.Identity, `${TableName.IdentityOrgMembership}.identityId`, `${TableName.Identity}.id`);
if (textFilter?.length) {
void query.whereILike(`${TableName.Identity}.name`, `%${textFilter}%`);
}
const identities = await query;
return identities.length;
} catch (error) {
throw new DatabaseError({ error, name: "countAllOrgIdentities" });
}
};
return { ...identityOrgOrm, find, findOne, countAllOrgIdentities };
}; };

View File

@@ -6,7 +6,6 @@ import { OrgPermissionActions, OrgPermissionSubjects } from "@app/ee/services/pe
import { TPermissionServiceFactory } from "@app/ee/services/permission/permission-service"; import { TPermissionServiceFactory } from "@app/ee/services/permission/permission-service";
import { isAtLeastAsPrivileged } from "@app/lib/casl"; import { isAtLeastAsPrivileged } from "@app/lib/casl";
import { BadRequestError, ForbiddenRequestError, NotFoundError } from "@app/lib/errors"; import { BadRequestError, ForbiddenRequestError, NotFoundError } from "@app/lib/errors";
import { TOrgPermission } from "@app/lib/types";
import { TIdentityProjectDALFactory } from "@app/services/identity-project/identity-project-dal"; import { TIdentityProjectDALFactory } from "@app/services/identity-project/identity-project-dal";
import { ActorType } from "../auth/auth-type"; import { ActorType } from "../auth/auth-type";
@@ -16,6 +15,7 @@ import {
TCreateIdentityDTO, TCreateIdentityDTO,
TDeleteIdentityDTO, TDeleteIdentityDTO,
TGetIdentityByIdDTO, TGetIdentityByIdDTO,
TListOrgIdentitiesByOrgIdDTO,
TListProjectIdentitiesByIdentityIdDTO, TListProjectIdentitiesByIdentityIdDTO,
TUpdateIdentityDTO TUpdateIdentityDTO
} from "./identity-types"; } from "./identity-types";
@@ -195,14 +195,36 @@ export const identityServiceFactory = ({
return { ...deletedIdentity, orgId: identityOrgMembership.orgId }; return { ...deletedIdentity, orgId: identityOrgMembership.orgId };
}; };
const listOrgIdentities = async ({ orgId, actor, actorId, actorAuthMethod, actorOrgId }: TOrgPermission) => { const listOrgIdentities = async ({
orgId,
actor,
actorId,
actorAuthMethod,
actorOrgId,
limit,
offset,
orderBy,
direction,
textFilter
}: TListOrgIdentitiesByOrgIdDTO) => {
const { permission } = await permissionService.getOrgPermission(actor, actorId, orgId, actorAuthMethod, actorOrgId); const { permission } = await permissionService.getOrgPermission(actor, actorId, orgId, actorAuthMethod, actorOrgId);
ForbiddenError.from(permission).throwUnlessCan(OrgPermissionActions.Read, OrgPermissionSubjects.Identity); ForbiddenError.from(permission).throwUnlessCan(OrgPermissionActions.Read, OrgPermissionSubjects.Identity);
const identityMemberships = await identityOrgMembershipDAL.find({ const identityMemberships = await identityOrgMembershipDAL.find({
[`${TableName.IdentityOrgMembership}.orgId` as "orgId"]: orgId [`${TableName.IdentityOrgMembership}.orgId` as "orgId"]: orgId,
limit,
offset,
orderBy,
direction,
textFilter
}); });
return identityMemberships;
const totalCount = await identityOrgMembershipDAL.countAllOrgIdentities({
[`${TableName.IdentityOrgMembership}.orgId` as "orgId"]: orgId,
textFilter
});
return { identityMemberships, totalCount };
}; };
const listProjectIdentitiesByIdentityId = async ({ const listProjectIdentitiesByIdentityId = async ({

View File

@@ -1,5 +1,5 @@
import { IPType } from "@app/lib/ip"; import { IPType } from "@app/lib/ip";
import { TOrgPermission } from "@app/lib/types"; import { OrderByDirection, TOrgPermission } from "@app/lib/types";
export type TCreateIdentityDTO = { export type TCreateIdentityDTO = {
role: string; role: string;
@@ -29,3 +29,16 @@ export interface TIdentityTrustedIp {
export type TListProjectIdentitiesByIdentityIdDTO = { export type TListProjectIdentitiesByIdentityIdDTO = {
identityId: string; identityId: string;
} & Omit<TOrgPermission, "orgId">; } & Omit<TOrgPermission, "orgId">;
export type TListOrgIdentitiesByOrgIdDTO = {
limit?: number;
offset?: number;
orderBy?: OrgIdentityOrderBy;
direction?: OrderByDirection;
textFilter?: string;
} & TOrgPermission;
export enum OrgIdentityOrderBy {
Name = "name",
Role = "role"
}

View File

@@ -11,6 +11,7 @@ type Props = {
isDisabled?: boolean; isDisabled?: boolean;
isReadOnly?: boolean; isReadOnly?: boolean;
autoCapitalization?: boolean; autoCapitalization?: boolean;
containerClassName?: string;
}; };
const inputVariants = cva( const inputVariants = cva(
@@ -71,6 +72,7 @@ export const Input = forwardRef<HTMLInputElement, InputProps>(
( (
{ {
className, className,
containerClassName,
isRounded = true, isRounded = true,
isFullWidth = true, isFullWidth = true,
isDisabled, isDisabled,
@@ -94,7 +96,15 @@ export const Input = forwardRef<HTMLInputElement, InputProps>(
}; };
return ( return (
<div className={inputParentContainerVariants({ isRounded, isError, isFullWidth, variant })}> <div
className={inputParentContainerVariants({
isRounded,
isError,
isFullWidth,
variant,
className: containerClassName
})}
>
{leftIcon && <span className="absolute left-0 ml-3 text-sm">{leftIcon}</span>} {leftIcon && <span className="absolute left-0 ml-3 text-sm">{leftIcon}</span>}
<input <input
{...props} {...props}

View File

@@ -50,7 +50,7 @@ export const Pagination = ({
> >
<div className="mr-6 flex items-center space-x-2"> <div className="mr-6 flex items-center space-x-2">
<div className="text-xs"> <div className="text-xs">
{(page - 1) * perPage} - {Math.min((page - 1) * perPage + perPage, count)} of {count} {(page - 1) * perPage + 1} - {Math.min((page - 1) * perPage + perPage, count)} of {count}
</div> </div>
<DropdownMenu> <DropdownMenu>
<DropdownMenuTrigger asChild> <DropdownMenuTrigger asChild>

View File

@@ -469,3 +469,8 @@ export type RevokeTokenDTO = {
export type RevokeTokenRes = { export type RevokeTokenRes = {
message: string; message: string;
}; };
export type TProjectIdentitiesList = {
identityMemberships: IdentityMembership[];
totalCount: number;
};

View File

@@ -1,9 +1,8 @@
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { useMutation, useQuery, useQueryClient, UseQueryOptions } from "@tanstack/react-query";
import { apiRequest } from "@app/config/request"; import { apiRequest } from "@app/config/request";
import { TGroupOrgMembership } from "../groups/types"; import { TGroupOrgMembership } from "../groups/types";
import { IdentityMembershipOrg } from "../identities/types";
import { import {
BillingDetails, BillingDetails,
Invoice, Invoice,
@@ -14,6 +13,8 @@ import {
PmtMethod, PmtMethod,
ProductsTable, ProductsTable,
TaxID, TaxID,
TListOrgIdentitiesDTO,
TOrgIdentitiesList,
UpdateOrgDTO UpdateOrgDTO
} from "./types"; } from "./types";
@@ -30,6 +31,12 @@ export const organizationKeys = {
getOrgLicenses: (orgId: string) => [{ orgId }, "organization-licenses"] as const, getOrgLicenses: (orgId: string) => [{ orgId }, "organization-licenses"] as const,
getOrgIdentityMemberships: (orgId: string) => getOrgIdentityMemberships: (orgId: string) =>
[{ orgId }, "organization-identity-memberships"] as const, [{ orgId }, "organization-identity-memberships"] as const,
// allows invalidation using above key without knowing params
getOrgIdentityMembershipsWithParams: ({
organizationId: orgId,
...params
}: TListOrgIdentitiesDTO) =>
[...organizationKeys.getOrgIdentityMemberships(orgId), params] as const,
getOrgGroups: (orgId: string) => [{ orgId }, "organization-groups"] as const getOrgGroups: (orgId: string) => [{ orgId }, "organization-groups"] as const
}; };
@@ -360,19 +367,51 @@ export const useGetOrgLicenses = (organizationId: string) => {
}); });
}; };
export const useGetIdentityMembershipOrgs = (organizationId: string) => { export const useGetIdentityMembershipOrgs = (
{
organizationId,
offset = 0,
limit = 100,
orderBy = "name",
direction = "asc",
textFilter = ""
}: TListOrgIdentitiesDTO,
options?: Omit<
UseQueryOptions<
TOrgIdentitiesList,
unknown,
TOrgIdentitiesList,
ReturnType<typeof organizationKeys.getOrgIdentityMembershipsWithParams>
>,
"queryKey" | "queryFn"
>
) => {
const params = new URLSearchParams({
offset: String(offset),
limit: String(limit),
orderBy: String(orderBy),
direction: String(direction),
textFilter: String(textFilter)
});
return useQuery({ return useQuery({
queryKey: organizationKeys.getOrgIdentityMemberships(organizationId), queryKey: organizationKeys.getOrgIdentityMembershipsWithParams({
organizationId,
offset,
limit,
orderBy,
direction,
textFilter
}),
queryFn: async () => { queryFn: async () => {
const { const { data } = await apiRequest.get<TOrgIdentitiesList>(
data: { identityMemberships } `/api/v2/organizations/${organizationId}/identity-memberships`,
} = await apiRequest.get<{ identityMemberships: IdentityMembershipOrg[] }>( { params }
`/api/v2/organizations/${organizationId}/identity-memberships`
); );
return identityMemberships; return data;
}, },
enabled: true enabled: true,
...options
}); });
}; };

View File

@@ -1,3 +1,5 @@
import { IdentityMembershipOrg } from "@app/hooks/api/identities/types";
export type Organization = { export type Organization = {
id: string; id: string;
name: string; name: string;
@@ -102,3 +104,17 @@ export type ProductsTable = {
head: ProductsTableHead[]; head: ProductsTableHead[];
rows: ProductsTableRow[]; rows: ProductsTableRow[];
}; };
export type TListOrgIdentitiesDTO = {
organizationId: string;
offset?: number;
limit?: number;
orderBy?: string;
direction?: string;
textFilter?: string;
};
export type TOrgIdentitiesList = {
identityMemberships: IdentityMembershipOrg[];
totalCount: number;
};

View File

@@ -1,4 +1,4 @@
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { useMutation, useQuery, useQueryClient, UseQueryOptions } from "@tanstack/react-query";
import { apiRequest } from "@app/config/request"; import { apiRequest } from "@app/config/request";
@@ -8,7 +8,7 @@ import { TCertificate } from "../certificates/types";
import { TCertificateTemplate } from "../certificateTemplates/types"; import { TCertificateTemplate } from "../certificateTemplates/types";
import { TGroupMembership } from "../groups/types"; import { TGroupMembership } from "../groups/types";
import { identitiesKeys } from "../identities/queries"; import { identitiesKeys } from "../identities/queries";
import { IdentityMembership } from "../identities/types"; import { TProjectIdentitiesList } from "../identities/types";
import { IntegrationAuth } from "../integrationAuth/types"; import { IntegrationAuth } from "../integrationAuth/types";
import { TIntegration } from "../integrations/types"; import { TIntegration } from "../integrations/types";
import { TPkiAlert } from "../pkiAlerts/types"; import { TPkiAlert } from "../pkiAlerts/types";
@@ -25,6 +25,7 @@ import {
NameWorkspaceSecretsDTO, NameWorkspaceSecretsDTO,
RenameWorkspaceDTO, RenameWorkspaceDTO,
TGetUpgradeProjectStatusDTO, TGetUpgradeProjectStatusDTO,
TListProjectIdentitiesDTO,
ToggleAutoCapitalizationDTO, ToggleAutoCapitalizationDTO,
TUpdateWorkspaceIdentityRoleDTO, TUpdateWorkspaceIdentityRoleDTO,
TUpdateWorkspaceUserRoleDTO, TUpdateWorkspaceUserRoleDTO,
@@ -49,6 +50,12 @@ export const workspaceKeys = {
getWorkspaceUsers: (workspaceId: string) => [{ workspaceId }, "workspace-users"] as const, getWorkspaceUsers: (workspaceId: string) => [{ workspaceId }, "workspace-users"] as const,
getWorkspaceIdentityMemberships: (workspaceId: string) => getWorkspaceIdentityMemberships: (workspaceId: string) =>
[{ workspaceId }, "workspace-identity-memberships"] as const, [{ workspaceId }, "workspace-identity-memberships"] as const,
// allows invalidation using above key without knowing params
getWorkspaceIdentityMembershipsWithParams: ({
workspaceId,
...params
}: TListProjectIdentitiesDTO) =>
[...workspaceKeys.getWorkspaceIdentityMemberships(workspaceId), params] as const,
getWorkspaceGroupMemberships: (workspaceId: string) => getWorkspaceGroupMemberships: (workspaceId: string) =>
[{ workspaceId }, "workspace-groups"] as const, [{ workspaceId }, "workspace-groups"] as const,
getWorkspaceCas: ({ projectSlug }: { projectSlug: string }) => getWorkspaceCas: ({ projectSlug }: { projectSlug: string }) =>
@@ -526,18 +533,51 @@ export const useDeleteIdentityFromWorkspace = () => {
}); });
}; };
export const useGetWorkspaceIdentityMemberships = (workspaceId: string) => { export const useGetWorkspaceIdentityMemberships = (
{
workspaceId,
offset = 0,
limit = 100,
orderBy = "name",
direction = "asc",
textFilter = ""
}: TListProjectIdentitiesDTO,
options?: Omit<
UseQueryOptions<
TProjectIdentitiesList,
unknown,
TProjectIdentitiesList,
ReturnType<typeof workspaceKeys.getWorkspaceIdentityMembershipsWithParams>
>,
"queryKey" | "queryFn"
>
) => {
return useQuery({ return useQuery({
queryKey: workspaceKeys.getWorkspaceIdentityMemberships(workspaceId), queryKey: workspaceKeys.getWorkspaceIdentityMembershipsWithParams({
workspaceId,
offset,
limit,
orderBy,
direction,
textFilter
}),
queryFn: async () => { queryFn: async () => {
const { const params = new URLSearchParams({
data: { identityMemberships } offset: String(offset),
} = await apiRequest.get<{ identityMemberships: IdentityMembership[] }>( limit: String(limit),
`/api/v2/workspace/${workspaceId}/identity-memberships` orderBy: String(orderBy),
direction: String(direction),
textFilter: String(textFilter)
});
const { data } = await apiRequest.get<TProjectIdentitiesList>(
`/api/v2/workspace/${workspaceId}/identity-memberships`,
{ params }
); );
return identityMemberships; return data;
}, },
enabled: true enabled: true,
...options
}); });
}; };

View File

@@ -141,3 +141,12 @@ export type TUpdateWorkspaceGroupRoleDTO = {
} }
)[]; )[];
}; };
export type TListProjectIdentitiesDTO = {
workspaceId: string;
offset?: number;
limit?: number;
orderBy?: string;
direction?: string;
textFilter?: string;
};

View File

@@ -1,5 +1,12 @@
import { useEffect, useState } from "react";
import { useRouter } from "next/router"; import { useRouter } from "next/router";
import { faEllipsis, faServer } from "@fortawesome/free-solid-svg-icons"; import {
faArrowDown,
faArrowUp,
faEllipsis,
faMagnifyingGlass,
faServer
} from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { twMerge } from "tailwind-merge"; import { twMerge } from "tailwind-merge";
@@ -11,8 +18,12 @@ import {
DropdownMenuItem, DropdownMenuItem,
DropdownMenuTrigger, DropdownMenuTrigger,
EmptyState, EmptyState,
IconButton,
Input,
Pagination,
Select, Select,
SelectItem, SelectItem,
Spinner,
Table, Table,
TableContainer, TableContainer,
TableSkeleton, TableSkeleton,
@@ -23,6 +34,7 @@ import {
Tr Tr
} from "@app/components/v2"; } from "@app/components/v2";
import { OrgPermissionActions, OrgPermissionSubjects, useOrganization } from "@app/context"; import { OrgPermissionActions, OrgPermissionSubjects, useOrganization } from "@app/context";
import { useDebounce } from "@app/hooks";
import { useGetIdentityMembershipOrgs, useGetOrgRoles, useUpdateIdentity } from "@app/hooks/api"; import { useGetIdentityMembershipOrgs, useGetOrgRoles, useUpdateIdentity } from "@app/hooks/api";
import { UsePopUpState } from "@app/hooks/usePopUp"; import { UsePopUpState } from "@app/hooks/usePopUp";
@@ -36,22 +48,58 @@ type Props = {
) => void; ) => void;
}; };
const INIT_PER_PAGE = 10;
export const IdentityTable = ({ handlePopUpOpen }: Props) => { export const IdentityTable = ({ handlePopUpOpen }: Props) => {
const router = useRouter(); const router = useRouter();
const { currentOrg } = useOrganization(); const { currentOrg } = useOrganization();
const orgId = currentOrg?.id || ""; const [page, setPage] = useState(1);
const [perPage, setPerPage] = useState(INIT_PER_PAGE);
const [direction, setDirection] = useState("asc");
const [orderBy, setOrderBy] = useState("name");
const [textFilter, setTextFilter] = useState("");
const debouncedTextFilter = useDebounce(textFilter);
const organizationId = currentOrg?.id || "";
const { mutateAsync: updateMutateAsync } = useUpdateIdentity(); const { mutateAsync: updateMutateAsync } = useUpdateIdentity();
const { data, isLoading } = useGetIdentityMembershipOrgs(orgId);
const { data: roles } = useGetOrgRoles(orgId); const offset = (page - 1) * perPage;
const { data, isLoading, isFetching } = useGetIdentityMembershipOrgs(
{
organizationId,
offset,
limit: perPage,
direction,
orderBy,
textFilter: debouncedTextFilter
},
{ keepPreviousData: true }
);
useEffect(() => {
// reset page if no longer valid
if (data && data.totalCount < offset) setPage(1);
}, [data?.totalCount]);
const { data: roles } = useGetOrgRoles(organizationId);
const handleSort = (column: string) => {
if (column === orderBy) {
setDirection((prev) => (prev === "asc" ? "desc" : "asc"));
return;
}
setOrderBy(column);
setDirection("asc");
};
const handleChangeRole = async ({ identityId, role }: { identityId: string; role: string }) => { const handleChangeRole = async ({ identityId, role }: { identityId: string; role: string }) => {
try { try {
await updateMutateAsync({ await updateMutateAsync({
identityId, identityId,
role, role,
organizationId: orgId organizationId
}); });
createNotification({ createNotification({
@@ -71,124 +119,170 @@ export const IdentityTable = ({ handlePopUpOpen }: Props) => {
}; };
return ( return (
<TableContainer> <div>
<Table> <Input
<THead> containerClassName="mb-4"
<Tr> value={textFilter}
<Th>Name</Th> onChange={(e) => setTextFilter(e.target.value)}
<Th>Role</Th> leftIcon={<FontAwesomeIcon icon={faMagnifyingGlass} />}
<Th className="w-5" /> placeholder="Search identities by name..."
</Tr> />
</THead> <TableContainer>
<TBody> <Table>
{isLoading && <TableSkeleton columns={4} innerKey="org-identities" />} <THead>
{!isLoading && <Tr className="h-14">
data?.map(({ identity: { id, name }, role, customRole }) => { <Th className="w-1/2">
return ( <div className="flex items-center">
<Tr Name
className="h-10 cursor-pointer transition-colors duration-100 hover:bg-mineshaft-700" <IconButton
key={`identity-${id}`} variant="plain"
onClick={() => router.push(`/org/${orgId}/identities/${id}`)} className={`ml-2 ${orderBy === "name" ? "" : "opacity-30"}`}
> ariaLabel="sort"
<Td>{name}</Td> onClick={() => handleSort("name")}
<Td> >
<OrgPermissionCan <FontAwesomeIcon
I={OrgPermissionActions.Edit} icon={direction === "desc" && orderBy === "name" ? faArrowUp : faArrowDown}
a={OrgPermissionSubjects.Identity} />
> </IconButton>
{(isAllowed) => { </div>
return ( </Th>
<Select <Th>
value={role === "custom" ? (customRole?.slug as string) : role} <div className="flex items-center">
isDisabled={!isAllowed} Role
className="w-40 bg-mineshaft-600" <IconButton
dropdownContainerClassName="border border-mineshaft-600 bg-mineshaft-800" variant="plain"
onValueChange={(selectedRole) => className={`ml-2 ${orderBy === "role" ? "" : "opacity-30"}`}
handleChangeRole({ ariaLabel="sort"
identityId: id, onClick={() => handleSort("role")}
role: selectedRole >
}) <FontAwesomeIcon
} icon={direction === "desc" && orderBy === "role" ? faArrowUp : faArrowDown}
> />
{(roles || []).map(({ slug, name: roleName }) => ( </IconButton>
<SelectItem value={slug} key={`owner-option-${slug}`}> </div>
{roleName} </Th>
</SelectItem> <Th className="w-16">{isFetching ? <Spinner size="xs" /> : null}</Th>
))}
</Select>
);
}}
</OrgPermissionCan>
</Td>
<Td>
<DropdownMenu>
<DropdownMenuTrigger asChild className="rounded-lg">
<div className="hover:text-primary-400 data-[state=open]:text-primary-400">
<FontAwesomeIcon size="sm" icon={faEllipsis} />
</div>
</DropdownMenuTrigger>
<DropdownMenuContent align="start" className="p-1">
<OrgPermissionCan
I={OrgPermissionActions.Edit}
a={OrgPermissionSubjects.Identity}
>
{(isAllowed) => (
<DropdownMenuItem
className={twMerge(
!isAllowed && "pointer-events-none cursor-not-allowed opacity-50"
)}
onClick={(e) => {
e.stopPropagation();
router.push(`/org/${orgId}/identities/${id}`);
}}
disabled={!isAllowed}
>
Edit Identity
</DropdownMenuItem>
)}
</OrgPermissionCan>
<OrgPermissionCan
I={OrgPermissionActions.Delete}
a={OrgPermissionSubjects.Identity}
>
{(isAllowed) => (
<DropdownMenuItem
className={twMerge(
isAllowed
? "hover:!bg-red-500 hover:!text-white"
: "pointer-events-none cursor-not-allowed opacity-50"
)}
onClick={(e) => {
e.stopPropagation();
handlePopUpOpen("deleteIdentity", {
identityId: id,
name
});
}}
disabled={!isAllowed}
>
Delete Identity
</DropdownMenuItem>
)}
</OrgPermissionCan>
</DropdownMenuContent>
</DropdownMenu>
</Td>
</Tr>
);
})}
{!isLoading && data && data?.length === 0 && (
<Tr>
<Td colSpan={4}>
<EmptyState
title="No identities have been created in this organization"
icon={faServer}
/>
</Td>
</Tr> </Tr>
)} </THead>
</TBody> <TBody>
</Table> {isLoading && <TableSkeleton columns={3} innerKey="org-identities" />}
</TableContainer> {!isLoading &&
data?.identityMemberships.map(({ identity: { id, name }, role, customRole }) => {
return (
<Tr
className="h-10 cursor-pointer transition-colors duration-100 hover:bg-mineshaft-700"
key={`identity-${id}`}
onClick={() => router.push(`/org/${organizationId}/identities/${id}`)}
>
<Td>{name}</Td>
<Td>
<OrgPermissionCan
I={OrgPermissionActions.Edit}
a={OrgPermissionSubjects.Identity}
>
{(isAllowed) => {
return (
<Select
value={role === "custom" ? (customRole?.slug as string) : role}
isDisabled={!isAllowed}
className="w-40 bg-mineshaft-600"
dropdownContainerClassName="border border-mineshaft-600 bg-mineshaft-800"
onValueChange={(selectedRole) =>
handleChangeRole({
identityId: id,
role: selectedRole
})
}
>
{(roles || []).map(({ slug, name: roleName }) => (
<SelectItem value={slug} key={`owner-option-${slug}`}>
{roleName}
</SelectItem>
))}
</Select>
);
}}
</OrgPermissionCan>
</Td>
<Td>
<DropdownMenu>
<DropdownMenuTrigger asChild className="rounded-lg">
<div className="flex justify-center hover:text-primary-400 data-[state=open]:text-primary-400">
<FontAwesomeIcon size="sm" icon={faEllipsis} />
</div>
</DropdownMenuTrigger>
<DropdownMenuContent align="start" className="p-1">
<OrgPermissionCan
I={OrgPermissionActions.Edit}
a={OrgPermissionSubjects.Identity}
>
{(isAllowed) => (
<DropdownMenuItem
className={twMerge(
!isAllowed && "pointer-events-none cursor-not-allowed opacity-50"
)}
onClick={(e) => {
e.stopPropagation();
router.push(`/org/${organizationId}/identities/${id}`);
}}
disabled={!isAllowed}
>
Edit Identity
</DropdownMenuItem>
)}
</OrgPermissionCan>
<OrgPermissionCan
I={OrgPermissionActions.Delete}
a={OrgPermissionSubjects.Identity}
>
{(isAllowed) => (
<DropdownMenuItem
className={twMerge(
isAllowed
? "hover:!bg-red-500 hover:!text-white"
: "pointer-events-none cursor-not-allowed opacity-50"
)}
onClick={(e) => {
e.stopPropagation();
handlePopUpOpen("deleteIdentity", {
identityId: id,
name
});
}}
disabled={!isAllowed}
>
Delete Identity
</DropdownMenuItem>
)}
</OrgPermissionCan>
</DropdownMenuContent>
</DropdownMenu>
</Td>
</Tr>
);
})}
</TBody>
</Table>
{!isLoading && data && data.totalCount > INIT_PER_PAGE && (
<Pagination
count={data.totalCount}
page={page}
perPage={perPage}
onChangePage={(newPage) => setPage(newPage)}
onChangePerPage={(newPerPage) => setPerPage(newPerPage)}
/>
)}
{!isLoading && data && data?.identityMemberships.length === 0 && (
<EmptyState
title={
debouncedTextFilter.trim().length > 0
? "No identities match search filter"
: "No identities have been created in this organization"
}
icon={faServer}
/>
)}
</TableContainer>
</div>
); );
}; };

View File

@@ -1,11 +1,15 @@
import { useEffect, useState } from "react";
import Link from "next/link"; import Link from "next/link";
import { import {
faArrowUpRightFromSquare, faArrowDown,
faClock, faArrowUp,
faEdit, faArrowUpRightFromSquare,
faPlus, faClock,
faServer, faEdit,
faXmark faMagnifyingGlass,
faPlus,
faServer,
faXmark
} from "@fortawesome/free-solid-svg-icons"; } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { format } from "date-fns"; import { format } from "date-fns";
@@ -15,28 +19,32 @@ import { twMerge } from "tailwind-merge";
import { createNotification } from "@app/components/notifications"; import { createNotification } from "@app/components/notifications";
import { ProjectPermissionCan } from "@app/components/permissions"; import { ProjectPermissionCan } from "@app/components/permissions";
import { import {
Button, Button,
DeleteActionModal, DeleteActionModal,
EmptyState, EmptyState,
HoverCard, HoverCard,
HoverCardContent, HoverCardContent,
HoverCardTrigger, HoverCardTrigger,
IconButton, IconButton,
Modal, Input,
ModalContent, Modal,
Table, ModalContent,
TableContainer, Pagination,
TableSkeleton, Spinner,
Tag, Table,
TBody, TableContainer,
Td, TableSkeleton,
Th, Tag,
THead, TBody,
Tooltip, Td,
Tr Th,
THead,
Tooltip,
Tr
} from "@app/components/v2"; } from "@app/components/v2";
import { ProjectPermissionActions, ProjectPermissionSub, useWorkspace } from "@app/context"; import { ProjectPermissionActions, ProjectPermissionSub, useWorkspace } from "@app/context";
import { withProjectPermission } from "@app/hoc"; import { withProjectPermission } from "@app/hoc";
import { useDebounce } from "@app/hooks";
import { useDeleteIdentityFromWorkspace, useGetWorkspaceIdentityMemberships } from "@app/hooks/api"; import { useDeleteIdentityFromWorkspace, useGetWorkspaceIdentityMemberships } from "@app/hooks/api";
import { IdentityMembership } from "@app/hooks/api/identities/types"; import { IdentityMembership } from "@app/hooks/api/identities/types";
import { ProjectMembershipRole } from "@app/hooks/api/roles/types"; import { ProjectMembershipRole } from "@app/hooks/api/roles/types";
@@ -46,306 +54,374 @@ import { IdentityModal } from "./components/IdentityModal";
import { IdentityRoleForm } from "./components/IdentityRoleForm"; import { IdentityRoleForm } from "./components/IdentityRoleForm";
const MAX_ROLES_TO_BE_SHOWN_IN_TABLE = 2; const MAX_ROLES_TO_BE_SHOWN_IN_TABLE = 2;
const INIT_PER_PAGE = 10;
const formatRoleName = (role: string, customRoleName?: string) => { const formatRoleName = (role: string, customRoleName?: string) => {
if (role === ProjectMembershipRole.Custom) return customRoleName; if (role === ProjectMembershipRole.Custom) return customRoleName;
if (role === ProjectMembershipRole.Member) return "Developer"; if (role === ProjectMembershipRole.Member) return "Developer";
if (role === ProjectMembershipRole.NoAccess) return "No access"; if (role === ProjectMembershipRole.NoAccess) return "No access";
return role; return role;
}; };
export const IdentityTab = withProjectPermission( export const IdentityTab = withProjectPermission(
() => { () => {
const { currentWorkspace } = useWorkspace(); const { currentWorkspace } = useWorkspace();
const workspaceId = currentWorkspace?.id ?? ""; const [page, setPage] = useState(1);
const [perPage, setPerPage] = useState(INIT_PER_PAGE);
const [direction, setDirection] = useState("asc");
const [orderBy, setOrderBy] = useState("name");
const [textFilter, setTextFilter] = useState("");
const debouncedTextFilter = useDebounce(textFilter);
const { data, isLoading } = useGetWorkspaceIdentityMemberships(currentWorkspace?.id || ""); const workspaceId = currentWorkspace?.id ?? "";
const { mutateAsync: deleteMutateAsync } = useDeleteIdentityFromWorkspace();
const { popUp, handlePopUpOpen, handlePopUpClose, handlePopUpToggle } = usePopUp([ const offset = (page - 1) * perPage;
"identity", const { data, isLoading, isFetching } = useGetWorkspaceIdentityMemberships(
"deleteIdentity", {
"upgradePlan", workspaceId: currentWorkspace?.id || "",
"updateRole" offset,
] as const); limit: perPage,
direction,
orderBy,
textFilter: debouncedTextFilter
},
{ keepPreviousData: true }
);
const { mutateAsync: deleteMutateAsync } = useDeleteIdentityFromWorkspace();
const onRemoveIdentitySubmit = async (identityId: string) => { const { popUp, handlePopUpOpen, handlePopUpClose, handlePopUpToggle } = usePopUp([
try { "identity",
await deleteMutateAsync({ "deleteIdentity",
identityId, "upgradePlan",
workspaceId "updateRole"
}); ] as const);
createNotification({ const onRemoveIdentitySubmit = async (identityId: string) => {
text: "Successfully removed identity from project", try {
type: "success" await deleteMutateAsync({
}); identityId,
workspaceId
});
handlePopUpClose("deleteIdentity"); createNotification({
} catch (err) { text: "Successfully removed identity from project",
console.error(err); type: "success"
const error = err as any; });
const text = error?.response?.data?.message ?? "Failed to remove identity from project";
createNotification({ handlePopUpClose("deleteIdentity");
text, } catch (err) {
type: "error" console.error(err);
}); const error = err as any;
} const text = error?.response?.data?.message ?? "Failed to remove identity from project";
};
return ( createNotification({
<motion.div text,
key="identity-role-panel" type: "error"
transition={{ duration: 0.15 }} });
initial={{ opacity: 0, translateX: 30 }} }
animate={{ opacity: 1, translateX: 0 }} };
exit={{ opacity: 0, translateX: 30 }}
useEffect(() => {
// reset page if no longer valid
if (data && data.totalCount < offset) setPage(1);
}, [data?.totalCount]);
const handleSort = (column: string) => {
if (column === orderBy) {
setDirection((prev) => (prev === "asc" ? "desc" : "asc"));
return;
}
setOrderBy(column);
setDirection("asc");
};
return (
<motion.div
key="identity-role-panel"
transition={{ duration: 0.15 }}
initial={{ opacity: 0, translateX: 30 }}
animate={{ opacity: 1, translateX: 0 }}
exit={{ opacity: 0, translateX: 30 }}
>
<div className="mb-6 rounded-lg border border-mineshaft-600 bg-mineshaft-900 p-4">
<div className="mb-4 flex items-center justify-between">
<p className="text-xl font-semibold text-mineshaft-100">Identities</p>
<div className="flex w-full justify-end pr-4">
<Link href="https://infisical.com/docs/documentation/platform/identities/overview">
<span className="w-max cursor-pointer rounded-md border border-mineshaft-500 bg-mineshaft-600 px-4 py-2 text-mineshaft-200 duration-200 hover:border-primary/40 hover:bg-primary/10 hover:text-white">
Documentation{" "}
<FontAwesomeIcon
icon={faArrowUpRightFromSquare}
className="mb-[0.06rem] ml-1 text-xs"
/>
</span>
</Link>
</div>
<ProjectPermissionCan
I={ProjectPermissionActions.Create}
a={ProjectPermissionSub.Identity}
> >
<div className="mb-6 rounded-lg border border-mineshaft-600 bg-mineshaft-900 p-4"> {(isAllowed) => (
<div className="mb-4 flex items-center justify-between"> <Button
<p className="text-xl font-semibold text-mineshaft-100">Identities</p> colorSchema="primary"
<div className="flex w-full justify-end pr-4"> type="submit"
<Link href="https://infisical.com/docs/documentation/platform/identities/overview"> leftIcon={<FontAwesomeIcon icon={faPlus} />}
<span className="w-max cursor-pointer rounded-md border border-mineshaft-500 bg-mineshaft-600 px-4 py-2 text-mineshaft-200 duration-200 hover:border-primary/40 hover:bg-primary/10 hover:text-white"> onClick={() => handlePopUpOpen("identity")}
Documentation{" "} isDisabled={!isAllowed}
<FontAwesomeIcon >
icon={faArrowUpRightFromSquare} Add identity
className="mb-[0.06rem] ml-1 text-xs" </Button>
/> )}
</span> </ProjectPermissionCan>
</Link> </div>
</div> <Input
<ProjectPermissionCan containerClassName="mb-4"
I={ProjectPermissionActions.Create} value={textFilter}
a={ProjectPermissionSub.Identity} onChange={(e) => setTextFilter(e.target.value)}
> leftIcon={<FontAwesomeIcon icon={faMagnifyingGlass} />}
{(isAllowed) => ( placeholder="Search identities by name..."
<Button />
colorSchema="primary" <TableContainer>
type="submit" <Table>
leftIcon={<FontAwesomeIcon icon={faPlus} />} <THead>
onClick={() => handlePopUpOpen("identity")} <Tr className="h-14">
isDisabled={!isAllowed} <Th className="w-1/3">
> <div className="flex items-center">
Add identity Name
</Button> <IconButton
)} variant="plain"
</ProjectPermissionCan> className={`ml-2 ${orderBy === "name" ? "" : "opacity-30"}`}
ariaLabel="sort"
onClick={() => handleSort("name")}
>
<FontAwesomeIcon
icon={
direction === "desc" && orderBy === "name" ? faArrowUp : faArrowDown
}
/>
</IconButton>
</div> </div>
<TableContainer> </Th>
<Table> <Th className="w-1/3">Role</Th>
<THead> <Th>Added on</Th>
<Tr> <Th className="w-16">{isFetching ? <Spinner size="xs" /> : null}</Th>
<Th>Name</Th> </Tr>
<Th>Role</Th> </THead>
<Th>Added on</Th> <TBody>
<Th className="w-5" /> {isLoading && <TableSkeleton columns={4} innerKey="project-identities" />}
</Tr> {!isLoading &&
</THead> data &&
<TBody> data.identityMemberships.length > 0 &&
{isLoading && <TableSkeleton columns={7} innerKey="project-identities" />} data.identityMemberships.map((identityMember, index) => {
{!isLoading && const {
data && identity: { id, name },
data.length > 0 && roles,
data.map((identityMember, index) => { createdAt
const { } = identityMember;
identity: { id, name }, return (
roles, <Tr className="h-10" key={`st-v3-${id}`}>
createdAt <Td>{name}</Td>
} = identityMember;
return (
<Tr className="h-10" key={`st-v3-${id}`}>
<Td>{name}</Td>
<Td> <Td>
<div className="flex items-center space-x-2"> <div className="flex items-center space-x-2">
{roles {roles
.slice(0, MAX_ROLES_TO_BE_SHOWN_IN_TABLE) .slice(0, MAX_ROLES_TO_BE_SHOWN_IN_TABLE)
.map( .map(
({ ({
role, role,
customRoleName, customRoleName,
id: roleId, id: roleId,
isTemporary, isTemporary,
temporaryAccessEndTime temporaryAccessEndTime
}) => { }) => {
const isExpired = const isExpired =
new Date() > new Date(temporaryAccessEndTime || ("" as string)); new Date() > new Date(temporaryAccessEndTime || ("" as string));
return ( return (
<Tag key={roleId}> <Tag key={roleId}>
<div className="flex items-center space-x-2"> <div className="flex items-center space-x-2">
<div className="capitalize"> <div className="capitalize">
{formatRoleName(role, customRoleName)} {formatRoleName(role, customRoleName)}
</div> </div>
{isTemporary && ( {isTemporary && (
<div> <div>
<Tooltip <Tooltip
content={ content={
isExpired isExpired
? "Timed role expired" ? "Timed role expired"
: "Timed role access" : "Timed role access"
} }
> >
<FontAwesomeIcon <FontAwesomeIcon
icon={faClock} icon={faClock}
className={twMerge(isExpired && "text-red-600")} className={twMerge(isExpired && "text-red-600")}
/> />
</Tooltip> </Tooltip>
</div> </div>
)} )}
</div> </div>
</Tag> </Tag>
); );
} }
)} )}
{roles.length > MAX_ROLES_TO_BE_SHOWN_IN_TABLE && ( {roles.length > MAX_ROLES_TO_BE_SHOWN_IN_TABLE && (
<HoverCard> <HoverCard>
<HoverCardTrigger> <HoverCardTrigger>
<Tag>+{roles.length - MAX_ROLES_TO_BE_SHOWN_IN_TABLE}</Tag> <Tag>+{roles.length - MAX_ROLES_TO_BE_SHOWN_IN_TABLE}</Tag>
</HoverCardTrigger> </HoverCardTrigger>
<HoverCardContent className="border border-gray-700 bg-mineshaft-800 p-4"> <HoverCardContent className="border border-gray-700 bg-mineshaft-800 p-4">
{roles {roles
.slice(MAX_ROLES_TO_BE_SHOWN_IN_TABLE) .slice(MAX_ROLES_TO_BE_SHOWN_IN_TABLE)
.map( .map(
({ ({
role, role,
customRoleName, customRoleName,
id: roleId, id: roleId,
isTemporary, isTemporary,
temporaryAccessEndTime temporaryAccessEndTime
}) => { }) => {
const isExpired = const isExpired =
new Date() > new Date() >
new Date(temporaryAccessEndTime || ("" as string)); new Date(temporaryAccessEndTime || ("" as string));
return ( return (
<Tag key={roleId} className="capitalize"> <Tag key={roleId} className="capitalize">
<div className="flex items-center space-x-2"> <div className="flex items-center space-x-2">
<div>{formatRoleName(role, customRoleName)}</div> <div>{formatRoleName(role, customRoleName)}</div>
{isTemporary && ( {isTemporary && (
<div> <div>
<Tooltip <Tooltip
content={ content={
isExpired isExpired
? "Access expired" ? "Access expired"
: "Temporary access" : "Temporary access"
} }
> >
<FontAwesomeIcon <FontAwesomeIcon
icon={faClock} icon={faClock}
className={twMerge( className={twMerge(
new Date() > new Date() >
new Date( new Date(
temporaryAccessEndTime as string temporaryAccessEndTime as string
) && "text-red-600" ) && "text-red-600"
)} )}
/> />
</Tooltip> </Tooltip>
</div> </div>
)} )}
</div> </div>
</Tag> </Tag>
);
}
)}
</HoverCardContent>
</HoverCard>
)}
<Tooltip content="Edit permission">
<IconButton
size="sm"
variant="plain"
ariaLabel="update-role"
onClick={() =>
handlePopUpOpen("updateRole", { ...identityMember, index })
}
>
<FontAwesomeIcon icon={faEdit} />
</IconButton>
</Tooltip>
</div>
</Td>
<Td>{format(new Date(createdAt), "yyyy-MM-dd")}</Td>
<Td className="flex justify-end">
<ProjectPermissionCan
I={ProjectPermissionActions.Delete}
a={ProjectPermissionSub.Identity}
>
{(isAllowed) => (
<IconButton
onClick={() => {
handlePopUpOpen("deleteIdentity", {
identityId: id,
name
});
}}
size="lg"
colorSchema="danger"
variant="plain"
ariaLabel="update"
className="ml-4"
isDisabled={!isAllowed}
>
<FontAwesomeIcon icon={faXmark} />
</IconButton>
)}
</ProjectPermissionCan>
</Td>
</Tr>
); );
})} }
{!isLoading && data && data?.length === 0 && ( )}
<Tr> </HoverCardContent>
<Td colSpan={7}> </HoverCard>
<EmptyState )}
title="No identities have been added to this project" <Tooltip content="Edit permission">
icon={faServer} <IconButton
/> size="sm"
</Td> variant="plain"
</Tr> ariaLabel="update-role"
)} onClick={() =>
</TBody> handlePopUpOpen("updateRole", { ...identityMember, index })
</Table> }
</TableContainer> >
<Modal <FontAwesomeIcon icon={faEdit} />
isOpen={popUp.updateRole.isOpen} </IconButton>
onOpenChange={(state) => handlePopUpToggle("updateRole", state)} </Tooltip>
> </div>
<ModalContent </Td>
className="max-w-3xl" <Td>{format(new Date(createdAt), "yyyy-MM-dd")}</Td>
title={`Manage Access for ${(popUp.updateRole.data as IdentityMembership)?.identity?.name <Td className="flex justify-end">
}`} <ProjectPermissionCan
subTitle={` I={ProjectPermissionActions.Delete}
a={ProjectPermissionSub.Identity}
>
{(isAllowed) => (
<IconButton
onClick={() => {
handlePopUpOpen("deleteIdentity", {
identityId: id,
name
});
}}
size="lg"
colorSchema="danger"
variant="plain"
ariaLabel="update"
className="ml-4"
isDisabled={!isAllowed}
>
<FontAwesomeIcon icon={faXmark} />
</IconButton>
)}
</ProjectPermissionCan>
</Td>
</Tr>
);
})}
</TBody>
</Table>
{!isLoading && data && data.totalCount > INIT_PER_PAGE && (
<Pagination
count={data.totalCount}
page={page}
perPage={perPage}
onChangePage={(newPage) => setPage(newPage)}
onChangePerPage={(newPerPage) => setPerPage(newPerPage)}
/>
)}
{!isLoading && data && data?.identityMemberships.length === 0 && (
<EmptyState
title={
debouncedTextFilter.trim().length > 0
? "No identities match search filter"
: "No identities have been added to this project"
}
icon={faServer}
/>
)}
</TableContainer>
<Modal
isOpen={popUp.updateRole.isOpen}
onOpenChange={(state) => handlePopUpToggle("updateRole", state)}
>
<ModalContent
className="max-w-3xl"
title={`Manage Access for ${
(popUp.updateRole.data as IdentityMembership)?.identity?.name
}`}
subTitle={`
Configure role-based access control by assigning machine identities a mix of roles and specific privileges. An identity will gain access to all actions within the roles assigned to it, not just the actions those roles share in common. You must choose at least one permanent role. Configure role-based access control by assigning machine identities a mix of roles and specific privileges. An identity will gain access to all actions within the roles assigned to it, not just the actions those roles share in common. You must choose at least one permanent role.
`} `}
> >
<IdentityRoleForm <IdentityRoleForm
onOpenUpgradeModal={(description) => onOpenUpgradeModal={(description) =>
handlePopUpOpen("upgradePlan", { description }) handlePopUpOpen("upgradePlan", { description })
} }
identityProjectMember={ identityProjectMember={
data?.[ data?.identityMemberships[
(popUp.updateRole?.data as IdentityMembership & { index: number })?.index (popUp.updateRole?.data as IdentityMembership & { index: number })?.index
] as IdentityMembership ] as IdentityMembership
} }
/> />
</ModalContent> </ModalContent>
</Modal> </Modal>
<IdentityModal popUp={popUp} handlePopUpToggle={handlePopUpToggle} /> <IdentityModal popUp={popUp} handlePopUpToggle={handlePopUpToggle} />
<DeleteActionModal <DeleteActionModal
isOpen={popUp.deleteIdentity.isOpen} isOpen={popUp.deleteIdentity.isOpen}
title={`Are you sure want to remove ${(popUp?.deleteIdentity?.data as { name: string })?.name || "" title={`Are you sure want to remove ${
} from the project?`} (popUp?.deleteIdentity?.data as { name: string })?.name || ""
onChange={(isOpen) => handlePopUpToggle("deleteIdentity", isOpen)} } from the project?`}
deleteKey="confirm" onChange={(isOpen) => handlePopUpToggle("deleteIdentity", isOpen)}
onDeleteApproved={() => deleteKey="confirm"
onRemoveIdentitySubmit( onDeleteApproved={() =>
(popUp?.deleteIdentity?.data as { identityId: string })?.identityId onRemoveIdentitySubmit(
) (popUp?.deleteIdentity?.data as { identityId: string })?.identityId
} )
/> }
</div> />
</motion.div> </div>
); </motion.div>
}, );
{ action: ProjectPermissionActions.Read, subject: ProjectPermissionSub.Identity } },
{ action: ProjectPermissionActions.Read, subject: ProjectPermissionSub.Identity }
); );

View File

@@ -5,7 +5,15 @@ import { yupResolver } from "@hookform/resolvers/yup";
import * as yup from "yup"; import * as yup from "yup";
import { createNotification } from "@app/components/notifications"; import { createNotification } from "@app/components/notifications";
import { Button, FormControl, Modal, ModalContent, Select, SelectItem } from "@app/components/v2"; import {
Button,
FormControl,
Modal,
ModalClose,
ModalContent,
Select,
SelectItem
} from "@app/components/v2";
import { useOrganization, useWorkspace } from "@app/context"; import { useOrganization, useWorkspace } from "@app/context";
import { import {
useAddIdentityToWorkspace, useAddIdentityToWorkspace,
@@ -33,12 +41,20 @@ export const IdentityModal = ({ popUp, handlePopUpToggle }: Props) => {
const { currentOrg } = useOrganization(); const { currentOrg } = useOrganization();
const { currentWorkspace } = useWorkspace(); const { currentWorkspace } = useWorkspace();
const orgId = currentOrg?.id || ""; const organizationId = currentOrg?.id || "";
const workspaceId = currentWorkspace?.id || ""; const workspaceId = currentWorkspace?.id || "";
const projectSlug = currentWorkspace?.slug || ""; const projectSlug = currentWorkspace?.slug || "";
const { data: identityMembershipOrgs } = useGetIdentityMembershipOrgs(orgId); const { data: identityMembershipOrgsData } = useGetIdentityMembershipOrgs({
const { data: identityMemberships } = useGetWorkspaceIdentityMemberships(workspaceId); organizationId,
limit: 20000 // TODO: this is temp to preserve functionality for bitcoindepot, will replace with combobox in separate PR
});
const identityMembershipOrgs = identityMembershipOrgsData?.identityMemberships;
const { data: identityMembershipsData } = useGetWorkspaceIdentityMemberships({
workspaceId,
limit: 20000 // TODO: this is temp to preserve functionality for bitcoindepot, will optimize in PR referenced above
});
const identityMemberships = identityMembershipsData?.identityMemberships;
const { data: roles } = useGetProjectRoles(projectSlug); const { data: roles } = useGetProjectRoles(projectSlug);
@@ -158,9 +174,11 @@ export const IdentityModal = ({ popUp, handlePopUpToggle }: Props) => {
> >
{popUp?.identity?.data ? "Update" : "Create"} {popUp?.identity?.data ? "Update" : "Create"}
</Button> </Button>
<Button colorSchema="secondary" variant="plain"> <ModalClose asChild>
Cancel <Button colorSchema="secondary" variant="plain">
</Button> Cancel
</Button>
</ModalClose>
</div> </div>
</form> </form>
) : ( ) : (