+ }
+ style={{
+ height: "100%",
+ display: "flex",
+ flexDirection: "column",
+ }}
+ >
+
+ className="flex-table"
+ size="small"
+ /* eslint-disable-next-line @typescript-eslint/no-unsafe-assignment -- columns type from ColumnsType */
+ columns={columns}
+ dataSource={accessControlData?.roles}
+ scroll={{
+ x: totalColumnsWidth,
+ y: containerHeight - 59 || 400,
+ }}
+ pagination={false}
+ rowKey={(record: AccessControlData) =>
+ `${record.chainId}-${record.elementId}`
+ }
+ loading={isLoading}
+ onScroll={(event) => void onScroll(event)}
+ rowSelection={{
+ selectedRowKeys,
+ onChange: (newSelectedRowKeys) => {
+ setSelectedRowKeys(newSelectedRowKeys);
+ },
+ }}
+ components={{
+ header: {
+ cell: ResizableTitle,
+ },
+ }}
+ rowClassName={(row) =>
+ row.unsavedChanges ? "highlight-row" : ""
+ }
+ onRow={(row: AccessControlData) => {
+ return {
+ onClick: (event: React.MouseEvent) => {
+ const target = event.target as HTMLElement;
+ if (
+ target.closest("button") ||
+ target.closest(".ant-dropdown") ||
+ target.closest("input") ||
+ target.closest("a") ||
+ target.closest(".ant-checkbox-wrapper") ||
+ target.closest('span[style*="cursor: pointer"]')
+ ) {
+ return;
+ }
+ showDrawer(row);
+ },
+ };
+ }}
+ />
+
+
+
+ }>
+ }
+ onClick={() => {
+ if (selectedRowKeys.length === 0) {
+ notificationService.info(
+ "No selection",
+ "Please select at least one row to modify",
+ );
+ return;
+ }
+
+ const selectedRecords = (accessControlData?.roles ?? []).filter(
+ (record: AccessControlData) => {
+ const rowKey = `${record.chainId}-${record.elementId}`;
+ return selectedRowKeys.includes(rowKey);
+ },
+ );
+
+ if (selectedRecords.length > 0) {
+ void handleBulkDeploy(selectedRecords);
+ } else {
+ notificationService.info("Error", "Selected records not found");
+ }
+ }}
+ />
+ }
+ onClick={() => {
+ if (selectedRowKeys.length === 0) {
+ notificationService.info(
+ "No selection",
+ "Please select at least one row to modify",
+ );
+ return;
+ }
+ const selectedRecords = (accessControlData?.roles ?? []).filter(
+ (record: AccessControlData) => {
+ const rowKey = `${record.chainId}-${record.elementId}`;
+ return selectedRowKeys.includes(rowKey);
+ },
+ );
+ if (selectedRecords.length > 0) {
+ const validRecords = selectedRecords.filter((record) => {
+ const accessControlType = (
+ record.properties as unknown as AccessControlProperty
+ )?.accessControlType;
+ return accessControlType !== AccessControlType.ABAC;
+ });
+ if (validRecords.length === 0) {
+ notificationService.info(
+ "Error",
+ "Can't apply roles to ABAC endpoint",
+ );
+ return;
+ }
+ showModal({
+ component: (
+ void getAccessControl()}
+ />
+ ),
+ });
+ } else {
+ notificationService.info("Error", "Selected record not found");
+ }
+ }}
+ />
+ }
+ onClick={() => {
+ if (selectedRowKeys.length === 0) {
+ notificationService.info(
+ "No selection",
+ "Please select at least one row to delete roles",
+ );
+ return;
+ }
+ const selectedRecords = (accessControlData?.roles ?? []).filter(
+ (record: AccessControlData) => {
+ const rowKey = `${record.chainId}-${record.elementId}`;
+ return selectedRowKeys.includes(rowKey);
+ },
+ );
+ if (selectedRecords.length > 0) {
+ const validRecords = selectedRecords.filter((record) => {
+ const accessControlType = (
+ record.properties as unknown as AccessControlProperty
+ )?.accessControlType;
+ return accessControlType !== AccessControlType.ABAC;
+ });
+ if (validRecords.length === 0) {
+ notificationService.info(
+ "Error",
+ "Can't apply roles to ABAC endpoint",
+ );
+ return;
+ }
+ showModal({
+ component: (
+ void getAccessControl()}
+ />
+ ),
+ });
+ } else {
+ notificationService.info("Error", "Selected record not found");
+ }
+ }}
+ />
+ }
+ onClick={() => void getAccessControl()}
+ />
+
+
+ );
+};
diff --git a/src/components/admin_tools/access-control/AddDeleteRolesPopUp.tsx b/src/components/admin_tools/access-control/AddDeleteRolesPopUp.tsx
new file mode 100644
index 00000000..b35c461b
--- /dev/null
+++ b/src/components/admin_tools/access-control/AddDeleteRolesPopUp.tsx
@@ -0,0 +1,215 @@
+/* eslint-disable react/prop-types -- TypeScript types define props */
+import { Button, Checkbox, Form, Modal, Select } from "antd";
+import React, { useState, useEffect } from "react";
+import { useModalContext } from "../../../ModalContextProvider.tsx";
+import {
+ AccessControl as AccessControlData,
+ AccessControlProperty,
+ AccessControlUpdateRequest,
+} from "../../../api/apiTypes.ts";
+import { useNotificationService } from "../../../hooks/useNotificationService.tsx";
+import { useAccessControl } from "../../../hooks/useAccessControl.tsx";
+
+export type AddDeleteRolesPopUpProps = {
+ record?: AccessControlData;
+ records?: AccessControlData[];
+ onSuccess?: () => void;
+ mode?: "add" | "delete";
+};
+
+export const AddDeleteRolesPopUp: React.FC = ({
+ record,
+ records,
+ onSuccess,
+ mode = "add",
+}) => {
+ const recordsToProcess =
+ records && records.length > 0 ? records : record ? [record] : [];
+ const { closeContainingModal } = useModalContext();
+ const notificationService = useNotificationService();
+ const { updateAccessControl } = useAccessControl();
+ const [form] = Form.useForm();
+ const getAllUniqueRoles = (): string[] => {
+ const allRoles = new Set();
+ recordsToProcess.forEach((rec) => {
+ const roles = (
+ rec?.properties as unknown as AccessControlProperty | undefined
+ )?.roles;
+ if (Array.isArray(roles)) {
+ roles.forEach((role: string) => allRoles.add(role));
+ }
+ });
+ return Array.from(allRoles);
+ };
+
+ const getInitialRoles = (): string[] => {
+ if (recordsToProcess.length === 0) return [];
+ if (recordsToProcess.length > 1) {
+ return getAllUniqueRoles();
+ }
+ const props = recordsToProcess[0]?.properties as unknown as
+ | AccessControlProperty
+ | undefined;
+ const roles = props?.roles;
+ return Array.isArray(roles) ? [...roles] : [];
+ };
+ const isDeleteMode = mode === "delete";
+ const [selectedRoles, setSelectedRoles] = useState(
+ isDeleteMode ? [] : getInitialRoles(),
+ );
+ const [isLoading, setIsLoading] = useState(false);
+
+ useEffect(() => {
+ if (isDeleteMode) {
+ setSelectedRoles([]);
+ form.setFieldsValue({ roles: [], redeploy: false });
+ } else {
+ const roles = getInitialRoles();
+ setSelectedRoles(roles);
+ form.setFieldsValue({ roles, redeploy: false });
+ }
+ // eslint-disable-next-line react-hooks/exhaustive-deps -- getInitialRoles depends on recordsToProcess
+ }, [recordsToProcess, form, isDeleteMode]);
+
+ const handleSubmit = async () => {
+ if (recordsToProcess.length === 0) {
+ notificationService.info("Error", "Element ID is required");
+ return;
+ }
+
+ if (isDeleteMode && selectedRoles.length === 0) {
+ notificationService.info(
+ "Error",
+ "Please select at least one role to delete",
+ );
+ return;
+ }
+
+ try {
+ setIsLoading(true);
+
+ const formValues = form.getFieldsValue() as {
+ roles?: string[];
+ redeploy?: boolean;
+ };
+ const updateRequests: AccessControlUpdateRequest[] = recordsToProcess.map(
+ (rec) => {
+ if (!rec.elementId) {
+ throw new Error("Element ID is required");
+ }
+
+ const props = rec.properties as unknown as
+ | AccessControlProperty
+ | undefined;
+ const existingRoles = Array.isArray(props?.roles) ? props?.roles : [];
+ let finalRoles: string[];
+
+ if (isDeleteMode) {
+ if (!(existingRoles) || existingRoles.length > 0) {
+ if (existingRoles) {
+ finalRoles = existingRoles.filter(
+ (role: string) => !selectedRoles.includes(role),
+ );
+ }
+ } else {
+ finalRoles = [];
+ }
+ } else {
+ const mergedRoles = [...existingRoles, ...selectedRoles];
+ finalRoles = Array.from(new Set(mergedRoles));
+ }
+
+ return {
+ elementId: rec.elementId,
+ isRedeploy: Boolean(formValues.redeploy),
+ roles: finalRoles,
+ };
+ },
+ );
+
+ await updateAccessControl(updateRequests);
+ notificationService.info(
+ "Success",
+ isDeleteMode
+ ? "Roles deleted successfully"
+ : "Roles updated successfully",
+ );
+ onSuccess?.();
+ closeContainingModal();
+ } catch (err: unknown) {
+ notificationService.requestFailed(
+ isDeleteMode ? "Failed to delete roles" : "Failed to update roles",
+ err instanceof Error ? err : new Error(String(err)),
+ );
+ } finally {
+ setIsLoading(false);
+ }
+ };
+
+ const handleRolesChange = (roles: string[] | []) => {
+ const rolesArray = Array.isArray(roles) ? roles : [];
+
+ if (!isDeleteMode && rolesArray.length < selectedRoles.length) {
+ form.setFieldsValue({ roles: selectedRoles });
+ return;
+ }
+
+ setSelectedRoles(rolesArray);
+ form.setFieldsValue({ roles: rolesArray });
+ };
+
+ const roleOptions = isDeleteMode
+ ? getAllUniqueRoles().map((role) => ({ label: role, value: role }))
+ : [];
+
+ return (
+
+ Cancel
+ ,
+ ,
+ ]}
+ width={600}
+ >
+
+
+
+
+ Redeploy selected chain to apply changes
+
+
+
+ );
+};
diff --git a/src/components/deployment_runtime_states/DeploymentsCumulativeState.tsx b/src/components/deployment_runtime_states/DeploymentsCumulativeState.tsx
index 995e38d0..d9d7a04b 100644
--- a/src/components/deployment_runtime_states/DeploymentsCumulativeState.tsx
+++ b/src/components/deployment_runtime_states/DeploymentsCumulativeState.tsx
@@ -6,6 +6,7 @@ import { useDeployments } from "../../hooks/useDeployments.tsx";
type DeploymentsCumulativeStateProps = {
chainId: string;
+ isNotificationEnabled?: boolean;
};
function getDeploymentsStatus(deployments: Deployment[]): string {
@@ -47,11 +48,11 @@ function getDeploymentBadgeStatus(status: string): BadgeProps["status"] {
export const DeploymentsCumulativeState: React.FC<
DeploymentsCumulativeStateProps
-> = ({ chainId }) => {
+> = ({ chainId, isNotificationEnabled = true }) => {
const [status, setStatus] = useState("DRAFT");
const [badgeStatus, setBadgeStatus] =
useState("default");
- const { isLoading, deployments } = useDeployments(chainId);
+ const { isLoading, deployments } = useDeployments(chainId, isNotificationEnabled);
useEffect(() => {
setStatus(getDeploymentsStatus(deployments));
diff --git a/src/hooks/useAccessControl.tsx b/src/hooks/useAccessControl.tsx
new file mode 100644
index 00000000..da3be1ae
--- /dev/null
+++ b/src/hooks/useAccessControl.tsx
@@ -0,0 +1,86 @@
+import { api } from "../api/api.ts";
+import {
+ AccessControlResponse,
+ AccessControlSearchRequest,
+ AccessControlUpdateRequest,
+ AccessControlBulkDeployRequest,
+} from "../api/apiTypes.ts";
+import { useCallback, useEffect, useState } from "react";
+import { useNotificationService } from "./useNotificationService.tsx";
+
+export const useAccessControl = () => {
+ const [isLoading, setIsLoading] = useState(false);
+ const [accessControlData, setAccessControlData] =
+ useState();
+ const notificationService = useNotificationService();
+
+ const getAccessControl = useCallback(async () => {
+ try {
+ setIsLoading(true);
+ const searchRequest: AccessControlSearchRequest = {
+ offset: 0,
+ limit: 30,
+ filters: [],
+ };
+ const responseData =
+ await api.loadHttpTriggerAccessControl(searchRequest);
+ setAccessControlData(responseData);
+ } catch (error) {
+ notificationService.requestFailed(
+ "Failed to load Http Trigger's Access Control",
+ error,
+ );
+ } finally {
+ setIsLoading(false);
+ }
+ }, [notificationService]);
+
+ const updateAccessControl = useCallback(
+ async (searchRequest: AccessControlUpdateRequest[]) => {
+ try {
+ const elementChange =
+ await api.updateHttpTriggerAccessControl(searchRequest);
+ setAccessControlData(elementChange);
+ } catch (error) {
+ notificationService.requestFailed(
+ "Failed to update Http Trigger's Access Control",
+ error,
+ );
+ } finally {
+ setIsLoading(false);
+ }
+ },
+ [notificationService],
+ );
+
+ const bulkDeployAccessControl = useCallback(
+ async (searchRequest: AccessControlBulkDeployRequest[]) => {
+ try {
+ const bulkDeployResponse =
+ await api.bulkDeployChainsAccessControl(searchRequest);
+ setAccessControlData(bulkDeployResponse);
+ } catch (error) {
+ notificationService.requestFailed(
+ "Failed to bulk deploy chains",
+ error,
+ );
+ } finally {
+ setIsLoading(false);
+ }
+ },
+ [notificationService],
+ );
+
+ useEffect(() => {
+ void getAccessControl();
+ }, [getAccessControl]);
+
+ return {
+ isLoading,
+ accessControlData,
+ setAccessControlData,
+ getAccessControl,
+ updateAccessControl,
+ bulkDeployAccessControl,
+ };
+};
diff --git a/src/hooks/useDeployments.tsx b/src/hooks/useDeployments.tsx
index 6da55615..b357d011 100644
--- a/src/hooks/useDeployments.tsx
+++ b/src/hooks/useDeployments.tsx
@@ -37,7 +37,10 @@ export const StatusNotificationMap: Record<
},
};
-export const useDeployments = (chainId?: string) => {
+export const useDeployments = (
+ chainId?: string,
+ isNotificationEnabled: boolean = true,
+) => {
const { subscribe } = useEventContext();
const notificationService = useNotificationService();
const [deployments, setDeployments] = useState([]);
@@ -64,6 +67,9 @@ export const useDeployments = (chainId?: string) => {
const showEventNotification = useCallback(
(data: DeploymentUpdate) => {
+ if (!isNotificationEnabled) {
+ return;
+ }
const chainName = data.chainName;
const status: DeploymentStatus = data.state.status;
const notificationData = StatusNotificationMap[status];
@@ -78,7 +84,7 @@ export const useDeployments = (chainId?: string) => {
}
notificationService.info(chainName, notificationData.message);
},
- [notificationService],
+ [notificationService, isNotificationEnabled],
);
useEffect(() => {
diff --git a/src/styles/antd-overrides.css b/src/styles/antd-overrides.css
index eaf9c68e..77e01c4e 100644
--- a/src/styles/antd-overrides.css
+++ b/src/styles/antd-overrides.css
@@ -363,6 +363,14 @@ pre[class*="language-"] .token.punctuation {
background-color: var(--vscode-list-hoverBackground) !important;
}
+.ant-table-tbody > tr.highlight-row > td {
+ background-color: #fffef0 !important;
+}
+
+.ant-table-tbody > tr.highlight-row:hover > td {
+ background-color: #fffef0 !important;
+}
+
.ant-table-cell {
border-color: var(
--vscode-editorGroup-border,