diff --git a/cmd/api/src/docs/json/definitions/models.json b/cmd/api/src/docs/json/definitions/models.json
index 78b308b5a8..0821c4b274 100644
--- a/cmd/api/src/docs/json/definitions/models.json
+++ b/cmd/api/src/docs/json/definitions/models.json
@@ -65,7 +65,10 @@
"properties": {
"action": {
"type": "string",
- "enum": ["add", "remove"]
+ "enum": [
+ "add",
+ "remove"
+ ]
},
"selector_name": {
"type": "string"
@@ -562,7 +565,12 @@
},
"model.PaginatedResponse": {
"type": "object",
- "required": ["count", "limit", "skip", "data"],
+ "required": [
+ "count",
+ "limit",
+ "skip",
+ "data"
+ ],
"properties": {
"count": {
"type": "integer"
@@ -1105,5 +1113,25 @@
"format": "date-time"
}
}
+ },
+ "model.SearchResult": {
+ "type": "object",
+ "properties": {
+ "objectid": {
+ "type": "string"
+ },
+ "type": {
+ "type": "string"
+ },
+ "name": {
+ "type": "string"
+ },
+ "distinguishedname": {
+ "type": "string"
+ },
+ "system_tags": {
+ "type": "string"
+ }
+ }
}
-}
+}
\ No newline at end of file
diff --git a/cmd/api/src/docs/json/definitions/v2.json b/cmd/api/src/docs/json/definitions/v2.json
index 39a19512e3..f6d86b069d 100644
--- a/cmd/api/src/docs/json/definitions/v2.json
+++ b/cmd/api/src/docs/json/definitions/v2.json
@@ -28,7 +28,10 @@
},
"v2.RiskAcceptRequest": {
"type": "object",
- "required": ["risk_type", "accepted"],
+ "required": [
+ "risk_type",
+ "accepted"
+ ],
"properties": {
"risk_type": {
"type": "string"
@@ -57,9 +60,15 @@
"data": {
"type": "object",
"properties": {
- "count": { "type": "integer" },
- "limit": { "type": "integer" },
- "skip": { "type": "integer" },
+ "count": {
+ "type": "integer"
+ },
+ "limit": {
+ "type": "integer"
+ },
+ "skip": {
+ "type": "integer"
+ },
"members": {
"type": "array",
"items": {
@@ -93,7 +102,10 @@
},
"v2.ClientCreateRequest": {
"type": "object",
- "required": ["name", "type"],
+ "required": [
+ "name",
+ "type"
+ ],
"properties": {
"domain_controller": {
"type": "string"
@@ -111,7 +123,9 @@
},
"v2.ClientUpdateRequest": {
"type": "object",
- "required": ["name"],
+ "required": [
+ "name"
+ ],
"properties": {
"name": {
"type": "string"
@@ -222,7 +236,10 @@
"status": {
"description": "Status code for complete (2) or failed (5)",
"type": "integer",
- "enum": [2, 5],
+ "enum": [
+ 2,
+ 5
+ ],
"required": true
},
"message": {
@@ -477,7 +494,10 @@
},
"v2.CreateUserRequest": {
"type": "object",
- "required": ["email_address", "roles"],
+ "required": [
+ "email_address",
+ "roles"
+ ],
"properties": {
"email_address": {
"type": "string",
@@ -618,7 +638,9 @@
},
"v2.ListAppConfigParametersResponse": {
"type": "object",
- "required": ["data"],
+ "required": [
+ "data"
+ ],
"properties": {
"data": {
"type": "object"
@@ -627,7 +649,9 @@
},
"v2.ListFlagsResponse": {
"type": "object",
- "required": ["data"],
+ "required": [
+ "data"
+ ],
"properties": {
"data": {
"type": "object"
@@ -636,7 +660,9 @@
},
"v2.ToggleFlagResponse": {
"type": "object",
- "required": ["enabled"],
+ "required": [
+ "enabled"
+ ],
"properties": {
"enabled": {
"type": "boolean"
@@ -645,7 +671,9 @@
},
"v2.ListSAMLSignOnEndpointsResponse": {
"type": "object",
- "required": ["endpoints"],
+ "required": [
+ "endpoints"
+ ],
"properties": {
"endpoints": {
"type": "boolean"
@@ -654,7 +682,9 @@
},
"v2.IDPValidationResponse": {
"type": "object",
- "required": ["successful"],
+ "required": [
+ "successful"
+ ],
"properties": {
"error_message": {
"type": "string"
@@ -697,7 +727,9 @@
},
"v2.MFAEnrollmentRequest": {
"type": "object",
- "required": ["secret"],
+ "required": [
+ "secret"
+ ],
"properties": {
"secret": {
"type": "string"
@@ -717,7 +749,9 @@
},
"v2.MFAStatusResponse": {
"type": "object",
- "required": ["status"],
+ "required": [
+ "status"
+ ],
"properties": {
"data": {
"type": "object",
@@ -731,7 +765,9 @@
},
"v2.MFAActivationRequest": {
"type": "object",
- "required": ["otp"],
+ "required": [
+ "otp"
+ ],
"properties": {
"otp": {
"type": "string"
@@ -740,7 +776,9 @@
},
"v2.LoginResponse": {
"type": "object",
- "required": ["status"],
+ "required": [
+ "status"
+ ],
"properties": {
"data": {
"type": "object",
@@ -760,7 +798,9 @@
},
"v2.ClientErrorRequest": {
"type": "object",
- "required": ["task_error"],
+ "required": [
+ "task_error"
+ ],
"properties": {
"task_error": {
"type": "string"
@@ -772,7 +812,9 @@
},
"v2.PagedNodeListEntry": {
"type": "object",
- "required": ["object_id"],
+ "required": [
+ "object_id"
+ ],
"properties": {
"object_id": {
"type": "string"
@@ -920,7 +962,10 @@
},
"v2.CreateSavedQueryRequest": {
"type": "object",
- "required": ["name", "query"],
+ "required": [
+ "name",
+ "query"
+ ],
"properties": {
"query": {
"type": "string"
@@ -929,5 +974,16 @@
"type": "string"
}
}
+ },
+ "v2.SearchResponse": {
+ "type": "object",
+ "properties": {
+ "data": {
+ "type": "array",
+ "items": {
+ "$ref": "#/definitions/model.SearchResult"
+ }
+ }
+ }
}
-}
+}
\ No newline at end of file
diff --git a/cmd/api/src/docs/json/paths/v2/search.json b/cmd/api/src/docs/json/paths/v2/search.json
index 3413a707eb..a3b068132d 100644
--- a/cmd/api/src/docs/json/paths/v2/search.json
+++ b/cmd/api/src/docs/json/paths/v2/search.json
@@ -102,7 +102,7 @@
"content": {
"application/json": {
"schema": {
- "$ref": "#/definitions/api.ResponseWrapper"
+ "$ref": "#/definitions/v2.SearchResponse"
}
}
}
@@ -113,4 +113,4 @@
}
}
}
-}
+}
\ No newline at end of file
diff --git a/cmd/api/src/model/model.go b/cmd/api/src/model/model.go
index 26c4ac585a..c6450e08c8 100644
--- a/cmd/api/src/model/model.go
+++ b/cmd/api/src/model/model.go
@@ -1,17 +1,17 @@
// Copyright 2023 Specter Ops, Inc.
-//
+//
// Licensed under the Apache License, Version 2.0
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
-//
+//
// http://www.apache.org/licenses/LICENSE-2.0
-//
+//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
-//
+//
// SPDX-License-Identifier: Apache-2.0
package model
@@ -97,4 +97,5 @@ type SearchResult struct {
Type string `json:"type"`
Name string `json:"name"`
DistinguishedName string `json:"distinguishedname"`
+ SystemTags string `json:"system_tags"`
}
diff --git a/cmd/api/src/queries/graph.go b/cmd/api/src/queries/graph.go
index f3932c2134..d16348b855 100644
--- a/cmd/api/src/queries/graph.go
+++ b/cmd/api/src/queries/graph.go
@@ -448,6 +448,7 @@ func nodeToSearchResult(node *graph.Node) model.SearchResult {
name, _ = node.Properties.GetOrDefault(common.Name.String(), "NO NAME").String()
objectID, _ = node.Properties.GetOrDefault(common.ObjectID.String(), "NO OBJECT ID").String()
distinguishedName, _ = node.Properties.GetOrDefault(ad.DistinguishedName.String(), "").String()
+ systemTags, _ = node.Properties.GetOrDefault(common.SystemTags.String(), "").String()
)
return model.SearchResult{
@@ -455,6 +456,7 @@ func nodeToSearchResult(node *graph.Node) model.SearchResult {
Type: analysis.GetNodeKindDisplayLabel(node),
Name: name,
DistinguishedName: distinguishedName,
+ SystemTags: systemTags,
}
}
diff --git a/cmd/ui/src/components/Header.tsx b/cmd/ui/src/components/Header.tsx
index 38e262f6f3..fffa7bd6f4 100644
--- a/cmd/ui/src/components/Header.tsx
+++ b/cmd/ui/src/components/Header.tsx
@@ -14,7 +14,7 @@
//
// SPDX-License-Identifier: Apache-2.0
-import { faCog, faProjectDiagram } from '@fortawesome/free-solid-svg-icons';
+import { faCog, faUsersRectangle, faProjectDiagram } from '@fortawesome/free-solid-svg-icons';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import { AppBar, Box, IconButton, Link, Toolbar } from '@mui/material';
import makeStyles from '@mui/styles/makeStyles';
@@ -94,6 +94,13 @@ const Header: React.FC = () => {
onClick={() => navigate(routes.ROUTE_EXPLORE)}
data-testid='global_header_nav-explore'
/>
+ }
+ active={location.pathname === routes.ROUTE_GROUP_MANAGEMENT}
+ onClick={() => navigate(routes.ROUTE_GROUP_MANAGEMENT)}
+ data-testid='global_header_nav-group-management'
+ />
diff --git a/cmd/ui/src/constants.ts b/cmd/ui/src/constants.ts
index 11fe1ed03c..5f773bd21a 100644
--- a/cmd/ui/src/constants.ts
+++ b/cmd/ui/src/constants.ts
@@ -23,3 +23,4 @@ export const MODERATE_THRESHOLD = 40;
export const ZERO_VALUE_API_DATE = '0001-01-01T00:00:00Z';
export const TIER_ZERO_TAG = 'admin_tier_0';
+export const TIER_ZERO_LABEL = 'High Value';
diff --git a/cmd/ui/src/ducks/global/routes.ts b/cmd/ui/src/ducks/global/routes.ts
index eb1c38ca56..7ab22eeac1 100644
--- a/cmd/ui/src/ducks/global/routes.ts
+++ b/cmd/ui/src/ducks/global/routes.ts
@@ -16,6 +16,7 @@
export const ROUTE_HOME = '/';
export const ROUTE_EXPLORE = '/explore';
+export const ROUTE_GROUP_MANAGEMENT = '/group-management';
export const ROUTE_LOGIN = '/login';
export const ROUTE_CHANGE_PASSWORD = '/changepassword';
export const ROUTE_USER_DISABLED = '/user-disabled';
diff --git a/cmd/ui/src/mocks/factories.ts b/cmd/ui/src/mocks/factories.ts
index 471d4394fa..2944ab0b27 100644
--- a/cmd/ui/src/mocks/factories.ts
+++ b/cmd/ui/src/mocks/factories.ts
@@ -80,3 +80,11 @@ export const createMockSearchResult = (nodeType?: string) => {
]),
};
};
+
+export const createMockDomain = () => ({
+ type: faker.helpers.arrayElement(['active-directory', 'azure']),
+ impactValue: faker.datatype.number({ min: 0, max: 100 }),
+ name: faker.internet.domainName(),
+ id: faker.datatype.uuid(),
+ collected: faker.datatype.boolean(),
+});
diff --git a/cmd/ui/src/utils.ts b/cmd/ui/src/utils.ts
index b1f594107f..363e5d6e6f 100644
--- a/cmd/ui/src/utils.ts
+++ b/cmd/ui/src/utils.ts
@@ -14,7 +14,7 @@
//
// SPDX-License-Identifier: Apache-2.0
-import { ActiveDirectoryNodeKind, AzureNodeKind, apiClient } from 'bh-shared-ui';
+import { apiClient } from 'bh-shared-ui';
import { FlatGraphResponse, GraphData, GraphResponse, StyledGraphEdge, StyledGraphNode } from 'js-client-library';
import identity from 'lodash/identity';
import throttle from 'lodash/throttle';
@@ -40,21 +40,6 @@ export const getDatesInRange = (startDate: Date, endDate: Date) => {
return dates;
};
-export const validateNodeType = (type: string): ActiveDirectoryNodeKind | AzureNodeKind | undefined => {
- let result = undefined;
- Object.values(ActiveDirectoryNodeKind).forEach((activeDirectoryType: string) => {
- if (activeDirectoryType.localeCompare(type, undefined, { sensitivity: 'base' }) === 0)
- result = activeDirectoryType as ActiveDirectoryNodeKind;
- });
-
- Object.values(AzureNodeKind).forEach((azureType: string) => {
- if (azureType.localeCompare(type, undefined, { sensitivity: 'base' }) === 0)
- result = azureType as AzureNodeKind;
- });
-
- return result;
-};
-
export const getUsername = (user: any): string | undefined => {
if (user?.first_name && user?.last_name) {
return `${user.first_name} ${user.last_name}`;
diff --git a/cmd/ui/src/views/Content.tsx b/cmd/ui/src/views/Content.tsx
index a652967a26..cd8eb90a75 100644
--- a/cmd/ui/src/views/Content.tsx
+++ b/cmd/ui/src/views/Content.tsx
@@ -38,6 +38,7 @@ const UserProfile = React.lazy(() => import('bh-shared-ui').then((module) => ({
const DownloadCollectors = React.lazy(() => import('./DownloadCollectors'));
const Administration = React.lazy(() => import('./Administration'));
const ApiExplorer = React.lazy(() => import('./ApiExplorer'));
+const GroupManagement = React.lazy(() => import('./GroupManagement/GroupManagement'));
const useStyles = makeStyles({
content: {
@@ -112,6 +113,11 @@ const Content: React.FC = () => {
component: ExploreGraphView,
authenticationRequired: true,
},
+ {
+ path: routes.ROUTE_GROUP_MANAGEMENT,
+ component: GroupManagement,
+ authenticationRequired: true,
+ },
{
path: routes.ROUTE_MY_PROFILE,
component: UserProfile,
diff --git a/cmd/ui/src/views/Explore/BasicObjectInfoFields.tsx b/cmd/ui/src/views/Explore/BasicObjectInfoFields.tsx
new file mode 100644
index 0000000000..30d0cf7006
--- /dev/null
+++ b/cmd/ui/src/views/Explore/BasicObjectInfoFields.tsx
@@ -0,0 +1,85 @@
+// Copyright 2023 Specter Ops, Inc.
+//
+// Licensed under the Apache License, Version 2.0
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+//
+// SPDX-License-Identifier: Apache-2.0
+
+import { Box } from '@mui/material';
+import { NodeIcon, Field, AzureNodeKind, EntityKinds } from 'bh-shared-ui';
+import { TIER_ZERO_TAG } from 'src/constants';
+import { sourceNodeSelected } from 'src/ducks/searchbar/actions';
+import { useAppDispatch } from 'src/store';
+
+interface BasicObjectInfoFieldsProps {
+ objectid: string;
+ displayname?: string;
+ system_tags?: string;
+ service_principal_id?: string;
+ noderesourcegroupid?: string;
+ name?: string;
+}
+
+const RelatedKindField = (fieldLabel: string, relatedKind: EntityKinds, id: string, name?: string) => {
+ const dispatch = useAppDispatch();
+ return (
+
+
+ {fieldLabel}
+
+
+
+
+ {
+ dispatch(
+ sourceNodeSelected({
+ objectid: id,
+ type: relatedKind,
+ name: name || '',
+ })
+ );
+ }}
+ style={{ cursor: 'pointer' }}
+ overflow='hidden'
+ textOverflow='ellipsis'
+ title={id}>
+ {id}
+
+
+
+ );
+};
+
+export const BasicObjectInfoFields: React.FC = (props): JSX.Element => {
+ return (
+ <>
+ {props.system_tags?.includes(TIER_ZERO_TAG) && }
+ {props.displayname && }
+
+ {props.service_principal_id &&
+ RelatedKindField(
+ 'Service Principal ID:',
+ AzureNodeKind.ServicePrincipal,
+ props.service_principal_id,
+ props.name
+ )}
+ {props.noderesourcegroupid &&
+ RelatedKindField(
+ 'Node Resource Group ID:',
+ AzureNodeKind.ResourceGroup,
+ props.noderesourcegroupid,
+ props.name
+ )}
+ >
+ );
+};
diff --git a/cmd/ui/src/views/Explore/EdgeInfo/EdgeInfoCollapsibleSection.tsx b/cmd/ui/src/views/Explore/EdgeInfo/EdgeInfoCollapsibleSection.tsx
index a9f0008bb6..7cc03e3db3 100644
--- a/cmd/ui/src/views/Explore/EdgeInfo/EdgeInfoCollapsibleSection.tsx
+++ b/cmd/ui/src/views/Explore/EdgeInfo/EdgeInfoCollapsibleSection.tsx
@@ -17,12 +17,10 @@
import { faMinus, faPlus } from '@fortawesome/free-solid-svg-icons';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import { Accordion, AccordionDetails, AccordionSummary } from '@mui/material';
-import { EdgeInfoState, EdgeSections, edgeSectionToggle } from 'bh-shared-ui';
+import { EdgeInfoState, EdgeSections, edgeSectionToggle, SubHeader, useCollapsibleSectionStyles } from 'bh-shared-ui';
import React, { PropsWithChildren } from 'react';
import { useSelector } from 'react-redux';
import { AppState, useAppDispatch } from 'src/store';
-import useCollapsibleSectionStyles from 'src/views/Explore/InfoStyles/CollapsibleSection';
-import { SubHeader } from 'src/views/Explore/fragments';
export const EdgeInfoCollapsibleSection: React.FC<
PropsWithChildren<{
diff --git a/cmd/ui/src/views/Explore/EdgeInfo/EdgeInfoHeader.tsx b/cmd/ui/src/views/Explore/EdgeInfo/EdgeInfoHeader.tsx
index a9a064bb99..90b4b5032a 100644
--- a/cmd/ui/src/views/Explore/EdgeInfo/EdgeInfoHeader.tsx
+++ b/cmd/ui/src/views/Explore/EdgeInfo/EdgeInfoHeader.tsx
@@ -20,7 +20,7 @@ import { Typography } from '@mui/material';
import { Icon, collapseAllSections } from 'bh-shared-ui';
import React from 'react';
import { useAppDispatch } from 'src/store';
-import useHeaderStyles from 'src/views/Explore/InfoStyles/Header';
+import { useHeaderStyles } from 'bh-shared-ui';
interface HeaderProps {
name: string;
diff --git a/cmd/ui/src/views/Explore/EdgeInfo/EdgeInfoPane.tsx b/cmd/ui/src/views/Explore/EdgeInfo/EdgeInfoPane.tsx
index eea69c18f9..c2d643f066 100644
--- a/cmd/ui/src/views/Explore/EdgeInfo/EdgeInfoPane.tsx
+++ b/cmd/ui/src/views/Explore/EdgeInfo/EdgeInfoPane.tsx
@@ -14,19 +14,19 @@
//
// SPDX-License-Identifier: Apache-2.0
-import { Paper } from '@mui/material';
+import { Box, Paper, SxProps } from '@mui/material';
import { SelectedEdge } from 'bh-shared-ui';
import React, { useState } from 'react';
import EdgeInfoContent from 'src/views/Explore/EdgeInfo/EdgeInfoContent';
import Header from 'src/views/Explore/EdgeInfo/EdgeInfoHeader';
-import usePaneStyles from 'src/views/Explore/InfoStyles/Pane';
+import { usePaneStyles } from 'bh-shared-ui';
-const EdgeInfoPane: React.FC<{ selectedEdge: SelectedEdge }> = ({ selectedEdge }) => {
+const EdgeInfoPane: React.FC<{ selectedEdge: SelectedEdge; sx?: SxProps }> = ({ selectedEdge, sx }) => {
const styles = usePaneStyles();
const [expanded, setExpanded] = useState(true);
return (
-
+
= ({ selectedEdge }
}}>
{selectedEdge === null ? 'No information to display.' : }
-
+
);
};
diff --git a/cmd/ui/src/views/Explore/EdgeInfo/EdgeObjectInformation.tsx b/cmd/ui/src/views/Explore/EdgeInfo/EdgeObjectInformation.tsx
index 41da185ff1..425375e999 100644
--- a/cmd/ui/src/views/Explore/EdgeInfo/EdgeObjectInformation.tsx
+++ b/cmd/ui/src/views/Explore/EdgeInfo/EdgeObjectInformation.tsx
@@ -15,11 +15,10 @@
// SPDX-License-Identifier: Apache-2.0
import { Skeleton } from '@mui/material';
-import { EntityField, SelectedEdge, apiClient } from 'bh-shared-ui';
+import { SelectedEdge, apiClient, EntityField, FieldsContainer, ObjectInfoFields } from 'bh-shared-ui';
import { FC } from 'react';
import { useQuery } from 'react-query';
import EdgeInfoCollapsibleSection from 'src/views/Explore/EdgeInfo/EdgeInfoCollapsibleSection';
-import { FieldsContainer, ObjectInfoFields } from 'src/views/Explore/fragments';
import { formatObjectInfoFields } from 'src/views/Explore/utils';
const selectedEdgeCypherQuery = (sourceId: string | number, targetId: string | number, edgeKind: string): string =>
diff --git a/cmd/ui/src/views/Explore/EntityInfo/EntityInfoCollapsibleSection.tsx b/cmd/ui/src/views/Explore/EntityInfo/EntityInfoCollapsibleSection.tsx
index 2e577f76d4..f8dc2620b6 100644
--- a/cmd/ui/src/views/Explore/EntityInfo/EntityInfoCollapsibleSection.tsx
+++ b/cmd/ui/src/views/Explore/EntityInfo/EntityInfoCollapsibleSection.tsx
@@ -19,8 +19,7 @@ import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import { Accordion, AccordionDetails, AccordionSummary, Alert, AlertTitle } from '@mui/material';
import React, { PropsWithChildren } from 'react';
import { useEntityInfoPanelContext } from './EntityInfoPanelContext';
-import { SubHeader } from 'src/views/Explore/fragments';
-import useCollapsibleSectionStyles from 'src/views/Explore/InfoStyles/CollapsibleSection';
+import { SubHeader, useCollapsibleSectionStyles } from 'bh-shared-ui';
const EntityInfoCollapsibleSectionError: React.FC<{ error: any }> = ({ error }) => {
//TODO: Once azure backend changes for counts param are in, utilize response error details
diff --git a/cmd/ui/src/views/Explore/EntityInfo/EntityInfoHeader.tsx b/cmd/ui/src/views/Explore/EntityInfo/EntityInfoHeader.tsx
index 23b59cc2f8..13d9bfb947 100644
--- a/cmd/ui/src/views/Explore/EntityInfo/EntityInfoHeader.tsx
+++ b/cmd/ui/src/views/Explore/EntityInfo/EntityInfoHeader.tsx
@@ -20,7 +20,7 @@ import { Typography } from '@mui/material';
import { Icon, NodeIcon, EntityKinds } from 'bh-shared-ui';
import React from 'react';
import { useEntityInfoPanelContext } from 'src/views/Explore/EntityInfo/EntityInfoPanelContext';
-import useHeaderStyles from 'src/views/Explore/InfoStyles/Header';
+import { useHeaderStyles } from 'bh-shared-ui';
interface HeaderProps {
name?: string;
diff --git a/cmd/ui/src/views/Explore/EntityInfo/EntityInfoPanel.tsx b/cmd/ui/src/views/Explore/EntityInfo/EntityInfoPanel.tsx
index f37342d70e..4148e2ac5e 100644
--- a/cmd/ui/src/views/Explore/EntityInfo/EntityInfoPanel.tsx
+++ b/cmd/ui/src/views/Explore/EntityInfo/EntityInfoPanel.tsx
@@ -14,21 +14,24 @@
//
// SPDX-License-Identifier: Apache-2.0
-import { Paper } from '@mui/material';
+import { Box, Paper, SxProps } from '@mui/material';
import React, { useEffect, useState } from 'react';
-import { useSelector } from 'react-redux';
import usePreviousValue from 'src/hooks/usePreviousValue';
-import { AppState } from 'src/store';
import EntityInfoContent from './EntityInfoContent';
import Header from './EntityInfoHeader';
import { EntityInfoPanelContextProvider } from './EntityInfoPanelContextProvider';
import { useEntityInfoPanelContext } from './EntityInfoPanelContext';
-import usePaneStyles from 'src/views/Explore/InfoStyles/Pane';
+import { usePaneStyles } from 'bh-shared-ui';
+import { SelectedNode } from 'src/ducks/entityinfo/types';
-const EntityInfoPanel: React.FC = () => {
+interface EntityInfoPanelProps {
+ selectedNode: SelectedNode | null;
+ sx?: SxProps;
+}
+
+const EntityInfoPanel: React.FC = ({ selectedNode, sx }) => {
const styles = usePaneStyles();
const [expanded, setExpanded] = useState(true);
- const selectedNode = useSelector((state: AppState) => state.entityinfo.selectedNode);
const { setExpandedSections } = useEntityInfoPanelContext();
const previousSelectedNode = usePreviousValue(selectedNode);
@@ -40,7 +43,7 @@ const EntityInfoPanel: React.FC = () => {
if (selectedNode === null) {
return (
-
+
{
}}>
No information to display.
-
+
);
}
return (
-
+
{
style={{
display: expanded ? 'initial' : 'none',
}}>
-
+
-
+
);
};
-const WrappedEntityInfoPanel = () => (
+const WrappedEntityInfoPanel: React.FC = (props) => (
-
+
);
diff --git a/cmd/ui/src/views/Explore/EntityInfo/EntityObjectInformation.tsx b/cmd/ui/src/views/Explore/EntityInfo/EntityObjectInformation.tsx
index 426012a048..ffacaa3f10 100644
--- a/cmd/ui/src/views/Explore/EntityInfo/EntityObjectInformation.tsx
+++ b/cmd/ui/src/views/Explore/EntityInfo/EntityObjectInformation.tsx
@@ -14,11 +14,11 @@
//
// SPDX-License-Identifier: Apache-2.0
-import { EntityField } from 'bh-shared-ui';
import React from 'react';
-import EntityInfoCollapsibleSection from 'src/views/Explore/EntityInfo/EntityInfoCollapsibleSection';
-import { BasicObjectInfoFields, FieldsContainer, ObjectInfoFields } from 'src/views/Explore/fragments';
+import EntityInfoCollapsibleSection from './EntityInfoCollapsibleSection';
+import { EntityField, FieldsContainer, ObjectInfoFields } from 'bh-shared-ui';
import { formatObjectInfoFields } from 'src/views/Explore/utils';
+import { BasicObjectInfoFields } from '../BasicObjectInfoFields';
const EntityObjectInformation: React.FC<{ props: any }> = ({ props }) => {
const formattedObjectFields: EntityField[] = formatObjectInfoFields(props);
diff --git a/cmd/ui/src/views/Explore/ExploreSearchCombobox/ExploreSearchCombobox.tsx b/cmd/ui/src/views/Explore/ExploreSearchCombobox/ExploreSearchCombobox.tsx
index c15243f6c7..6bb05abb6b 100644
--- a/cmd/ui/src/views/Explore/ExploreSearchCombobox/ExploreSearchCombobox.tsx
+++ b/cmd/ui/src/views/Explore/ExploreSearchCombobox/ExploreSearchCombobox.tsx
@@ -16,8 +16,14 @@
import { List, ListItem, ListItemText, Paper, TextField, useTheme } from '@mui/material';
import { useCombobox } from 'downshift';
-import { NodeIcon, SearchResultItem } from 'bh-shared-ui';
-import { getEmptyResultsText, getKeywordAndTypeValues, SearchResult, useSearch } from 'src/hooks/useSearch';
+import {
+ NodeIcon,
+ SearchResultItem,
+ getEmptyResultsText,
+ getKeywordAndTypeValues,
+ SearchResult,
+ useSearch,
+} from 'bh-shared-ui';
import { SearchNodeType } from 'src/ducks/searchbar/types';
const ExploreSearchCombobox: React.FC<{
diff --git a/cmd/ui/src/views/Explore/GraphView.tsx b/cmd/ui/src/views/Explore/GraphView.tsx
index d881478bee..e32194d42c 100644
--- a/cmd/ui/src/views/Explore/GraphView.tsx
+++ b/cmd/ui/src/views/Explore/GraphView.tsx
@@ -225,22 +225,36 @@ const GraphView: FC = () => {
};
const GridItems = () => {
+ const selectedNode = useSelector((state: AppState) => state.entityinfo.selectedNode);
+
const columnsDefault = { xs: 6, md: 5, lg: 4, xl: 3 };
const cypherSearchColumns = { xs: 6, md: 6, lg: 6, xl: 4 };
const edgeInfoState: EdgeInfoState = useSelector((state: AppState) => state.edgeinfo);
const [columns, setColumns] = useState(columnsDefault);
+ const theme = useTheme();
+
+ const columnStyles = { height: '100%' };
+
+ const infoPanelStyles = {
+ margin: theme.spacing(0, 4, 2, 2),
+ maxHeight: '95%',
+ };
const handleCypherTab = (isCypherEditorActive: boolean) => {
isCypherEditorActive ? setColumns(cypherSearchColumns) : setColumns(columnsDefault);
};
return [
-
+
,
-
- {edgeInfoState.open ? : }
+
+ {edgeInfoState.open ? (
+
+ ) : (
+
+ )}
,
];
};
diff --git a/cmd/ui/src/views/Explore/utils.ts b/cmd/ui/src/views/Explore/utils.ts
index 1c20e0f09e..ca7c85e081 100644
--- a/cmd/ui/src/views/Explore/utils.ts
+++ b/cmd/ui/src/views/Explore/utils.ts
@@ -36,7 +36,7 @@ import { NODE_ICON, GLYPHS, UNKNOWN_ICON } from './svgIcons';
export const formatObjectInfoFields = (props: any): EntityField[] => {
let mappedFields: EntityField[] = [];
- const propKeys = Object.keys(props);
+ const propKeys = Object.keys(props || {});
for (let i = 0; i < propKeys.length; i++) {
const value = props[propKeys[i]];
diff --git a/cmd/ui/src/views/GroupManagement/GroupManagement.test.tsx b/cmd/ui/src/views/GroupManagement/GroupManagement.test.tsx
new file mode 100644
index 0000000000..97d9fd4735
--- /dev/null
+++ b/cmd/ui/src/views/GroupManagement/GroupManagement.test.tsx
@@ -0,0 +1,115 @@
+// Copyright 2023 Specter Ops, Inc.
+//
+// Licensed under the Apache License, Version 2.0
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+//
+// SPDX-License-Identifier: Apache-2.0
+
+import { setupServer } from 'msw/node';
+import { act, render, waitFor } from '../../test-utils';
+import GroupManagement from './GroupManagement';
+import { rest } from 'msw';
+import { createMockDomain } from 'src/mocks/factories';
+import { createMockAssetGroup, createMockAssetGroupMembers } from 'bh-shared-ui';
+import userEvent from '@testing-library/user-event';
+
+const domain = createMockDomain();
+const assetGroup = createMockAssetGroup();
+const assetGroupMembers = createMockAssetGroupMembers();
+
+const server = setupServer(
+ rest.get('/api/v2/available-domains', (req, res, ctx) => {
+ return res(ctx.json({ data: [domain] }));
+ }),
+ rest.get('/api/v2/asset-groups', (req, res, ctx) => {
+ return res(ctx.json({ data: { asset_groups: [assetGroup] } }));
+ }),
+ rest.get('/api/v2/asset-groups/1/members', (req, res, ctx) => {
+ return res(
+ ctx.json({
+ count: assetGroupMembers.members.length,
+ limit: 100,
+ skip: 0,
+ data: assetGroupMembers,
+ })
+ );
+ }),
+ rest.get('*', (req, res, ctx) => res(ctx.json({ data: [] })))
+);
+
+beforeAll(() => server.listen());
+afterEach(() => server.resetHandlers());
+afterAll(() => server.close());
+
+describe('GroupManagement', () => {
+ const setup = async () =>
+ await act(async () => {
+ const user = userEvent.setup();
+ const screen = render();
+ return { user, screen };
+ });
+
+ it('renders group and tenant dropdown selectors', async () => {
+ const { screen } = await setup();
+ const groupSelector = screen.getByTestId('dropdown_context-selector');
+ const tenantSelector = await waitFor(() => screen.getByTestId('data-quality_context-selector'));
+
+ expect(screen.getByText('Group:')).toBeInTheDocument();
+ expect(screen.getByText('Environment:')).toBeInTheDocument();
+ expect(groupSelector).toBeInTheDocument();
+ expect(tenantSelector).toBeInTheDocument();
+ });
+
+ it('renders an edit form for the selected asset group', async () => {
+ const { screen } = await setup();
+ const input = screen.getByRole('combobox');
+ expect(input).toBeInTheDocument();
+ });
+
+ it('renders a list of asset group members', async () => {
+ const { screen } = await setup();
+ const member = assetGroupMembers.members[0];
+
+ expect(screen.getByRole('table')).toBeInTheDocument();
+ expect(screen.getByText(member.name)).toBeInTheDocument();
+ });
+
+ it('renders an empty message for the entity panel before a node is selected', async () => {
+ const { screen } = await setup();
+
+ expect(screen.getByText('None Selected')).toBeInTheDocument();
+ expect(screen.getByText('No information to display.')).toBeInTheDocument();
+ });
+
+ it('renders the node in the entity panel when member is clicked', async () => {
+ const { screen, user } = await setup();
+ const member = assetGroupMembers.members[0];
+ const listItem = screen.getByText(member.name);
+ const entityPanel = screen.getByTestId('explore_entity-information-panel');
+
+ await user.click(listItem);
+ const header = await waitFor(() => screen.getByText('Object Information'));
+
+ expect(header).toBeInTheDocument();
+ expect(entityPanel).toHaveTextContent(member.name);
+ });
+
+ it('renders a link to the explore page when member is clicked', async () => {
+ const { screen, user } = await setup();
+ const member = assetGroupMembers.members[0];
+ const listItem = screen.getByText(member.name);
+
+ await user.click(listItem);
+ const link = screen.getByTestId('group-management_explore-link');
+ expect(link).toBeInTheDocument();
+ });
+});
diff --git a/cmd/ui/src/views/GroupManagement/GroupManagement.tsx b/cmd/ui/src/views/GroupManagement/GroupManagement.tsx
new file mode 100644
index 0000000000..28a89302db
--- /dev/null
+++ b/cmd/ui/src/views/GroupManagement/GroupManagement.tsx
@@ -0,0 +1,92 @@
+// Copyright 2023 Specter Ops, Inc.
+//
+// Licensed under the Apache License, Version 2.0
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+//
+// SPDX-License-Identifier: Apache-2.0
+
+import EntityInfoPanel from '../Explore/EntityInfo/EntityInfoPanel';
+import { DropdownOption, GroupManagementContent, EntityKinds } from 'bh-shared-ui';
+import { SelectedNode } from 'src/ducks/entityinfo/types';
+import { useState } from 'react';
+import { AssetGroup, AssetGroupMember } from 'js-client-library';
+import { faGem } from '@fortawesome/free-solid-svg-icons';
+import { useSelector } from 'react-redux';
+import { Domain } from 'src/ducks/global/types';
+import { setSelectedNode } from 'src/ducks/entityinfo/actions';
+import { useNavigate } from 'react-router-dom';
+import { ROUTE_EXPLORE } from 'src/ducks/global/routes';
+import { sourceNodeSelected } from 'src/ducks/searchbar/actions';
+import { TIER_ZERO_LABEL, TIER_ZERO_TAG } from 'src/constants';
+import { useAppDispatch } from 'src/store';
+import { dataCollectionMessage } from '../QA/utils';
+
+const GroupManagement = () => {
+ const dispatch = useAppDispatch();
+ const navigate = useNavigate();
+
+ const globalDomain: Domain = useSelector((state: any) => state.global.options.domain);
+
+ // Kept out of the shared UI due to diff between GraphNodeTypes across apps
+ const [openNode, setOpenNode] = useState(null);
+
+ const handleClickMember = (member: AssetGroupMember) => {
+ setOpenNode({
+ id: member.object_id,
+ type: member.primary_kind as EntityKinds,
+ name: member.name,
+ });
+ };
+
+ const handleShowNodeInExplore = () => {
+ if (openNode) {
+ const searchNode = {
+ objectid: openNode.id,
+ label: openNode.name,
+ ...openNode,
+ };
+ dispatch(sourceNodeSelected(searchNode));
+ dispatch(setSelectedNode(openNode));
+
+ navigate(ROUTE_EXPLORE);
+ }
+ };
+
+ // Handle tier zero case
+ const mapAssetGroups = (assetGroups: AssetGroup[]): DropdownOption[] => {
+ return assetGroups.map((assetGroup) => {
+ const isTierZero = assetGroup.tag === TIER_ZERO_TAG;
+ return {
+ key: assetGroup.id,
+ value: isTierZero ? TIER_ZERO_LABEL : assetGroup.name,
+ icon: isTierZero ? faGem : undefined,
+ };
+ });
+ };
+
+ return (
+ }
+ domainSelectorErrorMessage={<>Domains unavailable. {dataCollectionMessage}>}
+ onShowNodeInExplore={handleShowNodeInExplore}
+ onClickMember={handleClickMember}
+ mapAssetGroups={mapAssetGroups}
+ />
+ );
+};
+
+export default GroupManagement;
diff --git a/cmd/ui/src/views/QA/QA.tsx b/cmd/ui/src/views/QA/QA.tsx
index 3dc8e7f44c..a3f39e79e0 100644
--- a/cmd/ui/src/views/QA/QA.tsx
+++ b/cmd/ui/src/views/QA/QA.tsx
@@ -26,6 +26,7 @@ import {
import { useEffect, useState } from 'react';
import { useSelector } from 'react-redux';
import { AppState } from 'src/store';
+import { dataCollectionMessage } from './utils';
const QualityAssurance: React.FC = () => {
const domain = useSelector((state: AppState) => state.global.options.domain);
@@ -56,6 +57,8 @@ const QualityAssurance: React.FC = () => {
}
};
+ const domainErrorMessage = <>Domains unavailable. {dataCollectionMessage}>;
+
if (!contextType || (!contextId && (contextType === 'active-directory' || contextType === 'azure'))) {
return (
{
type: contextType,
id: contextId,
}}
+ errorMessage={domainErrorMessage}
onChange={({ type, id }) => {
setContextType(type);
setContextId(id);
@@ -76,15 +80,7 @@ const QualityAssurance: React.FC = () => {
No Domain or Tenant Selected
Select a domain or tenant to view data. If you are unable to select a domain, you may need to run
- data collection first. See the{' '}
-
- Data Collection
- {' '}
- page to view instructions on how to begin data collection.
+ data collection first. {dataCollectionMessage}
);
@@ -100,6 +96,7 @@ const QualityAssurance: React.FC = () => {
type: contextType,
id: contextId,
}}
+ errorMessage={domainErrorMessage}
onChange={({ type, id }) => {
setContextType(type);
setContextId(id);
diff --git a/cmd/ui/src/views/QA/utils.tsx b/cmd/ui/src/views/QA/utils.tsx
new file mode 100644
index 0000000000..f647269af8
--- /dev/null
+++ b/cmd/ui/src/views/QA/utils.tsx
@@ -0,0 +1,29 @@
+// Copyright 2023 Specter Ops, Inc.
+//
+// Licensed under the Apache License, Version 2.0
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+//
+// SPDX-License-Identifier: Apache-2.0
+
+import { Link } from '@mui/material';
+
+export const dataCollectionMessage = (
+ <>
+ See the{' '}
+
+ Data Collection
+ {' '}
+ page to view instructions on how to begin data collection.
+ >
+);
diff --git a/packages/javascript/bh-shared-ui/src/components/AssetGroupEdit/AssetGroupAutocomplete.tsx b/packages/javascript/bh-shared-ui/src/components/AssetGroupEdit/AssetGroupAutocomplete.tsx
new file mode 100644
index 0000000000..034a5eb36b
--- /dev/null
+++ b/packages/javascript/bh-shared-ui/src/components/AssetGroupEdit/AssetGroupAutocomplete.tsx
@@ -0,0 +1,128 @@
+// Copyright 2023 Specter Ops, Inc.
+//
+// Licensed under the Apache License, Version 2.0
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+//
+// SPDX-License-Identifier: Apache-2.0
+
+import { Autocomplete, AutocompleteRenderInputParams, TextField } from '@mui/material';
+import { FC, HTMLAttributes, ReactNode, SyntheticEvent, useState } from 'react';
+import AutocompleteOption from './AutocompleteOption';
+import { AssetGroupChangelog, AssetGroupChangelogEntry, ChangelogAction } from './types';
+import { AssetGroup } from 'js-client-library';
+import { getEmptyResultsText, getKeywordAndTypeValues, useDebouncedValue, useSearch } from '../../hooks';
+
+export const AUTOCOMPLETE_PLACEHOLDER = 'Add or Remove Members';
+
+const AssetGroupAutocomplete: FC<{
+ assetGroup: AssetGroup;
+ changelog: AssetGroupChangelog;
+ onChange: (event: any, value: AssetGroupChangelogEntry) => void;
+}> = ({ assetGroup, changelog, onChange }) => {
+ const [searchInput, setSearchInput] = useState('');
+ const debouncedInputValue = useDebouncedValue(searchInput, 250);
+ const { keyword, type } = getKeywordAndTypeValues(debouncedInputValue);
+ const { data, isLoading, isFetching, isError, error } = useSearch(keyword, type);
+
+ const noOptionsText = getEmptyResultsText(
+ isLoading,
+ isFetching,
+ isError,
+ error,
+ debouncedInputValue,
+ type,
+ keyword,
+ data
+ );
+
+ const searchResultsWithActions = data?.map((result) => {
+ const resultInChangelog = changelog.find((member) => member.objectid === result.objectid);
+ const matchedSelector = assetGroup.Selectors.find((selector) => selector.selector === result.objectid);
+
+ let action = ChangelogAction.ADD;
+
+ if (result.system_tags?.includes(assetGroup.tag)) {
+ action = ChangelogAction.DEFAULT;
+ }
+ if (matchedSelector) {
+ action = ChangelogAction.REMOVE;
+ }
+ if (resultInChangelog) {
+ action = ChangelogAction.UNDO;
+ }
+
+ return { ...result, action };
+ });
+
+ const handleRenderInput = (params: AutocompleteRenderInputParams): ReactNode => {
+ return (
+
+ );
+ };
+
+ const handleRenderOption = (props: HTMLAttributes, option: AssetGroupChangelogEntry): ReactNode => {
+ const actionLabels = {
+ [ChangelogAction.ADD]: 'Add',
+ [ChangelogAction.REMOVE]: 'Remove',
+ [ChangelogAction.DEFAULT]: 'Default Group Member',
+ [ChangelogAction.UNDO]: 'Undo',
+ };
+ return (
+
+ );
+ };
+
+ const handleInputChange = (_event: SyntheticEvent, value: string, reason: string): void => {
+ if (reason === 'reset') return;
+ setSearchInput(value);
+ };
+
+ const filterOptions = (options: AssetGroupChangelogEntry[]) => options;
+ const getOptionLabel = (option: AssetGroupChangelogEntry) => option.name || option.objectid;
+ const getOptionDisabled = (option: AssetGroupChangelogEntry) => option.action === ChangelogAction.DEFAULT;
+
+ return (
+
+ renderInput={handleRenderInput}
+ renderOption={handleRenderOption}
+ onInputChange={handleInputChange}
+ onChange={onChange}
+ inputValue={searchInput}
+ filterOptions={filterOptions}
+ value={null}
+ options={searchResultsWithActions || []}
+ loading={isLoading || isFetching}
+ getOptionLabel={getOptionLabel}
+ getOptionDisabled={getOptionDisabled}
+ isOptionEqualToValue={() => false}
+ clearOnBlur
+ clearOnEscape
+ disableCloseOnSelect
+ noOptionsText={noOptionsText}
+ forcePopupIcon={false}
+ />
+ );
+};
+
+export default AssetGroupAutocomplete;
diff --git a/packages/javascript/bh-shared-ui/src/components/AssetGroupEdit/AssetGroupChangelogTable.tsx b/packages/javascript/bh-shared-ui/src/components/AssetGroupEdit/AssetGroupChangelogTable.tsx
new file mode 100644
index 0000000000..b3c1923cb1
--- /dev/null
+++ b/packages/javascript/bh-shared-ui/src/components/AssetGroupEdit/AssetGroupChangelogTable.tsx
@@ -0,0 +1,108 @@
+// Copyright 2023 Specter Ops, Inc.
+//
+// Licensed under the Apache License, Version 2.0
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+//
+// SPDX-License-Identifier: Apache-2.0
+
+import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
+import {
+ Box,
+ Button,
+ Grid,
+ IconButton,
+ Table,
+ TableBody,
+ TableCell,
+ TableContainer,
+ TableHead,
+ TableRow,
+} from '@mui/material';
+import NodeIcon from '../NodeIcon';
+import { faTimes } from '@fortawesome/free-solid-svg-icons';
+import { AssetGroupChangelogEntry } from './types';
+import { FC } from 'react';
+
+const AssetGroupChangelogTable: FC<{
+ addRows: AssetGroupChangelogEntry[];
+ removeRows: AssetGroupChangelogEntry[];
+ onRemove: (entry: AssetGroupChangelogEntry) => void;
+ onCancel: () => void;
+ onSubmit: () => void;
+}> = ({ addRows, removeRows, onRemove, onCancel, onSubmit }) => {
+ return (
+ <>
+
+
+ {addRows.length > 0 && (
+
+ )}
+ {removeRows.length > 0 && (
+
+ )}
+
+
+
+
+
+
+
+
+
+
+
+
+ >
+ );
+};
+
+const AssetGroupChangelogRows: FC<{
+ title: string;
+ rows: AssetGroupChangelogEntry[];
+ onRemove: (entry: AssetGroupChangelogEntry) => void;
+}> = ({ title, rows, onRemove }) => {
+ return (
+ <>
+
+
+ {title}
+
+
+
+ {rows.map((row) => (
+
+
+ onRemove(row)}>
+
+
+
+
+
+ {row.name}
+
+ {row.objectid}
+
+
+ ))}
+
+ >
+ );
+};
+
+export default AssetGroupChangelogTable;
diff --git a/packages/javascript/bh-shared-ui/src/components/AssetGroupEdit/AssetGroupEdit.test.tsx b/packages/javascript/bh-shared-ui/src/components/AssetGroupEdit/AssetGroupEdit.test.tsx
new file mode 100644
index 0000000000..a058f20857
--- /dev/null
+++ b/packages/javascript/bh-shared-ui/src/components/AssetGroupEdit/AssetGroupEdit.test.tsx
@@ -0,0 +1,121 @@
+// Copyright 2023 Specter Ops, Inc.
+//
+// Licensed under the Apache License, Version 2.0
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+//
+// SPDX-License-Identifier: Apache-2.0
+
+import { setupServer } from 'msw/node';
+import { createMockAssetGroup, createMockAssetGroupMembers, createMockSearchResults } from '../../mocks/factories';
+import { act, render, waitFor } from '../../test-utils';
+import { AUTOCOMPLETE_PLACEHOLDER } from './AssetGroupAutocomplete';
+import AssetGroupEdit from './AssetGroupEdit';
+import { rest } from 'msw';
+import userEvent from '@testing-library/user-event';
+
+const assetGroup = createMockAssetGroup();
+const assetGroupMembers = createMockAssetGroupMembers();
+const searchResults = createMockSearchResults();
+
+const server = setupServer(
+ rest.get('/api/v2/asset-groups/1/members', (req, res, ctx) => {
+ return res(
+ ctx.json({
+ count: assetGroupMembers.members.length,
+ limit: 100,
+ skip: 0,
+ data: assetGroupMembers,
+ })
+ );
+ }),
+ rest.get('/api/v2/search', (req, res, ctx) => {
+ return res(
+ ctx.json({
+ data: searchResults,
+ })
+ );
+ })
+);
+
+beforeAll(() => server.listen());
+afterEach(() => server.resetHandlers());
+afterAll(() => server.close());
+
+describe('AssetGroupEdit', () => {
+ const setup = async () => {
+ const user = userEvent.setup();
+ const screen = await act(async () => {
+ return render();
+ });
+ return { user, screen };
+ };
+
+ it('should display a searchbox with a placeholder when rendered', async () => {
+ const { screen } = await setup();
+ const input = screen.getByPlaceholderText(AUTOCOMPLETE_PLACEHOLDER);
+ expect(input).toBeInTheDocument();
+ });
+
+ it('should display a total count of asset group members', async () => {
+ const { screen } = await setup();
+ const count = screen.getByText('Total Count').nextSibling.textContent;
+ expect(count).toBe(assetGroupMembers.members.length.toString());
+ });
+
+ it('should display search results when the user enters text', async () => {
+ const { screen, user } = await setup();
+ const input = screen.getByRole('combobox');
+
+ await user.type(input, 'test');
+ expect(input.value).toEqual('test');
+
+ const result = await waitFor(() => screen.getByText('00001.TESTLAB.LOCAL'));
+ expect(result).toBeInTheDocument();
+ });
+
+ it('should add an option and display the changelog when it is clicked', async () => {
+ const { screen, user } = await setup();
+ const selection = searchResults[0];
+
+ const input = screen.getByRole('combobox');
+ await user.type(input, 'test');
+ expect(input.value).toEqual('test');
+
+ const result = await waitFor(() => screen.getByText(selection.name));
+ await user.click(result);
+ await user.keyboard('{Escape}');
+
+ expect(screen.getByText(selection.name)).toBeInTheDocument();
+ expect(screen.getByText(selection.objectid)).toBeInTheDocument();
+ expect(screen.getByText('Cancel')).toBeInTheDocument();
+ expect(screen.getByText('Confirm Changes')).toBeInTheDocument();
+ });
+
+ it('should remove the option from the changelog when the corresponding button is clicked', async () => {
+ const { screen, user } = await setup();
+ const selection = searchResults[0];
+
+ const input = screen.getByRole('combobox');
+ await user.type(input, 'test');
+ expect(input.value).toEqual('test');
+
+ const result = await waitFor(() => screen.getByText(selection.name));
+ await user.click(result);
+ await user.keyboard('{Escape}');
+
+ const removeButton = screen.getByText('xmark');
+ await user.click(removeButton);
+
+ expect(screen.queryByText(selection.name)).toBeNull();
+ expect(screen.queryByText(selection.objectid)).toBeNull();
+ });
+});
diff --git a/packages/javascript/bh-shared-ui/src/components/AssetGroupEdit/AssetGroupEdit.tsx b/packages/javascript/bh-shared-ui/src/components/AssetGroupEdit/AssetGroupEdit.tsx
new file mode 100644
index 0000000000..9c814539e9
--- /dev/null
+++ b/packages/javascript/bh-shared-ui/src/components/AssetGroupEdit/AssetGroupEdit.tsx
@@ -0,0 +1,160 @@
+// Copyright 2023 Specter Ops, Inc.
+//
+// Licensed under the Apache License, Version 2.0
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+//
+// SPDX-License-Identifier: Apache-2.0
+
+import { Box, Paper } from '@mui/material';
+import { AssetGroup, AssetGroupMemberParams, UpdateAssetGroupSelectorRequest } from 'js-client-library';
+import { FC, useEffect, useState } from 'react';
+import { AssetGroupChangelog, AssetGroupChangelogEntry, ChangelogAction } from './types';
+import AssetGroupAutocomplete from './AssetGroupAutocomplete';
+import { SubHeader } from '../../views/Explore';
+import { useMutation, useQuery, useQueryClient } from 'react-query';
+import { apiClient } from '../../utils';
+import AssetGroupChangelogTable from './AssetGroupChangelogTable';
+import {
+ ActiveDirectoryNodeKind,
+ ActiveDirectoryNodeKindToDisplay,
+ AzureNodeKind,
+ AzureNodeKindToDisplay,
+} from '../../graphSchema';
+import { useNotifications } from '../../providers';
+
+const AssetGroupEdit: FC<{
+ assetGroup: AssetGroup;
+ filter: AssetGroupMemberParams;
+}> = ({ assetGroup, filter }) => {
+ const [changelog, setChangelog] = useState([]);
+ const addRows = changelog.filter((entry) => entry.action === ChangelogAction.ADD);
+ const removeRows = changelog.filter((entry) => entry.action === ChangelogAction.REMOVE);
+ const { addNotification } = useNotifications();
+ const queryClient = useQueryClient();
+
+ const handleUpdateAssetGroupChangelog = (_event: any, changelogEntry: AssetGroupChangelogEntry) => {
+ if (changelogEntry.action === ChangelogAction.ADD || changelogEntry.action === ChangelogAction.REMOVE) {
+ setChangelog([...changelog, changelogEntry]);
+ }
+ if (changelogEntry.action === ChangelogAction.UNDO) {
+ handleRemoveEntryFromChangelog(changelogEntry);
+ }
+ };
+
+ const mapChangelogToSelectors = (): UpdateAssetGroupSelectorRequest[] => {
+ return changelog.map((item) => {
+ return {
+ selector_name: item.objectid,
+ sid: item.objectid,
+ action: item.action === ChangelogAction.ADD ? 'add' : 'remove',
+ };
+ });
+ };
+
+ // Clear out changelog when group/domain changes
+ useEffect(() => setChangelog([]), [filter]);
+
+ const mutation = useMutation({
+ mutationFn: () => {
+ const selectors = mapChangelogToSelectors();
+ return apiClient.updateAssetGroupSelector(assetGroup.id.toString(), selectors);
+ },
+ onSuccess: () => {
+ setChangelog([]);
+
+ // refetch all page data after updating group membership
+ queryClient.invalidateQueries({ queryKey: ['listAssetGroups'] });
+ queryClient.invalidateQueries({ queryKey: ['listAssetGroupMembers'] });
+ queryClient.invalidateQueries({ queryKey: ['countAssetGroupMembers'] });
+ queryClient.resetQueries({ queryKey: ['search'] });
+
+ addNotification('Update successful.', 'AssetGroupUpdateSuccess');
+ },
+ onError: (error) => {
+ console.error(error);
+ setChangelog([]);
+ addNotification('Unknown error, group was not updated', 'AssetGroupUpdateError');
+ },
+ });
+
+ const handleRemoveEntryFromChangelog = (entry: AssetGroupChangelogEntry) => {
+ setChangelog((prev) => prev.filter((item) => item.objectid !== entry.objectid));
+ };
+
+ return (
+
+
+
+ {changelog.length > 0 && (
+ setChangelog([])}
+ onSubmit={() => mutation.mutate()}
+ />
+ )}
+ {Object.values(ActiveDirectoryNodeKind).map((kind) => {
+ const filterByKind = { ...filter, primary_kind: `eq:${kind}` };
+ const label = ActiveDirectoryNodeKindToDisplay(kind) || '';
+ return (
+
+ );
+ })}
+ {Object.values(AzureNodeKind).map((kind) => {
+ const filterByKind = { ...filter, primary_kind: `eq:${kind}` };
+ const label = AzureNodeKindToDisplay(kind) || '';
+ return (
+
+ );
+ })}
+
+ );
+};
+
+const FilteredMemberCountDisplay: FC<{
+ assetGroupId: number;
+ label: string;
+ filter: AssetGroupMemberParams;
+}> = ({ assetGroupId, label, filter }) => {
+ const {
+ data: count,
+ isError,
+ isLoading,
+ } = useQuery(['countAssetGroupMembers', assetGroupId, filter], ({ signal }) =>
+ apiClient.listAssetGroupMembers(assetGroupId.toString(), filter, { signal }).then((res) => res.data.count)
+ );
+
+ const hasValidCount = !isLoading && !isError && count && count > 0;
+
+ if (hasValidCount) {
+ return ;
+ } else {
+ return null;
+ }
+};
+
+export default AssetGroupEdit;
diff --git a/packages/javascript/bh-shared-ui/src/components/AssetGroupEdit/AutocompleteOption.tsx b/packages/javascript/bh-shared-ui/src/components/AssetGroupEdit/AutocompleteOption.tsx
new file mode 100644
index 0000000000..86435183b2
--- /dev/null
+++ b/packages/javascript/bh-shared-ui/src/components/AssetGroupEdit/AutocompleteOption.tsx
@@ -0,0 +1,58 @@
+// Copyright 2023 Specter Ops, Inc.
+//
+// Licensed under the Apache License, Version 2.0
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+//
+// SPDX-License-Identifier: Apache-2.0
+
+import { Box, Fade, ListItem, Tooltip, Typography } from '@mui/material';
+import { FC, HTMLAttributes } from 'react';
+import NodeIcon from '../NodeIcon';
+
+const AutocompleteOption: FC<{
+ props: HTMLAttributes;
+ id: string;
+ name?: string;
+ type: string;
+ actionLabel?: string;
+}> = ({ props, id, name, type, actionLabel }) => {
+ return (
+
+ {actionLabel}
+
+
+
+
+ {name || id}
+
+
+
+
+ );
+};
+
+export default AutocompleteOption;
diff --git a/packages/javascript/bh-shared-ui/src/components/AssetGroupEdit/index.ts b/packages/javascript/bh-shared-ui/src/components/AssetGroupEdit/index.ts
new file mode 100644
index 0000000000..3b65499642
--- /dev/null
+++ b/packages/javascript/bh-shared-ui/src/components/AssetGroupEdit/index.ts
@@ -0,0 +1,21 @@
+// Copyright 2023 Specter Ops, Inc.
+//
+// Licensed under the Apache License, Version 2.0
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+//
+// SPDX-License-Identifier: Apache-2.0
+
+export { default } from './AssetGroupEdit';
+export { default as AssetGroupChangelogTable } from './AssetGroupChangelogTable';
+export { default as AssetGroupAutocomplete } from './AssetGroupAutocomplete';
+export { default as AutocompleteOption } from './AutocompleteOption';
+export * from './types';
diff --git a/packages/javascript/bh-shared-ui/src/components/AssetGroupEdit/types.ts b/packages/javascript/bh-shared-ui/src/components/AssetGroupEdit/types.ts
new file mode 100644
index 0000000000..bf92ea2fae
--- /dev/null
+++ b/packages/javascript/bh-shared-ui/src/components/AssetGroupEdit/types.ts
@@ -0,0 +1,32 @@
+// Copyright 2023 Specter Ops, Inc.
+//
+// Licensed under the Apache License, Version 2.0
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+//
+// SPDX-License-Identifier: Apache-2.0
+
+export enum ChangelogAction {
+ ADD,
+ REMOVE,
+ DEFAULT,
+ UNDO,
+}
+
+export type MemberData = {
+ objectid: string;
+ name: string;
+ type: string;
+};
+
+export type AssetGroupChangelogEntry = MemberData & { action: ChangelogAction };
+
+export type AssetGroupChangelog = AssetGroupChangelogEntry[];
diff --git a/packages/javascript/bh-shared-ui/src/components/AssetGroupMemberList/AssetGroupMemberList.test.tsx b/packages/javascript/bh-shared-ui/src/components/AssetGroupMemberList/AssetGroupMemberList.test.tsx
new file mode 100644
index 0000000000..5fb818f3cb
--- /dev/null
+++ b/packages/javascript/bh-shared-ui/src/components/AssetGroupMemberList/AssetGroupMemberList.test.tsx
@@ -0,0 +1,76 @@
+// Copyright 2023 Specter Ops, Inc.
+//
+// Licensed under the Apache License, Version 2.0
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+//
+// SPDX-License-Identifier: Apache-2.0
+
+import { rest } from 'msw';
+import { setupServer } from 'msw/node';
+import AssetGroupMemberList from './AssetGroupMemberList';
+import { render, waitFor } from '../../test-utils';
+import { createMockAssetGroup, createMockAssetGroupMembers } from '../../mocks/factories';
+import userEvent from '@testing-library/user-event';
+
+const assetGroup = createMockAssetGroup();
+const assetGroupMembers = createMockAssetGroupMembers();
+
+const server = setupServer(
+ rest.get('/api/v2/asset-groups/1/members', (req, res, ctx) => {
+ return res(
+ ctx.json({
+ count: assetGroupMembers.members.length,
+ limit: 100,
+ skip: 0,
+ data: assetGroupMembers,
+ })
+ );
+ })
+);
+
+beforeAll(() => server.listen());
+afterEach(() => server.resetHandlers());
+afterAll(() => server.close());
+
+describe('AssetGroupMemberList', () => {
+ const setup = () => {
+ const handleSelectMember = vi.fn();
+ const user = userEvent.setup();
+ const screen = render(
+
+ );
+ return { screen, user, handleSelectMember };
+ };
+
+ it('Should display headers for member name and count', () => {
+ const { screen } = setup();
+ expect(screen.getByText('Name')).toBeInTheDocument();
+ expect(screen.getByText('Custom Member')).toBeInTheDocument();
+ });
+
+ it('Should display a list of the asset group members', () => {
+ const { screen } = setup();
+ waitFor(() => {
+ for (const member of assetGroupMembers.members) {
+ expect(screen.getByText(member.name)).toBeInTheDocument();
+ }
+ });
+ });
+
+ it('Should call handler when a member is clicked', async () => {
+ const { screen, user, handleSelectMember } = setup();
+ const testMember = assetGroupMembers.members[0];
+ const entry = await waitFor(() => screen.getByText(testMember.name));
+ await user.click(entry);
+ expect(handleSelectMember).toHaveBeenCalledWith(testMember);
+ });
+});
diff --git a/packages/javascript/bh-shared-ui/src/components/AssetGroupMemberList/AssetGroupMemberList.tsx b/packages/javascript/bh-shared-ui/src/components/AssetGroupMemberList/AssetGroupMemberList.tsx
new file mode 100644
index 0000000000..0cc924da72
--- /dev/null
+++ b/packages/javascript/bh-shared-ui/src/components/AssetGroupMemberList/AssetGroupMemberList.tsx
@@ -0,0 +1,202 @@
+// Copyright 2023 Specter Ops, Inc.
+//
+// Licensed under the Apache License, Version 2.0
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+//
+// SPDX-License-Identifier: Apache-2.0
+
+import {
+ Box,
+ Paper,
+ Skeleton,
+ Table,
+ TableBody,
+ TableCell,
+ TableContainer,
+ TableFooter,
+ TableHead,
+ TablePagination,
+ TableRow,
+ Typography,
+ useTheme,
+} from '@mui/material';
+import { FC, useEffect, useState } from 'react';
+import NodeIcon from '../NodeIcon';
+import { AssetGroup, AssetGroupMember, AssetGroupMemberParams } from 'js-client-library';
+import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
+import { faCheck, faTimes } from '@fortawesome/free-solid-svg-icons';
+import { useQuery } from 'react-query';
+import { apiClient } from '../../utils';
+
+const AssetGroupMemberList: FC<{
+ assetGroup: AssetGroup | null;
+ filter: AssetGroupMemberParams;
+ onSelectMember: (member: any) => void;
+}> = ({ assetGroup, filter, onSelectMember }) => {
+ const theme = useTheme();
+
+ const [page, setPage] = useState(0);
+ const [rowsPerPage, setRowsPerPage] = useState(25);
+ const [count, setCount] = useState(0);
+
+ const { data, isLoading, isPreviousData, isSuccess } = useQuery(
+ ['listAssetGroupMembers', assetGroup, filter, page, rowsPerPage],
+ ({ signal }) => {
+ const paginatedFilter = {
+ skip: page * rowsPerPage,
+ limit: rowsPerPage,
+ // we could make this user selected in the future
+ sort_by: 'name',
+ ...filter,
+ };
+ return apiClient.listAssetGroupMembers(`${assetGroup?.id}`, paginatedFilter, { signal }).then((res) => {
+ setCount(res.data.count);
+ return res.data.data.members;
+ });
+ },
+ {
+ enabled: !!assetGroup,
+ keepPreviousData: true,
+ }
+ );
+
+ // Prevents an error that occurs if you try to query with a "skip" value greater than the member count of the current group
+ useEffect(() => setPage(0), [assetGroup, filter]);
+
+ const getLoadingRows = (count: number) => {
+ const rows = [];
+ for (let i = 0; i < count; i++) {
+ rows.push(
+
+
+
+
+
+
+
+
+ );
+ }
+ return rows;
+ };
+
+ return (
+
+
+
+
+
+
+
+
+ Name
+
+ Custom Member
+
+
+
+
+ {isLoading && getLoadingRows(10)}
+ {isSuccess &&
+ !!data.length &&
+ data.map((member) => (
+
+ ))}
+ {isSuccess && data.length === 0 && (
+
+
+ No members in selected Asset Group
+
+
+ )}
+
+ {isSuccess && !!data.length && (
+
+
+ setPage(page)}
+ onRowsPerPageChange={(event) => setRowsPerPage(parseInt(event.target.value))}
+ />
+
+
+ )}
+
+
+ );
+};
+
+const AssetGroupMemberRow: FC<{
+ member: AssetGroupMember;
+ disabled: boolean;
+ onClick: (member: AssetGroupMember) => void;
+}> = ({ member, disabled, onClick }) => {
+ const theme = useTheme();
+
+ const disabledRowStyles = { opacity: '0.5' };
+
+ const rowStyles = {
+ '&:hover': {
+ backgroundColor: theme.palette.action.hover,
+ cursor: 'pointer',
+ },
+ };
+
+ const handleClick = () => {
+ if (!disabled) onClick(member);
+ };
+
+ return (
+
+
+
+
+
+ {member.name}
+
+
+
+
+ {member.custom_member ? (
+
+ ) : (
+
+ )}
+
+
+ );
+};
+
+export default AssetGroupMemberList;
diff --git a/packages/javascript/bh-shared-ui/src/components/AssetGroupMemberList/index.tsx b/packages/javascript/bh-shared-ui/src/components/AssetGroupMemberList/index.tsx
new file mode 100644
index 0000000000..917f02964f
--- /dev/null
+++ b/packages/javascript/bh-shared-ui/src/components/AssetGroupMemberList/index.tsx
@@ -0,0 +1,17 @@
+// Copyright 2023 Specter Ops, Inc.
+//
+// Licensed under the Apache License, Version 2.0
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+//
+// SPDX-License-Identifier: Apache-2.0
+
+export { default } from './AssetGroupMemberList';
diff --git a/packages/javascript/bh-shared-ui/src/components/DropdownSelector/DropdownSelector.tsx b/packages/javascript/bh-shared-ui/src/components/DropdownSelector/DropdownSelector.tsx
new file mode 100644
index 0000000000..571b3909fa
--- /dev/null
+++ b/packages/javascript/bh-shared-ui/src/components/DropdownSelector/DropdownSelector.tsx
@@ -0,0 +1,110 @@
+// Copyright 2023 Specter Ops, Inc.
+//
+// Licensed under the Apache License, Version 2.0
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+//
+// SPDX-License-Identifier: Apache-2.0
+
+import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
+import { Box, Button, MenuItem, Popover, Tooltip, Typography } from '@mui/material';
+import { FC, useState } from 'react';
+import { DropdownOption } from './types';
+
+const DropdownSelector: FC<{
+ options: DropdownOption[];
+ selectedText: string;
+ fullWidth?: boolean;
+ onChange: (selection: DropdownOption) => void;
+}> = ({ options, selectedText, fullWidth, onChange }) => {
+ const [anchorEl, setAnchorEl] = useState(null);
+ const open = Boolean(anchorEl);
+
+ const handleClick = (e: any) => {
+ setAnchorEl(e.currentTarget);
+ };
+
+ const handleClose = () => {
+ setAnchorEl(null);
+ };
+
+ return (
+
+
+
+ {options.map((option) => {
+ return (
+
+ );
+ })}
+
+
+ );
+};
+
+export default DropdownSelector;
diff --git a/packages/javascript/bh-shared-ui/src/components/DropdownSelector/index.ts b/packages/javascript/bh-shared-ui/src/components/DropdownSelector/index.ts
new file mode 100644
index 0000000000..f3bf37420d
--- /dev/null
+++ b/packages/javascript/bh-shared-ui/src/components/DropdownSelector/index.ts
@@ -0,0 +1,19 @@
+// Copyright 2023 Specter Ops, Inc.
+//
+// Licensed under the Apache License, Version 2.0
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+//
+// SPDX-License-Identifier: Apache-2.0
+
+export * from './DropdownSelector';
+export { default } from './DropdownSelector';
+export * from './types';
diff --git a/packages/javascript/bh-shared-ui/src/components/DropdownSelector/types.ts b/packages/javascript/bh-shared-ui/src/components/DropdownSelector/types.ts
new file mode 100644
index 0000000000..cd4e6f4070
--- /dev/null
+++ b/packages/javascript/bh-shared-ui/src/components/DropdownSelector/types.ts
@@ -0,0 +1,23 @@
+// Copyright 2023 Specter Ops, Inc.
+//
+// Licensed under the Apache License, Version 2.0
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+//
+// SPDX-License-Identifier: Apache-2.0
+
+import { IconDefinition } from '@fortawesome/free-solid-svg-icons';
+
+export type DropdownOption = {
+ key: number;
+ value: string;
+ icon?: IconDefinition;
+};
diff --git a/packages/javascript/bh-shared-ui/src/components/GroupManagementContent/GroupManagementContent.tsx b/packages/javascript/bh-shared-ui/src/components/GroupManagementContent/GroupManagementContent.tsx
new file mode 100644
index 0000000000..689ad3cfe8
--- /dev/null
+++ b/packages/javascript/bh-shared-ui/src/components/GroupManagementContent/GroupManagementContent.tsx
@@ -0,0 +1,159 @@
+// Copyright 2023 Specter Ops, Inc.
+//
+// Licensed under the Apache License, Version 2.0
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+//
+// SPDX-License-Identifier: Apache-2.0
+
+import { AssetGroup, AssetGroupMember, AssetGroupMemberParams } from 'js-client-library';
+import { FC, ReactNode, useEffect, useState } from 'react';
+import DropdownSelector, { DropdownOption } from '../DropdownSelector';
+import { Box, Button, Grid, Paper, Typography, useTheme } from '@mui/material';
+import { useQuery } from 'react-query';
+import { apiClient } from '../../utils';
+import { faExternalLink } from '@fortawesome/free-solid-svg-icons';
+import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
+import AssetGroupEdit from '../AssetGroupEdit';
+import AssetGroupMemberList from '../AssetGroupMemberList';
+import { SelectedDomain } from './types';
+import DataSelector from '../../views/DataQuality/DataSelector';
+
+// Top level layout and shared logic for the Group Management page
+const GroupManagementContent: FC<{
+ globalDomain: SelectedDomain;
+ showExplorePageLink: boolean;
+ tierZeroLabel: string;
+ tierZeroTag: string;
+ entityPanelComponent: ReactNode;
+ domainSelectorErrorMessage: ReactNode;
+ onShowNodeInExplore: () => void;
+ onClickMember: (member: AssetGroupMember) => void;
+ mapAssetGroups: (assetGroups: AssetGroup[]) => DropdownOption[];
+}> = ({
+ globalDomain,
+ showExplorePageLink,
+ tierZeroLabel,
+ tierZeroTag,
+ entityPanelComponent,
+ domainSelectorErrorMessage,
+ onShowNodeInExplore,
+ onClickMember,
+ mapAssetGroups,
+}) => {
+ const theme = useTheme();
+
+ const [selectedDomain, setSelectedDomain] = useState(null);
+ const [selectedAssetGroupId, setSelectedAssetGroupId] = useState(null);
+ const [filterParams, setFilterParams] = useState({});
+
+ const setInitialGroup = (data: AssetGroup[]) => {
+ if (!selectedAssetGroupId && data?.length) {
+ const initialGroup = data.find((group) => group.tag === tierZeroTag) || data[0];
+ setSelectedAssetGroupId(initialGroup.id);
+ }
+ };
+
+ const listAssetGroups = useQuery(
+ ['listAssetGroups'],
+ () => apiClient.listAssetGroups().then((res) => res.data.data.asset_groups),
+ { onSuccess: setInitialGroup }
+ );
+
+ const selectedAssetGroup = listAssetGroups.data?.find((group) => group.id === selectedAssetGroupId) || null;
+
+ const handleAssetGroupSelectorChange = (selectedAssetGroup: DropdownOption) => {
+ const selected = listAssetGroups.data?.find((assetGroup) => assetGroup.id === selectedAssetGroup.key);
+ if (selected) setSelectedAssetGroupId(selected.id);
+ };
+
+ const getAssetGroupSelectorLabel = (): string => {
+ if (selectedAssetGroup?.tag === tierZeroTag) return tierZeroLabel;
+ return selectedAssetGroup?.name || 'Select a Group';
+ };
+
+ // Start building a filter query for members that gets passed down to AssetGroupMemberList to make the request
+ useEffect(() => {
+ const filterDomain = selectedDomain || globalDomain;
+ const filter: AssetGroupMemberParams = {};
+ if (filterDomain?.type === 'active-directory-platform') {
+ filter.environment_kind = 'eq:Domain';
+ } else if (filterDomain?.type === 'azure-platform') {
+ filter.environment_kind = 'eq:AZTenant';
+ } else {
+ filter.environment_id = `eq:${filterDomain?.id}`;
+ }
+ setFilterParams(filter);
+ }, [selectedDomain, globalDomain, selectedAssetGroupId]);
+
+ const selectorLabelStyles = { display: { xs: 'none', xl: 'flex' } };
+
+ return (
+
+
+
+
+
+
+ Group:
+
+
+
+
+
+ Environment:
+
+
+ setSelectedDomain({ ...selection })}
+ fullWidth={true}
+ />
+
+
+
+ {selectedAssetGroup && }
+
+
+
+
+
+ {/* CSS calc accounts for the height of the link button */}
+ {entityPanelComponent}
+ {showExplorePageLink && (
+ }>
+ Open in Explore
+
+ )}
+
+
+
+ );
+};
+
+export default GroupManagementContent;
diff --git a/packages/javascript/bh-shared-ui/src/components/GroupManagementContent/index.ts b/packages/javascript/bh-shared-ui/src/components/GroupManagementContent/index.ts
new file mode 100644
index 0000000000..c47b7468bb
--- /dev/null
+++ b/packages/javascript/bh-shared-ui/src/components/GroupManagementContent/index.ts
@@ -0,0 +1,20 @@
+// Copyright 2023 Specter Ops, Inc.
+//
+// Licensed under the Apache License, Version 2.0
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+//
+// SPDX-License-Identifier: Apache-2.0
+
+import GroupManagementContent from './GroupManagementContent';
+
+export default GroupManagementContent;
+export * from './types';
diff --git a/packages/javascript/bh-shared-ui/src/components/GroupManagementContent/types.ts b/packages/javascript/bh-shared-ui/src/components/GroupManagementContent/types.ts
new file mode 100644
index 0000000000..f25ccc51c3
--- /dev/null
+++ b/packages/javascript/bh-shared-ui/src/components/GroupManagementContent/types.ts
@@ -0,0 +1,20 @@
+// Copyright 2023 Specter Ops, Inc.
+//
+// Licensed under the Apache License, Version 2.0
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+//
+// SPDX-License-Identifier: Apache-2.0
+
+export type SelectedDomain = {
+ id: string | null;
+ type: string | null;
+};
diff --git a/packages/javascript/bh-shared-ui/src/components/index.ts b/packages/javascript/bh-shared-ui/src/components/index.ts
index c08fe9584b..6444e4113d 100644
--- a/packages/javascript/bh-shared-ui/src/components/index.ts
+++ b/packages/javascript/bh-shared-ui/src/components/index.ts
@@ -122,3 +122,15 @@ export { default as TextWithFallback } from './TextWithFallback';
export * from './UserTokenManagementDialog';
export { default as UserTokenManagementDialog } from './UserTokenManagementDialog';
+
+export * from './AssetGroupMemberList';
+export { default as AssetGroupMemberList } from './AssetGroupMemberList';
+
+export * from './DropdownSelector';
+export { default as DropdownSelector } from './DropdownSelector';
+
+export * from './AssetGroupEdit';
+export { default as AssetGroupEdit } from './AssetGroupEdit';
+
+export * from './GroupManagementContent';
+export { default as GroupManagementContent } from './GroupManagementContent';
diff --git a/packages/javascript/bh-shared-ui/src/hooks/index.ts b/packages/javascript/bh-shared-ui/src/hooks/index.ts
index fef3bd37a1..c6a71e4eeb 100644
--- a/packages/javascript/bh-shared-ui/src/hooks/index.ts
+++ b/packages/javascript/bh-shared-ui/src/hooks/index.ts
@@ -18,6 +18,10 @@ export { default as useAvailableDomains } from './useAvailableDomains';
export { default as useOnClickOutside } from './useOnClickOutside';
+export { default as useDebouncedValue } from './useDebouncedValue';
+
+export * from './useSearch';
+
export * from './useDataQualityStats';
export * from './useSavedQueries';
diff --git a/cmd/ui/src/hooks/useDebouncedValue.tsx b/packages/javascript/bh-shared-ui/src/hooks/useDebouncedValue.tsx
similarity index 91%
rename from cmd/ui/src/hooks/useDebouncedValue.tsx
rename to packages/javascript/bh-shared-ui/src/hooks/useDebouncedValue.tsx
index 9db8a7efb2..27b048f410 100644
--- a/cmd/ui/src/hooks/useDebouncedValue.tsx
+++ b/packages/javascript/bh-shared-ui/src/hooks/useDebouncedValue.tsx
@@ -16,7 +16,7 @@
import { useState, useEffect } from 'react';
-export const useDebouncedValue = (value: any, delay: number) => {
+const useDebouncedValue = (value: any, delay: number) => {
const [debouncedValue, setDebouncedValue] = useState(value);
useEffect(() => {
@@ -30,3 +30,5 @@ export const useDebouncedValue = (value: any, delay: number) => {
return debouncedValue;
};
+
+export default useDebouncedValue;
diff --git a/cmd/ui/src/hooks/useSearch/index.ts b/packages/javascript/bh-shared-ui/src/hooks/useSearch/index.ts
similarity index 100%
rename from cmd/ui/src/hooks/useSearch/index.ts
rename to packages/javascript/bh-shared-ui/src/hooks/useSearch/index.ts
diff --git a/cmd/ui/src/hooks/useSearch/useSearch.test.ts b/packages/javascript/bh-shared-ui/src/hooks/useSearch/useSearch.test.ts
similarity index 97%
rename from cmd/ui/src/hooks/useSearch/useSearch.test.ts
rename to packages/javascript/bh-shared-ui/src/hooks/useSearch/useSearch.test.ts
index 8497c30ae0..0777a8cfb0 100644
--- a/cmd/ui/src/hooks/useSearch/useSearch.test.ts
+++ b/packages/javascript/bh-shared-ui/src/hooks/useSearch/useSearch.test.ts
@@ -14,8 +14,8 @@
//
// SPDX-License-Identifier: Apache-2.0
-import { getEmptyResultsText, getKeywordAndTypeValues } from 'src/hooks/useSearch';
-import { ActiveDirectoryNodeKind } from 'bh-shared-ui';
+import { ActiveDirectoryNodeKind } from '../../graphSchema';
+import { getEmptyResultsText, getKeywordAndTypeValues } from './useSearch';
describe('Getting the text for the disabled item display for a search when there are no results', () => {
describe('Loading states', () => {
diff --git a/cmd/ui/src/hooks/useSearch/useSearch.tsx b/packages/javascript/bh-shared-ui/src/hooks/useSearch/useSearch.tsx
similarity index 82%
rename from cmd/ui/src/hooks/useSearch/useSearch.tsx
rename to packages/javascript/bh-shared-ui/src/hooks/useSearch/useSearch.tsx
index b43a62f191..e5c1da8978 100644
--- a/cmd/ui/src/hooks/useSearch/useSearch.tsx
+++ b/packages/javascript/bh-shared-ui/src/hooks/useSearch/useSearch.tsx
@@ -15,14 +15,14 @@
// SPDX-License-Identifier: Apache-2.0
import { useQuery } from 'react-query';
-import { apiClient } from 'bh-shared-ui';
-import { ActiveDirectoryNodeKind, AzureNodeKind } from 'bh-shared-ui';
-import { validateNodeType } from 'src/utils';
+import { apiClient } from '../../utils';
+import { ActiveDirectoryNodeKind, AzureNodeKind } from '../../graphSchema';
export type SearchResult = {
distinguishedname?: string;
name: string;
objectid: string;
+ system_tags?: string;
type: string;
};
@@ -67,6 +67,21 @@ export const getKeywordAndTypeValues = (
return { keyword: keyword, type: type };
};
+const validateNodeType = (type: string): ActiveDirectoryNodeKind | AzureNodeKind | undefined => {
+ let result = undefined;
+ Object.values(ActiveDirectoryNodeKind).forEach((activeDirectoryType: string) => {
+ if (activeDirectoryType.localeCompare(type, undefined, { sensitivity: 'base' }) === 0)
+ result = activeDirectoryType as ActiveDirectoryNodeKind;
+ });
+
+ Object.values(AzureNodeKind).forEach((azureType: string) => {
+ if (azureType.localeCompare(type, undefined, { sensitivity: 'base' }) === 0)
+ result = azureType as AzureNodeKind;
+ });
+
+ return result;
+};
+
const getErrorText = (error: any): string => {
if (error.response?.status === 504) return 'Search has timed out. Please try again.';
else return 'An error has occurred. Please try again.';
diff --git a/packages/javascript/bh-shared-ui/src/index.ts b/packages/javascript/bh-shared-ui/src/index.ts
index 12eaa6ee1b..6e064cd4df 100644
--- a/packages/javascript/bh-shared-ui/src/index.ts
+++ b/packages/javascript/bh-shared-ui/src/index.ts
@@ -36,3 +36,5 @@ export * from './graphSchema';
export * from './views';
export * from './store';
+
+export * from './mocks';
diff --git a/packages/javascript/bh-shared-ui/src/mocks/factories.ts b/packages/javascript/bh-shared-ui/src/mocks/factories.ts
new file mode 100644
index 0000000000..bb35a4aa60
--- /dev/null
+++ b/packages/javascript/bh-shared-ui/src/mocks/factories.ts
@@ -0,0 +1,84 @@
+// Copyright 2023 Specter Ops, Inc.
+//
+// Licensed under the Apache License, Version 2.0
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+//
+// SPDX-License-Identifier: Apache-2.0
+
+import { AssetGroup, AssetGroupMember } from 'js-client-library';
+import { SearchResults } from '../hooks';
+
+export const createMockAssetGroupMembers = (): { members: AssetGroupMember[] } => {
+ return {
+ members: [
+ {
+ asset_group_id: 1,
+ object_id: '00000-00001',
+ primary_kind: 'User',
+ kinds: ['User', 'Base'],
+ environment_id: '00000-00000-00001',
+ environment_kind: 'Domain',
+ name: 'USER_00001@TESTLAB.LOCAL',
+ custom_member: false,
+ },
+ {
+ asset_group_id: 1,
+ object_id: '00000-00002',
+ primary_kind: 'Computer',
+ kinds: ['Computer', 'Base'],
+ environment_id: '00000-00000-00001',
+ environment_kind: 'Domain',
+ name: 'COMPUTER_00001@TESTLAB.LOCAL',
+ custom_member: false,
+ },
+ {
+ asset_group_id: 1,
+ object_id: '00000-00003',
+ primary_kind: 'GPO',
+ kinds: ['GPO', 'Base'],
+ environment_id: '00000-00000-00001',
+ environment_kind: 'Domain',
+ name: 'GPO_00001@TESTLAB.LOCAL',
+ custom_member: true,
+ },
+ ],
+ };
+};
+
+export const createMockAssetGroup = (): AssetGroup => {
+ return {
+ id: 1,
+ name: 'Admin Tier Zero',
+ tag: 'admin_tier_0',
+ member_count: 3,
+ system_group: true,
+ Selectors: [],
+ created_at: '2023-10-18T16:19:25.26533Z',
+ updated_at: '2023-10-18T16:19:25.26533Z',
+ deleted_at: {
+ Time: '0001-01-01T00:00:00Z',
+ Valid: false,
+ },
+ };
+};
+
+export const createMockSearchResults = (): SearchResults => {
+ return [
+ {
+ objectid: '00000-00000-00000-00001',
+ type: 'Computer',
+ name: '00001.TESTLAB.LOCAL',
+ distinguishedname: '',
+ system_tags: '',
+ },
+ ];
+};
diff --git a/packages/javascript/bh-shared-ui/src/mocks/index.ts b/packages/javascript/bh-shared-ui/src/mocks/index.ts
new file mode 100644
index 0000000000..3ea6a2b454
--- /dev/null
+++ b/packages/javascript/bh-shared-ui/src/mocks/index.ts
@@ -0,0 +1,17 @@
+// Copyright 2023 Specter Ops, Inc.
+//
+// Licensed under the Apache License, Version 2.0
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+//
+// SPDX-License-Identifier: Apache-2.0
+
+export * from './factories';
diff --git a/packages/javascript/bh-shared-ui/src/views/DataQuality/DataSelector/DataSelector.test.tsx b/packages/javascript/bh-shared-ui/src/views/DataQuality/DataSelector/DataSelector.test.tsx
index 3876924bb0..0935f9f483 100644
--- a/packages/javascript/bh-shared-ui/src/views/DataQuality/DataSelector/DataSelector.test.tsx
+++ b/packages/javascript/bh-shared-ui/src/views/DataQuality/DataSelector/DataSelector.test.tsx
@@ -258,12 +258,14 @@ const testDomains = [
},
];
+const errorMessage = <>Domains unavailable>;
+
describe('Context Selector', () => {
it('should render with a full list of multiple tenants and domains', async () => {
const user = userEvent.setup();
const testOnChange = vi.fn();
const testValue = { type: 'active-directory', id: '6b55e74d-f24e-418a-bfd1-4769e93517c7' };
- render();
+ render();
const contextSelector = await screen.findByTestId('data-quality_context-selector');
expect(contextSelector).toBeInTheDocument();
@@ -288,7 +290,7 @@ describe('Context Selector', () => {
const user = userEvent.setup();
const testOnChange = vi.fn();
const testValue = { type: 'active-directory', id: '6b55e74d-f24e-418a-bfd1-4769e93517c7' };
- render();
+ render();
const contextSelector = await screen.findByTestId('data-quality_context-selector');
await user.click(contextSelector);
@@ -337,7 +339,7 @@ describe('Context Selector', () => {
const user = userEvent.setup();
const testOnChange = vi.fn();
const testValue = { type: 'azure', id: 'd1993a1b-55c1-4668-9393-ddfffb6ab639' };
- render();
+ render();
const contextSelector = await screen.findByTestId('data-quality_context-selector');
@@ -366,9 +368,10 @@ describe('Context Selector Error', () => {
it('should display an error message if data does not return from the API', async () => {
const testOnChange = vi.fn();
+ const testErrorMessage = 'test error message';
const testValue = { type: 'active-directory', id: '6b55e74d-f24e-418a-bfd1-4769e93517c7' };
- render();
+ render({testErrorMessage}>} />);
- expect(await screen.findByText('Data Collection')).toBeInTheDocument();
+ expect(await screen.findByText(testErrorMessage)).toBeInTheDocument();
});
});
diff --git a/packages/javascript/bh-shared-ui/src/views/DataQuality/DataSelector/DataSelector.tsx b/packages/javascript/bh-shared-ui/src/views/DataQuality/DataSelector/DataSelector.tsx
index c074e4516e..d6cafaf107 100644
--- a/packages/javascript/bh-shared-ui/src/views/DataQuality/DataSelector/DataSelector.tsx
+++ b/packages/javascript/bh-shared-ui/src/views/DataQuality/DataSelector/DataSelector.tsx
@@ -21,7 +21,6 @@ import {
Box,
Button,
Divider,
- Link,
MenuItem,
Popover,
Skeleton,
@@ -30,31 +29,21 @@ import {
Typography,
} from '@mui/material';
import { useAvailableDomains, Domain } from '../../../hooks';
-import React, { useState } from 'react';
+import React, { ReactNode, useState } from 'react';
const DataSelector: React.FC<{
value: { type: string | null; id: string | null };
+ errorMessage: ReactNode;
onChange?: (newValue: { type: string; id: string | null }) => void;
fullWidth?: boolean;
-}> = ({ value, onChange = () => {}, fullWidth = false }) => {
+}> = ({ value, errorMessage, onChange = () => {}, fullWidth = false }) => {
const [anchorEl, setAnchorEl] = useState(null);
const [searchInput, setSearchInput] = useState('');
const { data, isLoading, isError } = useAvailableDomains();
if (isLoading) return ;
- if (isError)
- return (
-
- Domains unavailable. See the{' '}
-
- Data Collection
- {' '}
- page to view instructions on how to begin data collection.
-
- );
+ if (isError) return {errorMessage};
const handleClick = (event: any) => {
setAnchorEl(event.currentTarget);
diff --git a/cmd/ui/src/views/Explore/InfoStyles/CollapsibleSection.tsx b/packages/javascript/bh-shared-ui/src/views/Explore/InfoStyles/CollapsibleSection.tsx
similarity index 100%
rename from cmd/ui/src/views/Explore/InfoStyles/CollapsibleSection.tsx
rename to packages/javascript/bh-shared-ui/src/views/Explore/InfoStyles/CollapsibleSection.tsx
diff --git a/cmd/ui/src/views/Explore/InfoStyles/Header.tsx b/packages/javascript/bh-shared-ui/src/views/Explore/InfoStyles/Header.tsx
similarity index 100%
rename from cmd/ui/src/views/Explore/InfoStyles/Header.tsx
rename to packages/javascript/bh-shared-ui/src/views/Explore/InfoStyles/Header.tsx
diff --git a/cmd/ui/src/views/Explore/InfoStyles/Pane.tsx b/packages/javascript/bh-shared-ui/src/views/Explore/InfoStyles/Pane.tsx
similarity index 95%
rename from cmd/ui/src/views/Explore/InfoStyles/Pane.tsx
rename to packages/javascript/bh-shared-ui/src/views/Explore/InfoStyles/Pane.tsx
index a8b075f444..06e251172c 100644
--- a/cmd/ui/src/views/Explore/InfoStyles/Pane.tsx
+++ b/packages/javascript/bh-shared-ui/src/views/Explore/InfoStyles/Pane.tsx
@@ -22,8 +22,7 @@ const usePaneStyles = makeStyles((theme: Theme) => ({
display: 'flex',
flexDirection: 'column',
pointerEvents: 'none',
- margin: theme.spacing(0, 4, 2, 2),
- maxHeight: '95%',
+ overflowY: 'hidden',
},
headerPaperRoot: {
backgroundColor: theme.palette.background.paper,
diff --git a/packages/javascript/bh-shared-ui/src/views/Explore/InfoStyles/index.ts b/packages/javascript/bh-shared-ui/src/views/Explore/InfoStyles/index.ts
new file mode 100644
index 0000000000..6b3e140399
--- /dev/null
+++ b/packages/javascript/bh-shared-ui/src/views/Explore/InfoStyles/index.ts
@@ -0,0 +1,19 @@
+// Copyright 2023 Specter Ops, Inc.
+//
+// Licensed under the Apache License, Version 2.0
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+//
+// SPDX-License-Identifier: Apache-2.0
+
+export { default as useCollapsibleSectionStyles } from './CollapsibleSection';
+export { default as useHeaderStyles } from './Header';
+export { default as usePaneStyles } from './Pane';
diff --git a/cmd/ui/src/views/Explore/fragments.test.tsx b/packages/javascript/bh-shared-ui/src/views/Explore/fragments.test.tsx
similarity index 97%
rename from cmd/ui/src/views/Explore/fragments.test.tsx
rename to packages/javascript/bh-shared-ui/src/views/Explore/fragments.test.tsx
index 0d240eb717..e4d347596c 100644
--- a/cmd/ui/src/views/Explore/fragments.test.tsx
+++ b/packages/javascript/bh-shared-ui/src/views/Explore/fragments.test.tsx
@@ -15,7 +15,7 @@
// SPDX-License-Identifier: Apache-2.0
import { Field } from './fragments';
-import { render, screen } from 'src/test-utils';
+import { render, screen } from '../../test-utils';
describe('Field', () => {
it('should render a Field when the provided value is false', () => {
diff --git a/cmd/ui/src/views/Explore/fragments.tsx b/packages/javascript/bh-shared-ui/src/views/Explore/fragments.tsx
similarity index 66%
rename from cmd/ui/src/views/Explore/fragments.tsx
rename to packages/javascript/bh-shared-ui/src/views/Explore/fragments.tsx
index 363f3e9cba..44010424c7 100644
--- a/cmd/ui/src/views/Explore/fragments.tsx
+++ b/packages/javascript/bh-shared-ui/src/views/Explore/fragments.tsx
@@ -15,14 +15,9 @@
// SPDX-License-Identifier: Apache-2.0
import { Alert, Box, CircularProgress, Typography } from '@mui/material';
-import { AzureNodeKind, EntityField, EntityKinds, NodeIcon, format } from 'bh-shared-ui';
-import isEmpty from 'lodash/isEmpty';
+import useCollapsibleSectionStyles from './InfoStyles/CollapsibleSection';
import React, { PropsWithChildren } from 'react';
-import { TIER_ZERO_TAG } from 'src/constants';
-import { sourceNodeSelected } from 'src/ducks/searchbar/actions';
-
-import { useAppDispatch } from 'src/store';
-import useCollapsibleSectionStyles from 'src/views/Explore/InfoStyles/CollapsibleSection';
+import { EntityField, format } from '../../utils';
const exclusionList = [
'gid',
@@ -108,7 +103,7 @@ export const Field: React.FC = (entityField) => {
value === undefined ||
value === '' ||
(Array.isArray(value) && value.length === 0) ||
- (typeof value === 'object' && isEmpty(value))
+ (typeof value === 'object' && Object.keys(value).length === 0)
)
return null;
@@ -152,70 +147,6 @@ export const Field: React.FC = (entityField) => {
return <>{content}>;
};
-interface BasicObjectInfoFieldsProps {
- objectid: string;
- displayname?: string;
- system_tags?: string;
- service_principal_id?: string;
- noderesourcegroupid?: string;
- name?: string;
-}
-
-const RelatedKindField = (fieldLabel: string, relatedKind: EntityKinds, id: string, name?: string) => {
- const dispatch = useAppDispatch();
- return (
-
-
- {fieldLabel}
-
-
-
-
- {
- dispatch(
- sourceNodeSelected({
- objectid: id,
- type: relatedKind,
- name: name || '',
- })
- );
- }}
- style={{ cursor: 'pointer' }}
- overflow='hidden'
- textOverflow='ellipsis'
- title={id}>
- {id}
-
-
-
- );
-};
-
-export const BasicObjectInfoFields: React.FC = (props): JSX.Element => {
- return (
- <>
- {props.system_tags?.includes(TIER_ZERO_TAG) && }
- {props.displayname && }
-
- {props.service_principal_id &&
- RelatedKindField(
- 'Service Principal ID:',
- AzureNodeKind.ServicePrincipal,
- props.service_principal_id,
- props.name
- )}
- {props.noderesourcegroupid &&
- RelatedKindField(
- 'Node Resource Group ID:',
- AzureNodeKind.ResourceGroup,
- props.noderesourcegroupid,
- props.name
- )}
- >
- );
-};
-
export const ObjectInfoFields: React.FC<{ fields: EntityField[] }> = ({ fields }): JSX.Element => {
const filteredFields = filterNegatedFields(fields);
diff --git a/packages/javascript/bh-shared-ui/src/views/Explore/index.ts b/packages/javascript/bh-shared-ui/src/views/Explore/index.ts
new file mode 100644
index 0000000000..03c10fd014
--- /dev/null
+++ b/packages/javascript/bh-shared-ui/src/views/Explore/index.ts
@@ -0,0 +1,18 @@
+// Copyright 2023 Specter Ops, Inc.
+//
+// Licensed under the Apache License, Version 2.0
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+//
+// SPDX-License-Identifier: Apache-2.0
+
+export * from './fragments';
+export * from './InfoStyles';
diff --git a/packages/javascript/bh-shared-ui/src/views/index.ts b/packages/javascript/bh-shared-ui/src/views/index.ts
index 28bb7aa19b..833ed54dc5 100644
--- a/packages/javascript/bh-shared-ui/src/views/index.ts
+++ b/packages/javascript/bh-shared-ui/src/views/index.ts
@@ -16,6 +16,8 @@
export { default as UserProfile } from './UserProfile';
+export * from './Explore';
+
export * from './DataQuality';
export * from './Explore/ExploreSearch';
diff --git a/packages/javascript/js-client-library/src/types.ts b/packages/javascript/js-client-library/src/types.ts
index c16387ab18..d9f70bbe65 100644
--- a/packages/javascript/js-client-library/src/types.ts
+++ b/packages/javascript/js-client-library/src/types.ts
@@ -82,7 +82,6 @@ export interface CreateScheduledSharpHoundJobRequest {
all_trusted_domains: boolean;
}
-// eslint-disable-next-line @typescript-eslint/no-empty-interface
export type CreateScheduledAzureHoundJobRequest = Record;
export type CreateScheduledJobRequest = CreateScheduledSharpHoundJobRequest | CreateScheduledAzureHoundJobRequest;