diff --git a/src/App.tsx b/src/App.tsx index f715a33f..d4e386d9 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -30,6 +30,7 @@ import { CommonVariables } from "./components/admin_tools/variables/CommonVariab import { SecuredVariables } from "./components/admin_tools/variables/SecuredVariables.tsx"; import { Domains } from "./components/admin_tools/domains/Domains.tsx"; import { ActionsLog } from "./components/admin_tools/ActionsLog.tsx"; +import { AccessControl } from "./components/admin_tools/access-control/AccessControl.tsx" import { NotImplemented } from "./pages/NotImplemented.tsx"; import { SessionsPage } from "./pages/SessionsPage.tsx"; import Services from "./pages/Services.tsx"; @@ -76,6 +77,7 @@ const router = createBrowserRouter( } /> } /> } /> + } /> } /> } /> diff --git a/src/api/api.ts b/src/api/api.ts index b78254f4..1e136328 100644 --- a/src/api/api.ts +++ b/src/api/api.ts @@ -71,6 +71,10 @@ import type { VariableImportPreview, SecretWithVariables, Variable, + AccessControlSearchRequest, + AccessControlResponse, + AccessControlUpdateRequest, + AccessControlBulkDeployRequest, } from "./apiTypes.ts"; import { RestApi } from "./rest/restApi.ts"; import { isVsCode, VSCodeExtensionApi } from "./rest/vscodeExtensionApi.ts"; @@ -501,6 +505,18 @@ export interface Api { ): Promise>; createSecret(secretName: string): Promise>; downloadHelmChart(secretName: string): Promise; + + loadHttpTriggerAccessControl( + searchRequest: AccessControlSearchRequest, + ): Promise; + + updateHttpTriggerAccessControl( + searchRequest: AccessControlUpdateRequest[], + ): Promise; + + bulkDeployChainsAccessControl( + searchRequest: AccessControlBulkDeployRequest[], + ): Promise; } export const api: Api = isVsCode ? new VSCodeExtensionApi() : new RestApi(); diff --git a/src/api/apiTypes.ts b/src/api/apiTypes.ts index 55ec1019..b33b737f 100644 --- a/src/api/apiTypes.ts +++ b/src/api/apiTypes.ts @@ -1182,6 +1182,63 @@ export interface SpecApiFile { fileUri: string; } +export type AccessControlSearchRequest = { + offset: number; + limit: number; + filters: unknown; +}; + +export type AccessControlUpdateRequest = { + elementId: string; + isRedeploy: boolean; + roles: string[]; +}; + +export type AccessControlResponse = { + offset: number; + roles: AccessControl[]; +}; + +export type AccessControlBulkDeployRequest = { + chainId: string; + unsavedChanges: boolean; +}; + +export type AccessControl = { + chainId: string; + chainName: string; + elementId: string; + elementName: string; + deploymentStatus: string[]; + unsavedChanges: boolean; + properties: Record; + modifiedWhen: number; +}; + +export enum AccessControlType { + RBAC = "RBAC", + ABAC = "ABAC", + NONE = "NONE", +} + +export type AbacParameters = { + operation: string; + resourceType: string; + resourceDataType: string; + resourceString?: string; + resourceMap?: Record; +}; + +export type AccessControlProperty = { + roles: string[]; + contextPath?: string; + integrationOperationPath?: string; + externalRoute: boolean; + privateRoute: boolean; + accessControlType?: AccessControlType; + abacParameters?: AbacParameters; +}; + export type LiveExchange = { exchangeId: string; deploymentId: string; diff --git a/src/api/rest/restApi.ts b/src/api/rest/restApi.ts index 06b27cdb..b217f9f4 100644 --- a/src/api/rest/restApi.ts +++ b/src/api/rest/restApi.ts @@ -74,6 +74,10 @@ import { BulkDeploymentResult, ImportVariablesResult, VariableImportPreview, + AccessControlSearchRequest, + AccessControlResponse, + AccessControlUpdateRequest, + AccessControlBulkDeployRequest, } from "../apiTypes.ts"; import { Api } from "../api.ts"; import { getFileFromResponse } from "../../misc/download-utils.ts"; @@ -1880,4 +1884,36 @@ export class RestApi implements Api { ); return response.data; }; + + loadHttpTriggerAccessControl = async ( + searchRequest: AccessControlSearchRequest, + ): Promise => { + const response = await this.instance.post( + `${this.v1()}/catalog/chains/roles`, + searchRequest, + ); + return response.data; + }; + + updateHttpTriggerAccessControl = async ( + searchRequest: AccessControlUpdateRequest[], + ): Promise => { + const response = await this.instance.put( + `${this.v1()}/catalog/chains/roles`, + searchRequest, + ); + + return response.data; + }; + + bulkDeployChainsAccessControl = async ( + searchRequest: AccessControlBulkDeployRequest[], + ): Promise => { + const response = await this.instance.put( + `${this.v1()}/catalog/chains/roles/redeploy`, + searchRequest, + ); + + return response.data; + }; } diff --git a/src/api/rest/vscodeExtensionApi.ts b/src/api/rest/vscodeExtensionApi.ts index e4dce472..1a2c92e3 100644 --- a/src/api/rest/vscodeExtensionApi.ts +++ b/src/api/rest/vscodeExtensionApi.ts @@ -1,6 +1,7 @@ import { ActionDifference, ActionLogResponse, + AccessControlResponse, BaseEntity, Chain, ChainDeployment, @@ -949,6 +950,18 @@ export class VSCodeExtensionApi implements Api { throw new Error("Method bulkDeploy not implemented."); } + loadHttpTriggerAccessControl = async (): Promise => { + throw new Error("Method loadHttpTriggerAccessControl not implemented."); + }; + + updateHttpTriggerAccessControl = async (): Promise => { + throw new Error("Method updateHttpTriggerAccessControl not implemented."); + }; + + bulkDeployChainsAccessControl = async (): Promise => { + throw new Error("Method bulkDeployChainsAccessControl not implemented."); + }; + getCommonVariables(): Promise> { throw new RestApiError("Not implemented", 501); } diff --git a/src/components/admin_tools/AdminToolsSidebar.tsx b/src/components/admin_tools/AdminToolsSidebar.tsx index 22c08aaa..3a7c2e13 100644 --- a/src/components/admin_tools/AdminToolsSidebar.tsx +++ b/src/components/admin_tools/AdminToolsSidebar.tsx @@ -41,9 +41,9 @@ const menuItems = [ label: "Sessions", }, { - key: "/admintools/roles", + key: "/admintools/access-control", icon: , - label: "Roles", + label: "Access Control", }, { key: "/admintools/design-templates", diff --git a/src/components/admin_tools/access-control/AbacAttributesPopUp.module.css b/src/components/admin_tools/access-control/AbacAttributesPopUp.module.css new file mode 100644 index 00000000..a610bb76 --- /dev/null +++ b/src/components/admin_tools/access-control/AbacAttributesPopUp.module.css @@ -0,0 +1,51 @@ +.header { + display: flex; + align-items: center; + margin-bottom: 8px; +} + +.leftHeader { + display: flex; + align-items: center; + gap: 8px; + cursor: pointer; +} + +.iconWrapper { + display: flex; + align-items: center; + gap: 8px; +} + +.badge { + min-width: 22px; + height: 22px; + border-radius: 11px; + background: #e6eef8; + color: #0b66ff; + display: inline-flex; + align-items: center; + justify-content: center; + font-size: 12px; +} + +.table { + width: 100%; + border-collapse: collapse; +} + +.th { + text-align: left; + border-bottom: 1px solid #ddd; + padding: 8px 4px; + font-weight: 500; +} + +.td { + border-bottom: 1px solid #eee; + padding: 4px; +} + +.noEntries { + font-weight: 600; +} diff --git a/src/components/admin_tools/access-control/AbacAttributesPopUp.tsx b/src/components/admin_tools/access-control/AbacAttributesPopUp.tsx new file mode 100644 index 00000000..12eb9137 --- /dev/null +++ b/src/components/admin_tools/access-control/AbacAttributesPopUp.tsx @@ -0,0 +1,118 @@ +import { Button, Form, Input, Modal } from "antd"; +import React, { useState } from "react"; +import { useModalContext } from "../../../ModalContextProvider.tsx"; +import { + AccessControl as AccessControlData, + AccessControlProperty, +} from "../../../api/apiTypes.ts"; +import { OverridableIcon } from "../../../icons/IconProvider.tsx"; +import styles from "./AbacAttributesPopUp.module.css"; + +export type AbacAttributesPopUpProps = { + record: AccessControlData; +}; + +export const AbacAttributesPopUp: React.FC = ({ + record, +}) => { + const { closeContainingModal } = useModalContext(); + const props = record.properties as unknown as + | AccessControlProperty + | undefined; + const abac = props?.abacParameters; + + return ( + + Close + , + ]} + width={600} + > +
+ + + + + + + + + + {abac?.resourceMap && ( + + + + )} + {abac?.resourceString != null && abac.resourceString !== "" && ( + + + + )} +
+
+ ); +}; + +const ResourceMapDisplay: React.FC<{ + resourceMap: Record; +}> = ({ resourceMap }) => { + const rowCount = Object.entries(resourceMap).length; + const [collapsed, setCollapsed] = useState(false); + + return ( +
+
+
setCollapsed((s) => !s)} + > + + {collapsed ? ( + + ) : ( + + )} + + Resource Map + {rowCount} +
+
+ {!collapsed && + (rowCount === 0 ? ( +
No entries.
+ ) : ( + + + + + + + + + {Object.entries(resourceMap).map(([key, value], idx) => ( + + + + + ))} + +
NameValue
+ + + +
+ ))} +
+ ); +}; diff --git a/src/components/admin_tools/access-control/AccessControl.tsx b/src/components/admin_tools/access-control/AccessControl.tsx new file mode 100644 index 00000000..c04b9b4b --- /dev/null +++ b/src/components/admin_tools/access-control/AccessControl.tsx @@ -0,0 +1,779 @@ +import React, { useState, UIEvent } from "react"; +import { + Flex, + Table, + Typography, + Button, + Dropdown, + MenuProps, + FloatButton, + Tag, + Drawer, + Descriptions, +} from "antd"; +import { useResizeHeight } from "../../../hooks/useResizeHeigth.tsx"; +import { ResizableTitle } from "../../ResizableTitle.tsx"; +import commonStyles from "../CommonStyle.module.css"; +import { OverridableIcon } from "../../../icons/IconProvider.tsx"; +import { makeEnumColumnFilterDropdown } from "../../EnumColumnFilterDropdown.tsx"; +import { + AccessControlType, + AccessControl as AccessControlData, + AccessControlBulkDeployRequest, + AccessControlProperty, +} from "../../../api/apiTypes.ts"; +import type { FilterDropdownProps } from "antd/lib/table/interface"; +import { + getTextColumnFilterFn, + TextColumnFilterDropdown, +} from "../../table/TextColumnFilterDropdown.tsx"; +import { useAccessControl } from "../../../hooks/useAccessControl.tsx"; +import { ColumnsType } from "antd/es/table"; +import { useModalsContext } from "../../../Modals.tsx"; +import { AbacAttributesPopUp } from "./AbacAttributesPopUp.tsx"; +import { AddDeleteRolesPopUp } from "./AddDeleteRolesPopUp.tsx"; +import { useNavigate } from "react-router"; +import { DeploymentsCumulativeState } from "../../deployment_runtime_states/DeploymentsCumulativeState.tsx"; +import FloatButtonGroup from "antd/lib/float-button/FloatButtonGroup"; +import { useNotificationService } from "../../../hooks/useNotificationService.tsx"; + +const { Title } = Typography; + +const columnVisibilityMenuItems: MenuProps["items"] = [ + { label: "Endpoint", key: "endpoint" }, + { label: "Type", key: "type" }, + { label: "Access Control Type", key: "accessControlType" }, + { label: "Roles", key: "roles" }, + { label: "Attributes", key: "attributes" }, + { label: "Chain", key: "chain" }, + { label: "Chain Status", key: "chainStatus" }, +]; + +const typeOptions = [ + { label: "External", value: "External" }, + { label: "Private", value: "Private" }, + { label: "Internal", value: "Internal" }, + { label: "External, Private", value: "External, Private" }, +]; + +const chainStatusOptions = [ + { label: "Draft", value: "DRAFT" }, + { label: "Deployed", value: "DEPLOYED" }, + { label: "Failed", value: "FAILED" }, + { label: "Processing", value: "PROCESSING" }, +]; + +const accessControlTypeOptions = Object.values(AccessControlType).map( + (value) => ({ + label: value, + value, + }), +); + +export const AccessControl: React.FC = () => { + const { + isLoading, + accessControlData, + setAccessControlData, + getAccessControl, + bulkDeployAccessControl, + } = useAccessControl(); + const { showModal } = useModalsContext(); + const navigate = useNavigate(); + const notificationService = useNotificationService(); + + const [selectedRowKeys, setSelectedRowKeys] = useState([]); + const [containerRef, containerHeight] = useResizeHeight(); + const [currentRecord, setCurrentRecord] = useState( + null, + ); + const [openSidebar, setOpenSidebar] = useState(false); + const [deployedChainIds, setDeployedChainIds] = useState>( + new Set(), + ); + + const [selectedKeys, setSelectedKeys] = useState([ + "endpoint", + "type", + "accessControlType", + "roles", + "attributes", + "chain", + "chainStatus", + ]); + + const [columnsWidth] = useState<{ [key: string]: number }>({ + checkbox: 50, + endpoint: 200, + type: 100, + accessControlType: 160, + roles: 100, + attributes: 100, + chain: 190, + chainStatus: 110, + }); + + const totalColumnsWidth = Object.values(columnsWidth).reduce( + (acc, width) => acc + width, + 0, + ); + + const { filterDropdown: accessControlTypeFilter } = + makeEnumColumnFilterDropdown( + accessControlTypeOptions, + "accessControlType", + true, + ); + + const { filterDropdown: typeFilter } = makeEnumColumnFilterDropdown( + typeOptions, + "type", + true, + ); + + const { filterDropdown: chainStatusFilter } = makeEnumColumnFilterDropdown( + chainStatusOptions, + "chainStatus", + true, + ); + + const onScroll = async (_event: UIEvent) => {}; + + const showDrawer = (record: AccessControlData) => { + setCurrentRecord(record); + setOpenSidebar(true); + }; + + const onClose = () => { + setOpenSidebar(false); + setCurrentRecord(null); + }; + + const handleBulkDeploy = async (records: AccessControlData[]) => { + if (records.length === 0) { + notificationService.info("Error", "No chains to deploy"); + return; + } + + const recordsToDeploy = records.filter( + (record) => record.chainId && record.unsavedChanges, + ); + + if (recordsToDeploy.length === 0) { + notificationService.info("Error", "No changes to apply"); + return; + } + + try { + const bulkDeployRequests: AccessControlBulkDeployRequest[] = + recordsToDeploy.map((record) => ({ + chainId: record.chainId, + unsavedChanges: record.unsavedChanges, + })); + + await bulkDeployAccessControl(bulkDeployRequests); + + const chainToDeploy = new Set(recordsToDeploy.map((r) => r.chainId)); + setDeployedChainIds(new Set(chainToDeploy)); + + if (accessControlData?.roles) { + const deployedChainIds = new Set( + recordsToDeploy.map((r) => `${r.chainId}-${r.elementId}`), + ); + const updatedRoles = accessControlData.roles.map((role) => { + const rowKey = `${role.chainId}-${role.elementId}`; + if (deployedChainIds.has(rowKey)) { + return { ...role, unsavedChanges: false }; + } + return role; + }); + setAccessControlData({ ...accessControlData, roles: updatedRoles }); + } + + notificationService.info("Success", `Selected chains are deployed.`); + return; + } catch (err: unknown) { + notificationService.requestFailed( + "Failed to deploy chains", + err instanceof Error ? err : new Error(String(err)), + ); + } + }; + + const columns: ColumnsType = [ + { + title: "Endpoint", + dataIndex: "endpoint", + key: "endpoint", + sorter: { + compare: (a: AccessControlData, b: AccessControlData) => { + const aPath = + (a.properties as unknown as AccessControlProperty)?.contextPath ?? + ""; + const bPath = + (b.properties as unknown as AccessControlProperty)?.contextPath ?? + ""; + return String(aPath).localeCompare(String(bPath)); + }, + }, + render: (_value: unknown, record: AccessControlData) => { + if (record.chainId) { + return ( + + void navigate( + `/chains/${record.chainId}/graph/${record.elementId}`, + ) + } + > + {(record.properties as unknown as AccessControlProperty) + ?.contextPath || "—"} + + ); + } + return <>—; + }, + }, + { + title: "Type", + key: "type", + filterDropdown: typeFilter, + onFilter: (value: React.Key | boolean, record: AccessControlData) => { + const { externalRoute, privateRoute } = + (record.properties as unknown as AccessControlProperty) ?? {}; + const recordType = + externalRoute && privateRoute + ? "External, Private" + : externalRoute + ? "External" + : privateRoute + ? "Private" + : "Internal"; + return recordType === value; + }, + render: (_value: unknown, record: AccessControlData) => { + const props = record.properties as unknown as + | AccessControlProperty + | undefined; + const { externalRoute, privateRoute } = props ?? {}; + return ( + <> + {externalRoute && privateRoute + ? "External, Private" + : externalRoute + ? "External" + : privateRoute + ? "Private" + : "Internal"} + + ); + }, + }, + { + title: "Access Control Type", + key: "accessControlType", + dataIndex: "accessControlType", + hidden: !selectedKeys.includes("accessControlType"), + filterDropdown: accessControlTypeFilter, + onFilter: (value: React.Key | boolean, record: AccessControlData) => { + const recordValue = ( + record.properties as unknown as AccessControlProperty + )?.accessControlType; + return recordValue === value; + }, + render: (_value: unknown, record: AccessControlData) => { + const props = record.properties as unknown as + | AccessControlProperty + | undefined; + const val = props?.accessControlType; + return ( + <>{typeof val === "string" ? val : val != null ? String(val) : "—"} + ); + }, + }, + { + title: "Roles", + key: "roles", + filterDropdown: (props: FilterDropdownProps) => ( + + ), + onFilter: getTextColumnFilterFn((record: AccessControlData) => { + const roles = (record.properties as unknown as AccessControlProperty) + ?.roles; + if (roles && Array.isArray(roles) && roles.length > 0) { + return roles.join(" "); + } + return ""; + }), + render: (_value: unknown, record: AccessControlData) => { + const props = record.properties as unknown as + | AccessControlProperty + | undefined; + const roles = props?.roles; + if (roles && Array.isArray(roles) && roles.length > 0) { + return ( + + {roles.map((role, idx) => ( + + {role} + + ))} + + ); + } + return <>—; + }, + }, + { + title: "Attributes", + key: "attributes", + render: (_value: unknown, record: AccessControlData) => { + if ( + (record.properties as unknown as AccessControlProperty) + ?.accessControlType === AccessControlType.ABAC + ) { + return ( + { + e.stopPropagation(); + showModal({ + component: , + }); + }} + > + Details + + ); + } else return <>{"—"}; + }, + }, + { + title: "Chain", + key: "chain", + filterDropdown: (props: FilterDropdownProps) => ( + + ), + onFilter: getTextColumnFilterFn((record: AccessControlData) => + record?.chainName ? record.chainName : "", + ), + render: (_value: unknown, record: AccessControlData) => { + if (record.chainId) { + return ( + void navigate(`/chains/${record.chainId}`)}> + {record.chainName} + + ); + } + return <>—; + }, + }, + { + title: "Chain Status", + key: "chainStatus", + filterDropdown: chainStatusFilter, + onFilter: (value: React.Key | boolean, record: AccessControlData) => { + if ( + !record.chainId || + !record.deploymentStatus || + record.deploymentStatus.length === 0 + ) { + return value === "DRAFT"; + } + const statuses = new Set( + record.deploymentStatus.map((s) => s.toUpperCase()), + ); + if (statuses.size === 0) { + return value === "DRAFT"; + } else if (statuses.size === 1) { + return statuses.has(value as string); + } else { + const priority = ["PROCESSING", "FAILED", "DEPLOYED", "DRAFT"]; + for (const status of priority) { + if (statuses.has(status)) { + return status === value; + } + } + return false; + } + }, + render: (_value: unknown, record: AccessControlData) => { + if (record.chainId) { + return ( + + ); + } + return <>—; + }, + }, + { + title: "ID", + key: "id", + hidden: true, + }, + ]; + + return ( + + + + <OverridableIcon name="settings" className={commonStyles["icon"]} /> + Access Control + + + setSelectedKeys(selectedKeys), + onDeselect: ({ selectedKeys }) => setSelectedKeys(selectedKeys), + }} + > + , + , + ]} + width={600} + > +
+ +