mirror of
https://github.com/awatertrevi/infisical.git
synced 2026-10-05 14:26:38 +00:00
Merge pull request #2333 from Infisical/daniel/secret-change-emails
Feat: Email notification on secret change requests
This commit is contained in:
@@ -20,7 +20,15 @@ export const secretApprovalPolicyDALFactory = (db: TDbClient) => {
|
|||||||
`${TableName.SecretApprovalPolicy}.id`,
|
`${TableName.SecretApprovalPolicy}.id`,
|
||||||
`${TableName.SecretApprovalPolicyApprover}.policyId`
|
`${TableName.SecretApprovalPolicyApprover}.policyId`
|
||||||
)
|
)
|
||||||
.select(tx.ref("approverUserId").withSchema(TableName.SecretApprovalPolicyApprover))
|
|
||||||
|
.leftJoin(TableName.Users, `${TableName.SecretApprovalPolicyApprover}.approverUserId`, `${TableName.Users}.id`)
|
||||||
|
|
||||||
|
.select(
|
||||||
|
tx.ref("approverUserId").withSchema(TableName.SecretApprovalPolicyApprover),
|
||||||
|
tx.ref("email").withSchema(TableName.Users).as("approverEmail"),
|
||||||
|
tx.ref("firstName").withSchema(TableName.Users).as("approverFirstName"),
|
||||||
|
tx.ref("lastName").withSchema(TableName.Users).as("approverLastName")
|
||||||
|
)
|
||||||
.select(
|
.select(
|
||||||
tx.ref("name").withSchema(TableName.Environment).as("envName"),
|
tx.ref("name").withSchema(TableName.Environment).as("envName"),
|
||||||
tx.ref("slug").withSchema(TableName.Environment).as("envSlug"),
|
tx.ref("slug").withSchema(TableName.Environment).as("envSlug"),
|
||||||
@@ -47,8 +55,11 @@ export const secretApprovalPolicyDALFactory = (db: TDbClient) => {
|
|||||||
{
|
{
|
||||||
key: "approverUserId",
|
key: "approverUserId",
|
||||||
label: "userApprovers" as const,
|
label: "userApprovers" as const,
|
||||||
mapper: ({ approverUserId }) => ({
|
mapper: ({ approverUserId, approverEmail, approverFirstName, approverLastName }) => ({
|
||||||
userId: approverUserId
|
userId: approverUserId,
|
||||||
|
email: approverEmail,
|
||||||
|
firstName: approverFirstName,
|
||||||
|
lastName: approverLastName
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -0,0 +1,46 @@
|
|||||||
|
import { TSecretApprovalRequests } from "@app/db/schemas";
|
||||||
|
import { getConfig } from "@app/lib/config/env";
|
||||||
|
import { TProjectDALFactory } from "@app/services/project/project-dal";
|
||||||
|
import { SmtpTemplates, TSmtpService } from "@app/services/smtp/smtp-service";
|
||||||
|
|
||||||
|
import { TSecretApprovalPolicyDALFactory } from "../secret-approval-policy/secret-approval-policy-dal";
|
||||||
|
|
||||||
|
type TSendApprovalEmails = {
|
||||||
|
secretApprovalPolicyDAL: Pick<TSecretApprovalPolicyDALFactory, "findById">;
|
||||||
|
projectDAL: Pick<TProjectDALFactory, "findProjectWithOrg">;
|
||||||
|
smtpService: Pick<TSmtpService, "sendMail">;
|
||||||
|
projectId: string;
|
||||||
|
secretApprovalRequest: TSecretApprovalRequests;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const sendApprovalEmailsFn = async ({
|
||||||
|
secretApprovalPolicyDAL,
|
||||||
|
projectDAL,
|
||||||
|
smtpService,
|
||||||
|
projectId,
|
||||||
|
secretApprovalRequest
|
||||||
|
}: TSendApprovalEmails) => {
|
||||||
|
const cfg = getConfig();
|
||||||
|
|
||||||
|
const policy = await secretApprovalPolicyDAL.findById(secretApprovalRequest.policyId);
|
||||||
|
|
||||||
|
const project = await projectDAL.findProjectWithOrg(projectId);
|
||||||
|
|
||||||
|
// now we need to go through each of the reviewers and print out all the commits that they need to approve
|
||||||
|
for await (const reviewerUser of policy.userApprovers) {
|
||||||
|
await smtpService.sendMail({
|
||||||
|
recipients: [reviewerUser?.email as string],
|
||||||
|
subjectLine: "Infisical Secret Change Request",
|
||||||
|
|
||||||
|
substitutions: {
|
||||||
|
firstName: reviewerUser.firstName,
|
||||||
|
projectName: project.name,
|
||||||
|
organizationName: project.organization.name,
|
||||||
|
approvalUrl: `${cfg.isDevelopmentMode ? "https" : "http"}://${cfg.SITE_URL}/project/${
|
||||||
|
project.id
|
||||||
|
}/approval?requestId=${secretApprovalRequest.id}`
|
||||||
|
},
|
||||||
|
template: SmtpTemplates.SecretApprovalRequestNeedsReview
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -53,8 +53,10 @@ import { TUserDALFactory } from "@app/services/user/user-dal";
|
|||||||
import { TLicenseServiceFactory } from "../license/license-service";
|
import { TLicenseServiceFactory } from "../license/license-service";
|
||||||
import { TPermissionServiceFactory } from "../permission/permission-service";
|
import { TPermissionServiceFactory } from "../permission/permission-service";
|
||||||
import { ProjectPermissionActions, ProjectPermissionSub } from "../permission/project-permission";
|
import { ProjectPermissionActions, ProjectPermissionSub } from "../permission/project-permission";
|
||||||
|
import { TSecretApprovalPolicyDALFactory } from "../secret-approval-policy/secret-approval-policy-dal";
|
||||||
import { TSecretSnapshotServiceFactory } from "../secret-snapshot/secret-snapshot-service";
|
import { TSecretSnapshotServiceFactory } from "../secret-snapshot/secret-snapshot-service";
|
||||||
import { TSecretApprovalRequestDALFactory } from "./secret-approval-request-dal";
|
import { TSecretApprovalRequestDALFactory } from "./secret-approval-request-dal";
|
||||||
|
import { sendApprovalEmailsFn } from "./secret-approval-request-fns";
|
||||||
import { TSecretApprovalRequestReviewerDALFactory } from "./secret-approval-request-reviewer-dal";
|
import { TSecretApprovalRequestReviewerDALFactory } from "./secret-approval-request-reviewer-dal";
|
||||||
import { TSecretApprovalRequestSecretDALFactory } from "./secret-approval-request-secret-dal";
|
import { TSecretApprovalRequestSecretDALFactory } from "./secret-approval-request-secret-dal";
|
||||||
import {
|
import {
|
||||||
@@ -89,7 +91,10 @@ type TSecretApprovalRequestServiceFactoryDep = {
|
|||||||
smtpService: Pick<TSmtpService, "sendMail">;
|
smtpService: Pick<TSmtpService, "sendMail">;
|
||||||
userDAL: Pick<TUserDALFactory, "find" | "findOne">;
|
userDAL: Pick<TUserDALFactory, "find" | "findOne">;
|
||||||
projectEnvDAL: Pick<TProjectEnvDALFactory, "findOne">;
|
projectEnvDAL: Pick<TProjectEnvDALFactory, "findOne">;
|
||||||
projectDAL: Pick<TProjectDALFactory, "checkProjectUpgradeStatus" | "findById" | "findProjectById">;
|
projectDAL: Pick<
|
||||||
|
TProjectDALFactory,
|
||||||
|
"checkProjectUpgradeStatus" | "findById" | "findProjectById" | "findProjectWithOrg"
|
||||||
|
>;
|
||||||
secretQueueService: Pick<TSecretQueueFactory, "syncSecrets" | "removeSecretReminder">;
|
secretQueueService: Pick<TSecretQueueFactory, "syncSecrets" | "removeSecretReminder">;
|
||||||
kmsService: Pick<TKmsServiceFactory, "createCipherPairWithDataKey" | "encryptWithInputKey" | "decryptWithInputKey">;
|
kmsService: Pick<TKmsServiceFactory, "createCipherPairWithDataKey" | "encryptWithInputKey" | "decryptWithInputKey">;
|
||||||
secretV2BridgeDAL: Pick<
|
secretV2BridgeDAL: Pick<
|
||||||
@@ -98,6 +103,7 @@ type TSecretApprovalRequestServiceFactoryDep = {
|
|||||||
>;
|
>;
|
||||||
secretVersionV2BridgeDAL: Pick<TSecretVersionV2DALFactory, "insertMany" | "findLatestVersionMany">;
|
secretVersionV2BridgeDAL: Pick<TSecretVersionV2DALFactory, "insertMany" | "findLatestVersionMany">;
|
||||||
secretVersionTagV2BridgeDAL: Pick<TSecretVersionV2TagDALFactory, "insertMany">;
|
secretVersionTagV2BridgeDAL: Pick<TSecretVersionV2TagDALFactory, "insertMany">;
|
||||||
|
secretApprovalPolicyDAL: Pick<TSecretApprovalPolicyDALFactory, "findById">;
|
||||||
licenseService: Pick<TLicenseServiceFactory, "getPlan">;
|
licenseService: Pick<TLicenseServiceFactory, "getPlan">;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -121,6 +127,7 @@ export const secretApprovalRequestServiceFactory = ({
|
|||||||
smtpService,
|
smtpService,
|
||||||
userDAL,
|
userDAL,
|
||||||
projectEnvDAL,
|
projectEnvDAL,
|
||||||
|
secretApprovalPolicyDAL,
|
||||||
kmsService,
|
kmsService,
|
||||||
secretV2BridgeDAL,
|
secretV2BridgeDAL,
|
||||||
secretVersionV2BridgeDAL,
|
secretVersionV2BridgeDAL,
|
||||||
@@ -1061,6 +1068,15 @@ export const secretApprovalRequestServiceFactory = ({
|
|||||||
}
|
}
|
||||||
return { ...doc, commits: approvalCommits };
|
return { ...doc, commits: approvalCommits };
|
||||||
});
|
});
|
||||||
|
|
||||||
|
await sendApprovalEmailsFn({
|
||||||
|
projectDAL,
|
||||||
|
secretApprovalPolicyDAL,
|
||||||
|
secretApprovalRequest,
|
||||||
|
smtpService,
|
||||||
|
projectId
|
||||||
|
});
|
||||||
|
|
||||||
return secretApprovalRequest;
|
return secretApprovalRequest;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -1311,8 +1327,17 @@ export const secretApprovalRequestServiceFactory = ({
|
|||||||
tx
|
tx
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return { ...doc, commits: approvalCommits };
|
return { ...doc, commits: approvalCommits };
|
||||||
});
|
});
|
||||||
|
|
||||||
|
await sendApprovalEmailsFn({
|
||||||
|
projectDAL,
|
||||||
|
secretApprovalPolicyDAL,
|
||||||
|
secretApprovalRequest,
|
||||||
|
smtpService,
|
||||||
|
projectId
|
||||||
|
});
|
||||||
return secretApprovalRequest;
|
return secretApprovalRequest;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -845,6 +845,7 @@ export const registerRoutes = async (
|
|||||||
secretQueueService,
|
secretQueueService,
|
||||||
kmsService,
|
kmsService,
|
||||||
secretV2BridgeDAL,
|
secretV2BridgeDAL,
|
||||||
|
secretApprovalPolicyDAL,
|
||||||
secretVersionV2BridgeDAL,
|
secretVersionV2BridgeDAL,
|
||||||
secretVersionTagV2BridgeDAL,
|
secretVersionTagV2BridgeDAL,
|
||||||
smtpService,
|
smtpService,
|
||||||
|
|||||||
@@ -279,6 +279,34 @@ export const projectDALFactory = (db: TDbClient) => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const findProjectWithOrg = async (projectId: string) => {
|
||||||
|
// we just need the project, and we need to include a new .organization field that includes the org from the orgId reference
|
||||||
|
|
||||||
|
const project = await db(TableName.Project)
|
||||||
|
.where({ [`${TableName.Project}.id` as "id"]: projectId })
|
||||||
|
|
||||||
|
.join(TableName.Organization, `${TableName.Organization}.id`, `${TableName.Project}.orgId`)
|
||||||
|
|
||||||
|
.select(
|
||||||
|
db.ref("id").withSchema(TableName.Organization).as("organizationId"),
|
||||||
|
db.ref("name").withSchema(TableName.Organization).as("organizationName")
|
||||||
|
)
|
||||||
|
.select(selectAllTableCols(TableName.Project))
|
||||||
|
.first();
|
||||||
|
|
||||||
|
if (!project) {
|
||||||
|
throw new BadRequestError({ message: "Project not found" });
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
...ProjectsSchema.parse(project),
|
||||||
|
organization: {
|
||||||
|
id: project.organizationId,
|
||||||
|
name: project.organizationName
|
||||||
|
}
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
return {
|
return {
|
||||||
...projectOrm,
|
...projectOrm,
|
||||||
findAllProjects,
|
findAllProjects,
|
||||||
@@ -288,6 +316,7 @@ export const projectDALFactory = (db: TDbClient) => {
|
|||||||
findProjectById,
|
findProjectById,
|
||||||
findProjectByFilter,
|
findProjectByFilter,
|
||||||
findProjectBySlug,
|
findProjectBySlug,
|
||||||
|
findProjectWithOrg,
|
||||||
checkProjectUpgradeStatus
|
checkProjectUpgradeStatus
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -25,6 +25,7 @@ export enum SmtpTemplates {
|
|||||||
UnlockAccount = "unlockAccount.handlebars",
|
UnlockAccount = "unlockAccount.handlebars",
|
||||||
AccessApprovalRequest = "accessApprovalRequest.handlebars",
|
AccessApprovalRequest = "accessApprovalRequest.handlebars",
|
||||||
AccessSecretRequestBypassed = "accessSecretRequestBypassed.handlebars",
|
AccessSecretRequestBypassed = "accessSecretRequestBypassed.handlebars",
|
||||||
|
SecretApprovalRequestNeedsReview = "secretApprovalRequestNeedsReview.handlebars",
|
||||||
HistoricalSecretList = "historicalSecretLeakIncident.handlebars",
|
HistoricalSecretList = "historicalSecretLeakIncident.handlebars",
|
||||||
NewDeviceJoin = "newDevice.handlebars",
|
NewDeviceJoin = "newDevice.handlebars",
|
||||||
OrgInvite = "organizationInvitation.handlebars",
|
OrgInvite = "organizationInvitation.handlebars",
|
||||||
|
|||||||
@@ -0,0 +1,22 @@
|
|||||||
|
<html>
|
||||||
|
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8" />
|
||||||
|
<meta http-equiv="x-ua-compatible" content="ie=edge" />
|
||||||
|
<title>Secret Change Approval Request</title>
|
||||||
|
</head>
|
||||||
|
|
||||||
|
<body>
|
||||||
|
<h2>Hi {{firstName}},</h2>
|
||||||
|
<h2>New secret change requests are pending review.</h2>
|
||||||
|
<br />
|
||||||
|
<p>You have a secret change request pending your review in project "{{projectName}}", in the "{{organizationName}}"
|
||||||
|
organization.</p>
|
||||||
|
|
||||||
|
<p>
|
||||||
|
View the request and approve or deny it
|
||||||
|
<a href="{{approvalUrl}}">here</a>.
|
||||||
|
</p>
|
||||||
|
</body>
|
||||||
|
|
||||||
|
</html>
|
||||||
@@ -9,7 +9,15 @@ export const useCreateSecretApprovalPolicy = () => {
|
|||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
return useMutation<{}, {}, TCreateSecretPolicyDTO>({
|
return useMutation<{}, {}, TCreateSecretPolicyDTO>({
|
||||||
mutationFn: async ({ environment, workspaceId, approvals, approvers, secretPath, name, enforcementLevel }) => {
|
mutationFn: async ({
|
||||||
|
environment,
|
||||||
|
workspaceId,
|
||||||
|
approvals,
|
||||||
|
approvers,
|
||||||
|
secretPath,
|
||||||
|
name,
|
||||||
|
enforcementLevel
|
||||||
|
}) => {
|
||||||
const { data } = await apiRequest.post("/api/v1/secret-approvals", {
|
const { data } = await apiRequest.post("/api/v1/secret-approvals", {
|
||||||
environment,
|
environment,
|
||||||
workspaceId,
|
workspaceId,
|
||||||
|
|||||||
+4
-5
@@ -45,11 +45,11 @@ const formSchema = z
|
|||||||
name: z.string().optional(),
|
name: z.string().optional(),
|
||||||
secretPath: z.string().optional(),
|
secretPath: z.string().optional(),
|
||||||
approvals: z.number().min(1),
|
approvals: z.number().min(1),
|
||||||
approverUserIds: z.string().array().min(1),
|
approvers: z.string().array().min(1),
|
||||||
policyType: z.nativeEnum(PolicyType),
|
policyType: z.nativeEnum(PolicyType),
|
||||||
enforcementLevel: z.nativeEnum(EnforcementLevel)
|
enforcementLevel: z.nativeEnum(EnforcementLevel)
|
||||||
})
|
})
|
||||||
.refine((data) => data.approvals <= data.approverUserIds.length, {
|
.refine((data) => data.approvals <= data.approvers.length, {
|
||||||
path: ["approvals"],
|
path: ["approvals"],
|
||||||
message: "The number of approvals should be lower than the number of approvers."
|
message: "The number of approvals should be lower than the number of approvers."
|
||||||
});
|
});
|
||||||
@@ -75,8 +75,7 @@ export const AccessPolicyForm = ({
|
|||||||
? {
|
? {
|
||||||
...editValues,
|
...editValues,
|
||||||
environment: editValues.environment.slug,
|
environment: editValues.environment.slug,
|
||||||
approverUserIds:
|
approvers: editValues?.userApprovers?.map((user) => user.userId) || editValues?.approvers
|
||||||
editValues?.userApprovers?.map((user) => user.userId) || editValues?.approvers
|
|
||||||
}
|
}
|
||||||
: undefined
|
: undefined
|
||||||
});
|
});
|
||||||
@@ -264,7 +263,7 @@ export const AccessPolicyForm = ({
|
|||||||
/>
|
/>
|
||||||
<Controller
|
<Controller
|
||||||
control={control}
|
control={control}
|
||||||
name="approverUserIds"
|
name="approvers"
|
||||||
render={({ field: { value, onChange }, fieldState: { error } }) => (
|
render={({ field: { value, onChange }, fieldState: { error } }) => (
|
||||||
<FormControl
|
<FormControl
|
||||||
label="Required Approvers"
|
label="Required Approvers"
|
||||||
|
|||||||
+19
-8
@@ -1,4 +1,5 @@
|
|||||||
import { Fragment, useState } from "react";
|
import { Fragment, useEffect, useState } from "react";
|
||||||
|
import { useRouter } from "next/router";
|
||||||
import {
|
import {
|
||||||
faCheck,
|
faCheck,
|
||||||
faCheckCircle,
|
faCheckCircle,
|
||||||
@@ -31,7 +32,7 @@ import {
|
|||||||
useGetSecretApprovalRequests,
|
useGetSecretApprovalRequests,
|
||||||
useGetWorkspaceUsers
|
useGetWorkspaceUsers
|
||||||
} from "@app/hooks/api";
|
} from "@app/hooks/api";
|
||||||
import { ApprovalStatus, TSecretApprovalRequest } from "@app/hooks/api/types";
|
import { ApprovalStatus } from "@app/hooks/api/types";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
generateCommitText,
|
generateCommitText,
|
||||||
@@ -41,12 +42,13 @@ import {
|
|||||||
export const SecretApprovalRequest = () => {
|
export const SecretApprovalRequest = () => {
|
||||||
const { currentWorkspace } = useWorkspace();
|
const { currentWorkspace } = useWorkspace();
|
||||||
const workspaceId = currentWorkspace?.id || "";
|
const workspaceId = currentWorkspace?.id || "";
|
||||||
const [selectedApproval, setSelectedApproval] = useState<TSecretApprovalRequest | null>(null);
|
const [selectedApprovalId, setSelectedApprovalId] = useState<string | null>(null);
|
||||||
|
|
||||||
// filters
|
// filters
|
||||||
const [statusFilter, setStatusFilter] = useState<"open" | "close">("open");
|
const [statusFilter, setStatusFilter] = useState<"open" | "close">("open");
|
||||||
const [envFilter, setEnvFilter] = useState<string>();
|
const [envFilter, setEnvFilter] = useState<string>();
|
||||||
const [committerFilter, setCommitterFilter] = useState<string>();
|
const [committerFilter, setCommitterFilter] = useState<string>();
|
||||||
|
const [usingUrlRequestId, setUsingUrlRequestId] = useState(false);
|
||||||
|
|
||||||
const {
|
const {
|
||||||
data: secretApprovalRequests,
|
data: secretApprovalRequests,
|
||||||
@@ -64,12 +66,21 @@ export const SecretApprovalRequest = () => {
|
|||||||
const { data: secretApprovalRequestCount, isSuccess: isSecretApprovalReqCountSuccess } =
|
const { data: secretApprovalRequestCount, isSuccess: isSecretApprovalReqCountSuccess } =
|
||||||
useGetSecretApprovalRequestCount({ workspaceId });
|
useGetSecretApprovalRequestCount({ workspaceId });
|
||||||
const { user: userSession } = useUser();
|
const { user: userSession } = useUser();
|
||||||
|
const router = useRouter();
|
||||||
const { permission } = useProjectPermission();
|
const { permission } = useProjectPermission();
|
||||||
const { data: members } = useGetWorkspaceUsers(workspaceId);
|
const { data: members } = useGetWorkspaceUsers(workspaceId);
|
||||||
const isSecretApprovalScreen = Boolean(selectedApproval);
|
const isSecretApprovalScreen = Boolean(selectedApprovalId);
|
||||||
|
const { requestId } = router.query;
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!requestId || usingUrlRequestId) return;
|
||||||
|
|
||||||
|
setSelectedApprovalId(requestId as string);
|
||||||
|
setUsingUrlRequestId(true);
|
||||||
|
}, [requestId]);
|
||||||
|
|
||||||
const handleGoBackSecretRequestDetail = () => {
|
const handleGoBackSecretRequestDetail = () => {
|
||||||
setSelectedApproval(null);
|
setSelectedApprovalId(null);
|
||||||
refetch({ refetchPage: (_page, index) => index === 0 });
|
refetch({ refetchPage: (_page, index) => index === 0 });
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -88,7 +99,7 @@ export const SecretApprovalRequest = () => {
|
|||||||
>
|
>
|
||||||
<SecretApprovalRequestChanges
|
<SecretApprovalRequestChanges
|
||||||
workspaceId={workspaceId}
|
workspaceId={workspaceId}
|
||||||
approvalRequestId={selectedApproval?.id || ""}
|
approvalRequestId={selectedApprovalId || ""}
|
||||||
onGoBack={handleGoBackSecretRequestDetail}
|
onGoBack={handleGoBackSecretRequestDetail}
|
||||||
/>
|
/>
|
||||||
</motion.div>
|
</motion.div>
|
||||||
@@ -219,9 +230,9 @@ export const SecretApprovalRequest = () => {
|
|||||||
className="flex flex-col px-8 py-4 hover:bg-mineshaft-700"
|
className="flex flex-col px-8 py-4 hover:bg-mineshaft-700"
|
||||||
role="button"
|
role="button"
|
||||||
tabIndex={0}
|
tabIndex={0}
|
||||||
onClick={() => setSelectedApproval(secretApproval)}
|
onClick={() => setSelectedApprovalId(secretApproval.id)}
|
||||||
onKeyDown={(evt) => {
|
onKeyDown={(evt) => {
|
||||||
if (evt.key === "Enter") setSelectedApproval(secretApproval);
|
if (evt.key === "Enter") setSelectedApprovalId(secretApproval.id);
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<div className="mb-1">
|
<div className="mb-1">
|
||||||
|
|||||||
Reference in New Issue
Block a user