Merge pull request #4363 from Infisical/daniel/scim-deprovisioning-ui

feat(approvals): visualization of deprovisioned scim users
This commit is contained in:
Daniel Hougaard
2025-08-25 18:08:07 +02:00
committed by GitHub
20 changed files with 422 additions and 78 deletions

View File

@@ -133,6 +133,7 @@ export const registerAccessApprovalRequestRouter = async (server: FastifyZodProv
approvals: z.number(),
approvers: z
.object({
isOrgMembershipActive: z.boolean().nullable().optional(),
userId: z.string().nullable().optional(),
sequence: z.number().nullable().optional(),
approvalsRequired: z.number().nullable().optional(),
@@ -150,6 +151,7 @@ export const registerAccessApprovalRequestRouter = async (server: FastifyZodProv
}),
reviewers: z
.object({
isOrgMembershipActive: z.boolean().nullable().optional(),
userId: z.string(),
status: z.string()
})

View File

@@ -294,12 +294,13 @@ export const registerSecretApprovalRequestRouter = async (server: FastifyZodProv
200: z.object({
approval: SecretApprovalRequestsSchema.merge(
z.object({
// secretPath: z.string(),
policy: z.object({
id: z.string(),
name: z.string(),
approvals: z.number(),
approvers: approvalRequestUser.array(),
approvers: approvalRequestUser
.extend({ isOrgMembershipActive: z.boolean().nullable().optional() })
.array(),
bypassers: approvalRequestUser.array(),
secretPath: z.string().optional().nullable(),
enforcementLevel: z.string(),
@@ -309,7 +310,13 @@ export const registerSecretApprovalRequestRouter = async (server: FastifyZodProv
environment: z.string(),
statusChangedByUser: approvalRequestUser.optional(),
committerUser: approvalRequestUser.nullish(),
reviewers: approvalRequestUser.extend({ status: z.string(), comment: z.string().optional() }).array(),
reviewers: approvalRequestUser
.extend({
status: z.string(),
comment: z.string().optional(),
isOrgMembershipActive: z.boolean().nullable().optional()
})
.array(),
secretPath: z.string(),
commits: secretRawSchema
.omit({ _id: true, environment: true, workspace: true, type: true, version: true, secretValue: true })

View File

@@ -5,6 +5,7 @@ import {
AccessApprovalRequestsSchema,
TableName,
TAccessApprovalRequests,
TOrgMemberships,
TUserGroupMembership,
TUsers
} from "@app/db/schemas";
@@ -144,6 +145,7 @@ export interface TAccessApprovalRequestDALFactory extends Omit<TOrmify<TableName
approvalsRequired: number | null | undefined;
email: string | null | undefined;
username: string;
isOrgMembershipActive: boolean;
}
| {
userId: string;
@@ -151,6 +153,7 @@ export interface TAccessApprovalRequestDALFactory extends Omit<TOrmify<TableName
approvalsRequired: number | null | undefined;
email: string | null | undefined;
username: string;
isOrgMembershipActive: boolean;
}
)[];
bypassers: string[];
@@ -202,6 +205,7 @@ export interface TAccessApprovalRequestDALFactory extends Omit<TOrmify<TableName
reviewers: {
userId: string;
status: string;
isOrgMembershipActive: boolean;
}[];
approvers: (
| {
@@ -210,6 +214,7 @@ export interface TAccessApprovalRequestDALFactory extends Omit<TOrmify<TableName
approvalsRequired: number | null | undefined;
email: string | null | undefined;
username: string;
isOrgMembershipActive: boolean;
}
| {
userId: string;
@@ -217,6 +222,7 @@ export interface TAccessApprovalRequestDALFactory extends Omit<TOrmify<TableName
approvalsRequired: number | null | undefined;
email: string | null | undefined;
username: string;
isOrgMembershipActive: boolean;
}
)[];
bypassers: string[];
@@ -288,6 +294,24 @@ export const accessApprovalRequestDALFactory = (db: TDbClient): TAccessApprovalR
`requestedByUser.id`
)
.leftJoin<TOrgMemberships>(
db(TableName.OrgMembership).as("approverOrgMembership"),
`${TableName.AccessApprovalPolicyApprover}.approverUserId`,
`approverOrgMembership.userId`
)
.leftJoin<TOrgMemberships>(
db(TableName.OrgMembership).as("approverGroupOrgMembership"),
`${TableName.Users}.id`,
`approverGroupOrgMembership.userId`
)
.leftJoin<TOrgMemberships>(
db(TableName.OrgMembership).as("reviewerOrgMembership"),
`${TableName.AccessApprovalRequestReviewer}.reviewerUserId`,
`reviewerOrgMembership.userId`
)
.leftJoin(TableName.Environment, `${TableName.AccessApprovalPolicy}.envId`, `${TableName.Environment}.id`)
.select(selectAllTableCols(TableName.AccessApprovalRequest))
@@ -300,6 +324,10 @@ export const accessApprovalRequestDALFactory = (db: TDbClient): TAccessApprovalR
db.ref("allowedSelfApprovals").withSchema(TableName.AccessApprovalPolicy).as("policyAllowedSelfApprovals"),
db.ref("envId").withSchema(TableName.AccessApprovalPolicy).as("policyEnvId"),
db.ref("deletedAt").withSchema(TableName.AccessApprovalPolicy).as("policyDeletedAt"),
db.ref("isActive").withSchema("approverOrgMembership").as("approverIsOrgMembershipActive"),
db.ref("isActive").withSchema("approverGroupOrgMembership").as("approverGroupIsOrgMembershipActive"),
db.ref("isActive").withSchema("reviewerOrgMembership").as("reviewerIsOrgMembershipActive"),
db.ref("maxTimePeriod").withSchema(TableName.AccessApprovalPolicy).as("policyMaxTimePeriod")
)
.select(db.ref("approverUserId").withSchema(TableName.AccessApprovalPolicyApprover))
@@ -396,17 +424,26 @@ export const accessApprovalRequestDALFactory = (db: TDbClient): TAccessApprovalR
{
key: "reviewerUserId",
label: "reviewers" as const,
mapper: ({ reviewerUserId: userId, reviewerStatus: status }) => (userId ? { userId, status } : undefined)
mapper: ({ reviewerUserId: userId, reviewerStatus: status, reviewerIsOrgMembershipActive }) =>
userId ? { userId, status, isOrgMembershipActive: reviewerIsOrgMembershipActive } : undefined
},
{
key: "approverUserId",
label: "approvers" as const,
mapper: ({ approverUserId, approverSequence, approvalsRequired, approverUsername, approverEmail }) => ({
mapper: ({
approverUserId,
approverSequence,
approvalsRequired,
approverUsername,
approverEmail,
approverIsOrgMembershipActive
}) => ({
userId: approverUserId,
sequence: approverSequence,
approvalsRequired,
email: approverEmail,
username: approverUsername
username: approverUsername,
isOrgMembershipActive: approverIsOrgMembershipActive
})
},
{
@@ -417,13 +454,15 @@ export const accessApprovalRequestDALFactory = (db: TDbClient): TAccessApprovalR
approverSequence,
approvalsRequired,
approverGroupEmail,
approverGroupUsername
approverGroupUsername,
approverGroupIsOrgMembershipActive
}) => ({
userId: approverGroupUserId,
sequence: approverSequence,
approvalsRequired,
email: approverGroupEmail,
username: approverGroupUsername
username: approverGroupUsername,
isOrgMembershipActive: approverGroupIsOrgMembershipActive
})
},
{ key: "bypasserUserId", label: "bypassers" as const, mapper: ({ bypasserUserId }) => bypasserUserId },

View File

@@ -87,6 +87,7 @@ export interface TAccessApprovalRequestServiceFactory {
approvalsRequired: number | null | undefined;
email: string | null | undefined;
username: string;
isOrgMembershipActive: boolean;
}
| {
userId: string;
@@ -94,6 +95,7 @@ export interface TAccessApprovalRequestServiceFactory {
approvalsRequired: number | null | undefined;
email: string | null | undefined;
username: string;
isOrgMembershipActive: boolean;
}
)[];
bypassers: string[];
@@ -145,6 +147,7 @@ export interface TAccessApprovalRequestServiceFactory {
reviewers: {
userId: string;
status: string;
isOrgMembershipActive: boolean;
}[];
approvers: (
| {
@@ -153,6 +156,7 @@ export interface TAccessApprovalRequestServiceFactory {
approvalsRequired: number | null | undefined;
email: string | null | undefined;
username: string;
isOrgMembershipActive: boolean;
}
| {
userId: string;
@@ -160,6 +164,7 @@ export interface TAccessApprovalRequestServiceFactory {
approvalsRequired: number | null | undefined;
email: string | null | undefined;
username: string;
isOrgMembershipActive: boolean;
}
)[];
bypassers: string[];

View File

@@ -4,6 +4,7 @@ import { TDbClient } from "@app/db";
import {
SecretApprovalRequestsSchema,
TableName,
TOrgMemberships,
TSecretApprovalRequests,
TSecretApprovalRequestsSecrets,
TUserGroupMembership,
@@ -107,11 +108,32 @@ export const secretApprovalRequestDALFactory = (db: TDbClient) => {
`${TableName.SecretApprovalRequestReviewer}.reviewerUserId`,
`secretApprovalReviewerUser.id`
)
.leftJoin<TOrgMemberships>(
db(TableName.OrgMembership).as("approverOrgMembership"),
`${TableName.SecretApprovalPolicyApprover}.approverUserId`,
`approverOrgMembership.userId`
)
.leftJoin<TOrgMemberships>(
db(TableName.OrgMembership).as("approverGroupOrgMembership"),
`secretApprovalPolicyGroupApproverUser.id`,
`approverGroupOrgMembership.userId`
)
.leftJoin<TOrgMemberships>(
db(TableName.OrgMembership).as("reviewerOrgMembership"),
`${TableName.SecretApprovalRequestReviewer}.reviewerUserId`,
`reviewerOrgMembership.userId`
)
.select(selectAllTableCols(TableName.SecretApprovalRequest))
.select(
tx.ref("approverUserId").withSchema(TableName.SecretApprovalPolicyApprover),
tx.ref("userId").withSchema("approverUserGroupMembership").as("approverGroupUserId"),
tx.ref("email").withSchema("secretApprovalPolicyApproverUser").as("approverEmail"),
tx.ref("isActive").withSchema("approverOrgMembership").as("approverIsOrgMembershipActive"),
tx.ref("isActive").withSchema("approverGroupOrgMembership").as("approverGroupIsOrgMembershipActive"),
tx.ref("email").withSchema("secretApprovalPolicyGroupApproverUser").as("approverGroupEmail"),
tx.ref("username").withSchema("secretApprovalPolicyApproverUser").as("approverUsername"),
tx.ref("username").withSchema("secretApprovalPolicyGroupApproverUser").as("approverGroupUsername"),
@@ -148,6 +170,7 @@ export const secretApprovalRequestDALFactory = (db: TDbClient) => {
tx.ref("username").withSchema("secretApprovalReviewerUser").as("reviewerUsername"),
tx.ref("firstName").withSchema("secretApprovalReviewerUser").as("reviewerFirstName"),
tx.ref("lastName").withSchema("secretApprovalReviewerUser").as("reviewerLastName"),
tx.ref("isActive").withSchema("reviewerOrgMembership").as("reviewerIsOrgMembershipActive"),
tx.ref("id").withSchema(TableName.SecretApprovalPolicy).as("policyId"),
tx.ref("name").withSchema(TableName.SecretApprovalPolicy).as("policyName"),
tx.ref("projectId").withSchema(TableName.Environment),
@@ -211,9 +234,21 @@ export const secretApprovalRequestDALFactory = (db: TDbClient) => {
reviewerLastName: lastName,
reviewerUsername: username,
reviewerFirstName: firstName,
reviewerComment: comment
reviewerComment: comment,
reviewerIsOrgMembershipActive: isOrgMembershipActive
}) =>
userId ? { userId, status, email, firstName, lastName, username, comment: comment ?? "" } : undefined
userId
? {
userId,
status,
email,
firstName,
lastName,
username,
comment: comment ?? "",
isOrgMembershipActive
}
: undefined
},
{
key: "approverUserId",
@@ -223,13 +258,15 @@ export const secretApprovalRequestDALFactory = (db: TDbClient) => {
approverEmail: email,
approverUsername: username,
approverLastName: lastName,
approverFirstName: firstName
approverFirstName: firstName,
approverIsOrgMembershipActive: isOrgMembershipActive
}) => ({
userId,
email,
firstName,
lastName,
username
username,
isOrgMembershipActive
})
},
{
@@ -240,13 +277,15 @@ export const secretApprovalRequestDALFactory = (db: TDbClient) => {
approverGroupEmail: email,
approverGroupUsername: username,
approverGroupLastName: lastName,
approverGroupFirstName: firstName
approverGroupFirstName: firstName,
approverGroupIsOrgMembershipActive: isOrgMembershipActive
}) => ({
userId,
email,
firstName,
lastName,
username
username,
isOrgMembershipActive
})
},
{

View File

@@ -258,6 +258,7 @@ export const secretApprovalRequestServiceFactory = ({
if (actor === ActorType.SERVICE) throw new BadRequestError({ message: "Cannot use service token" });
const secretApprovalRequest = await secretApprovalRequestDAL.findById(id);
if (!secretApprovalRequest)
throw new NotFoundError({ message: `Secret approval request with ID '${id}' not found` });

View File

@@ -108,7 +108,11 @@ export const registerProjectRouter = async (server: FastifyZodProvider) => {
firstName: true,
lastName: true,
id: true
}).merge(UserEncryptionKeysSchema.pick({ publicKey: true })),
})
.merge(UserEncryptionKeysSchema.pick({ publicKey: true }))
.extend({
isOrgMembershipActive: z.boolean()
}),
project: SanitizedProjectSchema.pick({ name: true, id: true }),
roles: z.array(
z.object({

View File

@@ -156,6 +156,7 @@ export const groupProjectDALFactory = (db: TDbClient) => {
`${TableName.GroupProjectMembershipRole}.customRoleId`,
`${TableName.ProjectRoles}.id`
)
.join(TableName.OrgMembership, `${TableName.Users}.id`, `${TableName.OrgMembership}.userId`)
.select(
db.ref("id").withSchema(TableName.UserGroupMembership),
db.ref("createdAt").withSchema(TableName.UserGroupMembership),
@@ -176,7 +177,8 @@ export const groupProjectDALFactory = (db: TDbClient) => {
db.ref("temporaryRange").withSchema(TableName.GroupProjectMembershipRole),
db.ref("temporaryAccessStartTime").withSchema(TableName.GroupProjectMembershipRole),
db.ref("temporaryAccessEndTime").withSchema(TableName.GroupProjectMembershipRole),
db.ref("name").as("projectName").withSchema(TableName.Project)
db.ref("name").as("projectName").withSchema(TableName.Project),
db.ref("isActive").withSchema(TableName.OrgMembership)
)
.where({ isGhost: false });
@@ -192,7 +194,8 @@ export const groupProjectDALFactory = (db: TDbClient) => {
id,
userId,
projectName,
createdAt
createdAt,
isActive
}) => ({
isGroupMember: true,
id,
@@ -202,7 +205,7 @@ export const groupProjectDALFactory = (db: TDbClient) => {
id: projectId,
name: projectName
},
user: { email, username, firstName, lastName, id: userId, publicKey, isGhost },
user: { email, username, firstName, lastName, id: userId, publicKey, isGhost, isOrgMembershipActive: isActive },
createdAt
}),
key: "id",

View File

@@ -21,6 +21,14 @@ export const projectMembershipDALFactory = (db: TDbClient) => {
.where({ [`${TableName.ProjectMembership}.projectId` as "projectId"]: projectId })
.join(TableName.Project, `${TableName.ProjectMembership}.projectId`, `${TableName.Project}.id`)
.join(TableName.Users, `${TableName.ProjectMembership}.userId`, `${TableName.Users}.id`)
.join(TableName.OrgMembership, (qb) => {
qb.on(`${TableName.Users}.id`, "=", `${TableName.OrgMembership}.userId`).andOn(
`${TableName.OrgMembership}.orgId`,
"=",
`${TableName.Project}.orgId`
);
})
.where((qb) => {
if (filter.usernames) {
void qb.whereIn("username", filter.usernames);
@@ -90,7 +98,8 @@ export const projectMembershipDALFactory = (db: TDbClient) => {
db.ref("temporaryRange").withSchema(TableName.ProjectUserMembershipRole),
db.ref("temporaryAccessStartTime").withSchema(TableName.ProjectUserMembershipRole),
db.ref("temporaryAccessEndTime").withSchema(TableName.ProjectUserMembershipRole),
db.ref("name").as("projectName").withSchema(TableName.Project)
db.ref("name").as("projectName").withSchema(TableName.Project),
db.ref("isActive").withSchema(TableName.OrgMembership)
)
.where({ isGhost: false })
.orderBy(`${TableName.Users}.username` as "username");
@@ -107,12 +116,22 @@ export const projectMembershipDALFactory = (db: TDbClient) => {
id,
userId,
projectName,
createdAt
createdAt,
isActive
}) => ({
id,
userId,
projectId,
user: { email, username, firstName, lastName, id: userId, publicKey, isGhost },
user: {
email,
username,
firstName,
lastName,
id: userId,
publicKey,
isGhost,
isOrgMembershipActive: isActive
},
project: {
id: projectId,
name: projectName

View File

@@ -97,7 +97,6 @@ export const projectMembershipServiceFactory = ({
const projectMembers = await projectMembershipDAL.findAllProjectMembers(projectId, { roles });
// projectMembers[0].project
if (includeGroupMembers) {
const groupMembers = await groupProjectDAL.findAllProjectGroupMembers(projectId);
const allMembers = [

View File

@@ -36,6 +36,7 @@ export type Approver = {
type: ApproverType;
sequence?: number;
approvalsRequired?: number;
isOrgMembershipActive: boolean;
};
export type Bypasser = {
@@ -82,6 +83,7 @@ export type TAccessApprovalRequest = {
name: string;
approvals: number;
approvers: {
isOrgMembershipActive: boolean;
userId: string;
sequence?: number;
approvalsRequired?: number;
@@ -98,6 +100,7 @@ export type TAccessApprovalRequest = {
};
reviewers: {
isOrgMembershipActive: boolean;
userId: string;
status: string;
}[];
@@ -177,7 +180,7 @@ export type TCreateAccessPolicyDTO = {
projectSlug: string;
name?: string;
environments: string[];
approvers?: Approver[];
approvers?: Omit<Approver, "isOrgMembershipActive">[];
bypassers?: Bypasser[];
approvals?: number;
secretPath: string;
@@ -190,7 +193,7 @@ export type TCreateAccessPolicyDTO = {
export type TUpdateAccessPolicyDTO = {
id: string;
name?: string;
approvers?: Approver[];
approvers?: Omit<Approver, "isOrgMembershipActive">[];
bypassers?: Bypasser[];
secretPath?: string;
environments?: string[];

View File

@@ -20,6 +20,7 @@ export enum ApproverType {
}
export type Approver = {
isOrgMembershipActive: boolean;
id: string;
type: ApproverType;
};
@@ -49,7 +50,7 @@ export type TCreateSecretPolicyDTO = {
name?: string;
environments: string[];
secretPath: string;
approvers?: Approver[];
approvers?: Omit<Approver, "isOrgMembershipActive">[];
bypassers?: Bypasser[];
approvals?: number;
enforcementLevel: EnforcementLevel;
@@ -59,7 +60,7 @@ export type TCreateSecretPolicyDTO = {
export type TUpdateSecretPolicyDTO = {
id: string;
name?: string;
approvers?: Approver[];
approvers?: Omit<Approver, "isOrgMembershipActive">[];
bypassers?: Bypasser[];
secretPath?: string;
approvals?: number;

View File

@@ -53,6 +53,7 @@ export type TSecretApprovalRequest = {
firstName: string;
lastName: string;
username: string;
isOrgMembershipActive: boolean;
}[];
workspace: string;
environment: string;
@@ -62,6 +63,7 @@ export type TSecretApprovalRequest = {
status: "open" | "close";
policy: Omit<TSecretApprovalPolicy, "approvers" | "bypassers"> & {
approvers: {
isOrgMembershipActive: boolean;
userId: string;
email: string;
firstName: string;

View File

@@ -83,6 +83,7 @@ export type TProjectMembership = {
export type TWorkspaceUser = {
id: string;
user: {
isOrgMembershipActive: boolean;
email: string;
username: string;
firstName: string;

View File

@@ -1,5 +1,6 @@
import { faInfoCircle } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { twMerge } from "tailwind-merge";
import { UpgradePlanModal } from "@app/components/license/UpgradePlanModal";
import { createNotification } from "@app/components/notifications";
@@ -13,7 +14,6 @@ import {
} from "@app/context";
import { useLogoutUser, useUpdateOrg } from "@app/hooks/api";
import { usePopUp } from "@app/hooks/usePopUp";
import { twMerge } from "tailwind-merge";
enum EnforceAuthType {
SAML = "saml",

View File

@@ -4,7 +4,9 @@ import {
faCheck,
faEdit,
faHourglass,
faTriangleExclamation
faTriangleExclamation,
faUser,
faUserSlash
} from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import ms from "ms";
@@ -37,7 +39,7 @@ import { ApprovalStatus, TWorkspaceUser } from "@app/hooks/api/types";
import { groupBy } from "@app/lib/fn/array";
import { EditAccessRequestModal } from "@app/pages/secret-manager/SecretApprovalsPage/components/AccessApprovalRequest/components/EditAccessRequestModal";
const getReviewedStatusSymbol = (status?: ApprovalStatus) => {
const getReviewedStatusSymbol = (status?: ApprovalStatus, isOrgMembershipActive?: boolean) => {
if (status === ApprovalStatus.APPROVED)
return (
<Badge variant="success" className="flex h-4 items-center justify-center">
@@ -50,6 +52,17 @@ const getReviewedStatusSymbol = (status?: ApprovalStatus) => {
<FontAwesomeIcon icon={faBan} size="xs" />
</Badge>
);
if (!isOrgMembershipActive) {
return (
// Can't do a tooltip here because nested tooltips doesn't work properly as of yet.
// TODO(daniel): Fix nested tooltips in the future.
<Badge className="flex h-4 items-center justify-center bg-mineshaft-400/50 text-bunker-300">
<FontAwesomeIcon size="xs" icon={faUserSlash} />
</Badge>
);
}
return (
<Badge variant="primary" className="flex h-4 items-center justify-center">
<FontAwesomeIcon icon={faHourglass} size="xs" />
@@ -87,6 +100,7 @@ export const ReviewAccessRequestModal = ({
}) => {
const [isLoading, setIsLoading] = useState<"approved" | "rejected" | null>(null);
const [bypassApproval, setBypassApproval] = useState(false);
const [bypassReason, setBypassReason] = useState("");
const { currentWorkspace } = useWorkspace();
const { data: groupMemberships = [] } = useListWorkspaceGroups(currentWorkspace?.id || "");
@@ -192,6 +206,7 @@ export const ReviewAccessRequestModal = ({
(acc, curr) => {
if (acc.length && acc[acc.length - 1].sequence === curr.sequence) {
acc[acc.length - 1][curr.type]?.push(curr);
return acc;
}
@@ -203,6 +218,7 @@ export const ReviewAccessRequestModal = ({
? { user: [curr], group: [], sequence, approvals }
: { group: [curr], user: [], sequence, approvals }
);
return acc;
},
[] as {
@@ -216,7 +232,10 @@ export const ReviewAccessRequestModal = ({
const approvers = approversBySequence?.map((approverChain) => {
const reviewers = request.policy.approvers
.filter((el) => (el.sequence || 1) === approverChain.sequence)
.map((el) => ({ ...el, status: reviewesGroupById?.[el.userId]?.[0]?.status }));
.map((el) => ({
...el,
status: reviewesGroupById?.[el.userId]?.[0]?.status
}));
const hasApproved =
reviewers.filter((el) => el.status === "approved").length >=
(approverChain?.approvals || 1);
@@ -410,12 +429,39 @@ export const ReviewAccessRequestModal = ({
</div>
)}
<div className="grid flex-1 grid-cols-5 border-b border-mineshaft-600 p-4">
<GenericFieldLabel className="col-span-2" label="Users">
{approver?.user
?.map(
(el) => approverSequence?.membersGroupById?.[el.id]?.[0]?.user?.username
)
.join(", ")}
<GenericFieldLabel className="col-span-2" icon={faUser} label="Users">
{Boolean(approver.user.length) && (
<div className="flex flex-row flex-wrap gap-2">
{approver?.user?.map((el, idx) => {
const member = approverSequence?.membersGroupById?.[el.id]?.[0];
if (!member) return null;
return member.user.isOrgMembershipActive ? (
<div className="flex items-center" key={member.user.id}>
<span>{member.user.username}</span>
{idx < approver.user.length - 1 && ","}
</div>
) : (
<div className="flex items-center" key={member.user.id}>
<span className="flex items-center opacity-40">
{member.user.username}
<span className="text-xs">
<Tooltip content="This user has been deactivated and no longer has an active organization membership.">
<div>
<Badge className="pointer-events-none ml-1 mr-auto flex h-5 w-min items-center gap-1.5 whitespace-nowrap bg-mineshaft-400/50 text-bunker-300">
<FontAwesomeIcon icon={faBan} />
Inactive
</Badge>
</div>
</Tooltip>
</span>
</span>
{idx < approver.user.length - 1 && ","}
</div>
);
})}
</div>
)}
</GenericFieldLabel>
<GenericFieldLabel className="col-span-2" label="Groups">
{approver?.group
@@ -440,8 +486,18 @@ export const ReviewAccessRequestModal = ({
key={`reviewer-${idx + 1}`}
className="flex items-center gap-2 px-2 py-2 text-sm"
>
<div className="flex-1">{el.username}</div>
{getReviewedStatusSymbol(el?.status as ApprovalStatus)}
<div
className={twMerge(
"flex-1",
!el.isOrgMembershipActive && "opacity-40"
)}
>
{el.username}
</div>
{getReviewedStatusSymbol(
el?.status as ApprovalStatus,
el.isOrgMembershipActive
)}
</div>
))}
</div>

View File

@@ -43,6 +43,8 @@ import {
import { EnforcementLevel, PolicyType } from "@app/hooks/api/policies/enums";
import { TWorkspaceUser } from "@app/hooks/api/users/types";
import { PolicyMemberOption } from "./PolicyMemberOption";
type Props = {
isOpen?: boolean;
onToggle: (isOpen: boolean) => void;
@@ -59,7 +61,11 @@ const formSchema = z
secretPath: z.string().trim().min(1),
approvals: z.number().min(1).default(1),
userApprovers: z
.object({ type: z.literal(ApproverType.User), id: z.string() })
.object({
type: z.literal(ApproverType.User),
id: z.string(),
isOrgMembershipActive: z.boolean().optional()
})
.array()
.default([]),
groupApprovers: z
@@ -67,7 +73,11 @@ const formSchema = z
.array()
.default([]),
userBypassers: z
.object({ type: z.literal(BypasserType.User), id: z.string() })
.object({
type: z.literal(BypasserType.User),
id: z.string(),
isOrgMembershipActive: z.boolean().optional()
})
.array()
.default([]),
groupBypassers: z
@@ -80,7 +90,11 @@ const formSchema = z
sequenceApprovers: z
.object({
user: z
.object({ type: z.literal(ApproverType.User), id: z.string() })
.object({
type: z.literal(ApproverType.User),
id: z.string(),
isOrgMembershipActive: z.boolean().optional()
})
.array()
.default([]),
group: z
@@ -139,7 +153,11 @@ const Form = ({
userApprovers:
editValues?.approvers
?.filter((approver) => approver.type === ApproverType.User)
.map(({ id, type }) => ({ id, type: type as ApproverType.User })) || [],
.map(({ id, type, isOrgMembershipActive }) => ({
id,
type: type as ApproverType.User,
isOrgMembershipActive
})) || [],
groupApprovers:
editValues?.approvers
?.filter((approver) => approver.type === ApproverType.Group)
@@ -235,7 +253,9 @@ const Form = ({
...data,
approvers: sequenceApprovers?.flatMap((approvers, index) =>
approvers.user
.map((el) => ({ ...el, sequence: index + 1 }) as Approver)
.map(
(el) => ({ ...el, sequence: index + 1 }) as Omit<Approver, "isOrgMembershipActive">
)
.concat(approvers.group.map((el) => ({ ...el, sequence: index + 1 })))
),
approvalsRequired: sequenceApprovers?.map((el, index) => ({
@@ -291,7 +311,9 @@ const Form = ({
...data,
approvers: sequenceApprovers?.flatMap((approvers, index) =>
approvers.user
.map((el) => ({ ...el, sequence: index + 1 }) as Approver)
.map(
(el) => ({ ...el, sequence: index + 1 }) as Omit<Approver, "isOrgMembershipActive">
)
.concat(approvers.group.map((el) => ({ ...el, sequence: index + 1 })))
),
approvalsRequired: sequenceApprovers?.map((el, index) => ({
@@ -329,7 +351,8 @@ const Form = ({
() =>
members.map((member) => ({
id: member.user.id,
type: ApproverType.User
type: ApproverType.User,
isOrgMembershipActive: member.user.isOrgMembershipActive
})),
[members]
);
@@ -347,7 +370,8 @@ const Form = ({
() =>
members.map((member) => ({
id: member.user.id,
type: BypasserType.User
type: BypasserType.User,
isOrgMembershipActive: member.user.isOrgMembershipActive
})),
[members]
);
@@ -608,6 +632,7 @@ const Form = ({
isMulti
placeholder="Select members..."
options={memberOptions}
components={{ Option: PolicyMemberOption }}
getOptionValue={(option) => option.id}
getOptionLabel={(option) => {
const member = members?.find((m) => m.user.id === option.id);
@@ -685,6 +710,7 @@ const Form = ({
menuPlacement="top"
isMulti
placeholder="Select members..."
components={{ Option: PolicyMemberOption }}
options={memberOptions}
getOptionValue={(option) => option.id}
getOptionLabel={(option) => {
@@ -783,6 +809,7 @@ const Form = ({
menuPlacement="top"
isMulti
placeholder="Select members..."
components={{ Option: PolicyMemberOption }}
options={bypasserMemberOptions}
getOptionValue={(option) => option.id}
getOptionLabel={(option) => {

View File

@@ -1,5 +1,6 @@
import { useMemo } from "react";
import {
faBan,
faClipboardCheck,
faEdit,
faEllipsisV,
@@ -19,6 +20,7 @@ import {
GenericFieldLabel,
IconButton,
Td,
Tooltip,
Tr
} from "@app/components/v2";
import { Badge } from "@app/components/v2/Badge";
@@ -86,10 +88,9 @@ export const ApprovalPolicyRow = ({
return entityInSameSequence?.map((el) => {
return {
sequence: el.sequence || policy.approvals,
userLabels: members
?.filter((member) => el.user.find((i) => i.id === member.user.id))
.map((member) => getMemberLabel(member))
.join(", "),
users: members.filter((member) => el.user.find((i) => i.id === member.user.id)),
groupLabels: groups
?.filter(({ group }) => el.group.find((i) => i.id === group.id))
.map(({ group }) => group.name)
@@ -212,7 +213,35 @@ export const ApprovalPolicyRow = ({
)}
<div className="grid flex-1 grid-cols-5 border-b border-mineshaft-600 p-4">
<GenericFieldLabel className="col-span-2" icon={faUser} label="Users">
{el.userLabels}
{Boolean(el.users.length) && (
<div className="flex flex-row flex-wrap gap-2">
{el.users.map((u, idx) => {
return u.user.isOrgMembershipActive ? (
<div className="flex items-center" key={u.id}>
<span>{getMemberLabel(u)}</span>
{idx < el.users.length - 1 && ","}
</div>
) : (
<div className="flex items-center" key={u.id}>
<span className="flex items-center opacity-40">
{getMemberLabel(u)}
<span className="text-xs">
<Tooltip content="This user has been deactivated and no longer has an active organization membership.">
<div>
<Badge className="pointer-events-none ml-1 mr-auto flex h-5 w-min items-center gap-1.5 whitespace-nowrap bg-mineshaft-400/50 text-bunker-300">
<FontAwesomeIcon icon={faBan} />
Inactive
</Badge>
</div>
</Tooltip>
</span>
</span>
{idx < el.users.length - 1 && ","}
</div>
);
})}
</div>
)}
</GenericFieldLabel>
<GenericFieldLabel className="col-span-2" icon={faUserGroup} label="Groups">
{el.groupLabels}

View File

@@ -0,0 +1,40 @@
import { components, OptionProps } from "react-select";
import { faCheckCircle } from "@fortawesome/free-regular-svg-icons";
import { faBan } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { twMerge } from "tailwind-merge";
import { Badge } from "@app/components/v2";
import { BypasserType } from "@app/hooks/api/accessApproval/types";
import { ApproverType } from "@app/hooks/api/secretApproval/types";
export const PolicyMemberOption = ({
isSelected,
children,
...props
}: OptionProps<{
id: string;
type: BypasserType | ApproverType;
isOrgMembershipActive?: boolean;
}>) => {
return (
<components.Option isSelected={isSelected} {...props}>
<div className="flex flex-row items-center justify-between">
<p
className={twMerge("truncate", !props.data.isOrgMembershipActive && "text-mineshaft-400")}
>
{children}
</p>
{!props.data.isOrgMembershipActive && (
<Badge className="pointer-events-none ml-1 mr-auto flex h-5 w-min items-center gap-1.5 whitespace-nowrap bg-mineshaft-400/50 text-bunker-300">
<FontAwesomeIcon icon={faBan} />
Inactive
</Badge>
)}
{isSelected && (
<FontAwesomeIcon className="ml-2 text-primary" icon={faCheckCircle} size="sm" />
)}
</div>
</components.Option>
);
};

View File

@@ -1,3 +1,4 @@
/* eslint-disable no-nested-ternary */
import { ReactNode } from "react";
import { Controller, useForm } from "react-hook-form";
import {
@@ -8,7 +9,8 @@ import {
faCodeBranch,
faComment,
faFolder,
faHourglass
faHourglass,
faUserSlash
} from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { zodResolver } from "@hookform/resolvers/zod";
@@ -85,6 +87,7 @@ const getReviewedStatusSymbol = (status?: ApprovalStatus) => {
return <FontAwesomeIcon icon={faCheck} size="xs" className="text-green" />;
if (status === ApprovalStatus.REJECTED)
return <FontAwesomeIcon icon={faBan} size="xs" className="text-red" />;
return <FontAwesomeIcon icon={faHourglass} size="xs" className="text-yellow" />;
};
@@ -162,11 +165,15 @@ export const SecretApprovalRequestChanges = ({
secretApprovalRequestDetails.policy.bypassers.some(({ userId }) => userId === userSession.id);
const reviewedUsers = secretApprovalRequestDetails?.reviewers?.reduce<
Record<string, { status: ApprovalStatus; comment: string }>
Record<string, { status: ApprovalStatus; comment: string; isOrgMembershipActive: boolean }>
>(
(prev, curr) => ({
...prev,
[curr.userId]: { status: curr.status, comment: curr.comment }
[curr.userId]: {
status: curr.status,
comment: curr.comment,
isOrgMembershipActive: curr.isOrgMembershipActive
}
}),
{}
);
@@ -533,26 +540,44 @@ export const SecretApprovalRequestChanges = ({
)
.map((requiredApprover) => {
const reviewer = reviewedUsers?.[requiredApprover.userId];
const { isOrgMembershipActive } = requiredApprover;
return (
<div
className="flex flex-nowrap items-center justify-between space-x-2 rounded border border-mineshaft-600 bg-mineshaft-800 px-2 py-1"
key={`required-approver-${requiredApprover.userId}`}
>
<Tooltip
content={
requiredApprover.firstName
? `${requiredApprover.firstName || ""} ${requiredApprover.lastName || ""}`
: undefined
}
position="left"
sideOffset={10}
<div
className={twMerge(
"flex items-center gap-1 text-sm",
!isOrgMembershipActive && "opacity-40"
)}
>
<div className="flex text-sm">
<div>{requiredApprover?.email}</div>
<span className="text-red">*</span>
</div>
</Tooltip>
<div>
<Tooltip
content={
!isOrgMembershipActive
? "This user has been deactivated and no longer has an active organization membership."
: requiredApprover.firstName
? `${requiredApprover.firstName || ""} ${requiredApprover.lastName || ""}`
: undefined
}
position="left"
sideOffset={10}
>
<div className="flex items-center">
<div>{requiredApprover?.email}</div>
<span className="text-red">*</span>
{!isOrgMembershipActive && (
<FontAwesomeIcon
icon={faUserSlash}
size="xs"
className="ml-1 text-mineshaft-300"
/>
)}
</div>
</Tooltip>
</div>
<div className="flex items-center">
{reviewer?.comment && (
<Tooltip className="max-w-lg break-words" content={reviewer.comment}>
<FontAwesomeIcon
@@ -562,9 +587,21 @@ export const SecretApprovalRequestChanges = ({
/>
</Tooltip>
)}
<Tooltip content={`Status: ${reviewer?.status || ApprovalStatus.PENDING}`}>
{getReviewedStatusSymbol(reviewer?.status)}
</Tooltip>
<div className="flex gap-2">
<Tooltip
className="relative !z-[500]"
content={
<span className="text-sm">
Status:{" "}
<span className="capitalize">
{reviewer?.status || ApprovalStatus.PENDING}
</span>
</span>
}
>
{getReviewedStatusSymbol(reviewer?.status)}
</Tooltip>
</div>
</div>
</div>
);
@@ -578,20 +615,42 @@ export const SecretApprovalRequestChanges = ({
)
.map((reviewer) => {
const status = reviewedUsers?.[reviewer.userId].status;
const { isOrgMembershipActive } = reviewer;
return (
<div
className="flex flex-nowrap items-center space-x-2 rounded bg-mineshaft-800 px-2 py-1"
className="flex flex-nowrap items-center justify-between space-x-2 rounded bg-mineshaft-800 px-2 py-1"
key={`required-approver-${reviewer.userId}`}
>
<div className="flex-grow text-sm">
<Tooltip content={`${reviewer.firstName || ""} ${reviewer.lastName || ""}`}>
<span>{reviewer?.email} </span>
<div
className={twMerge(
"flex items-center gap-1 text-sm",
!isOrgMembershipActive && "opacity-40"
)}
>
<Tooltip
className="relative !z-[500]"
content={
!isOrgMembershipActive
? "This user has been deactivated and no longer has an active organization membership."
: `${reviewer.firstName || ""} ${reviewer.lastName || ""}`
}
>
<div className="flex items-center">
<span>{reviewer?.email} </span>
{!isOrgMembershipActive && (
<FontAwesomeIcon
icon={faUserSlash}
size="xs"
className="ml-1 text-mineshaft-300"
/>
)}
</div>
</Tooltip>
<span className="text-red">*</span>
</div>
<div>
{reviewer.comment && (
<Tooltip content={reviewer.comment}>
<Tooltip className="relative !z-[500]" content={reviewer.comment}>
<FontAwesomeIcon
icon={faComment}
size="xs"
@@ -599,7 +658,15 @@ export const SecretApprovalRequestChanges = ({
/>
</Tooltip>
)}
<Tooltip content={status || ApprovalStatus.PENDING}>
<Tooltip
className="relative !z-[500]"
content={
<span className="text-sm">
Status:{" "}
<span className="capitalize">{status || ApprovalStatus.PENDING}</span>
</span>
}
>
{getReviewedStatusSymbol(status)}
</Tooltip>
</div>