@cccsaurora/howler-ui 2.17.0-dev.521 → 2.17.0-dev.523

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -15,6 +15,7 @@ const Modal = () => {
15
15
  left: '50%',
16
16
  maxWidth: options.maxWidth || '1200px',
17
17
  maxHeight: options.maxHeight || '400px',
18
+ height: '100%',
18
19
  transform: 'translate(-50%, -50%)',
19
20
  backgroundColor: 'background.paper',
20
21
  borderRadius: theme.shape.borderRadius,
@@ -1,16 +1,15 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
2
2
  import { Box, Chip, Divider, Grid, Stack, Tooltip, Typography, avatarClasses, iconButtonClasses, useTheme } from '@mui/material';
3
- import useMatchers from '@cccsaurora/howler-ui/components/app/hooks/useMatchers';
4
3
  import { ApiConfigContext } from '@cccsaurora/howler-ui/components/app/providers/ApiConfigProvider';
5
4
  import { uniq } from 'lodash-es';
6
5
  import howlerPluginStore from '@cccsaurora/howler-ui/plugins/store';
7
- import { useCallback, useContext, useEffect, useMemo, useState } from 'react';
6
+ import { useCallback, useContext, useMemo } from 'react';
8
7
  import { Trans, useTranslation } from 'react-i18next';
9
8
  import { usePluginStore } from 'react-pluggable';
10
- import { Link } from 'react-router-dom';
11
9
  import { ESCALATION_COLORS, PROVIDER_COLORS } from '@cccsaurora/howler-ui/utils/constants';
12
10
  import { stringToColor } from '@cccsaurora/howler-ui/utils/utils';
13
11
  import PluginTypography from '../PluginTypography';
12
+ import AnalyticLink from './elements/AnalyticLink';
14
13
  import Assigned from './elements/Assigned';
15
14
  import EscalationChip from './elements/EscalationChip';
16
15
  import HitTimestamp from './elements/HitTimestamp';
@@ -21,17 +20,8 @@ const HitBanner = ({ hit, layout = HitLayout.NORMAL, showAssigned = true }) => {
21
20
  const { config } = useContext(ApiConfigContext);
22
21
  const theme = useTheme();
23
22
  const pluginStore = usePluginStore();
24
- const { getMatchingAnalytic } = useMatchers();
25
- const [analyticId, setAnalyticId] = useState();
26
23
  const compressed = useMemo(() => layout === HitLayout.DENSE, [layout]);
27
24
  const textVariant = useMemo(() => (layout === HitLayout.COMFY ? 'body1' : 'caption'), [layout]);
28
- useEffect(() => {
29
- if (!hit?.howler.analytic) {
30
- return;
31
- }
32
- getMatchingAnalytic(hit).then(analytic => setAnalyticId(analytic?.analytic_id));
33
- // eslint-disable-next-line react-hooks/exhaustive-deps
34
- }, [hit?.howler.analytic]);
35
25
  const providerColor = useMemo(() => {
36
26
  if (!hit?.event.provider) {
37
27
  return PROVIDER_COLORS.unknown;
@@ -91,11 +81,7 @@ const HitBanner = ({ hit, layout = HitLayout.NORMAL, showAssigned = true }) => {
91
81
  }, spacing: layout !== HitLayout.COMFY ? 1 : 2, divider: _jsx(Divider, { orientation: "horizontal", sx: [
92
82
  layout !== HitLayout.COMFY && { marginTop: '4px !important' },
93
83
  { mr: `${theme.spacing(-1)} !important` }
94
- ] }), children: [_jsxs(Typography, { variant: compressed ? 'body1' : 'h6', fontWeight: compressed && 'bold', sx: { alignSelf: 'start', '& a': { color: 'text.primary' } }, children: [analyticId ? (_jsx(Link, { to: `/analytics/${analyticId}`, onAuxClick: e => {
95
- e.stopPropagation();
96
- }, onClick: e => {
97
- e.stopPropagation();
98
- }, children: hit.howler.analytic })) : (hit.howler.analytic), hit.howler.detection && ': ', hit.howler.detection] }), hit.howler?.rationale && (_jsxs(Typography, { flex: 1, variant: textVariant, color: ESCALATION_COLORS[hit.howler.escalation] + '.main', sx: { fontWeight: 'bold' }, children: [t('hit.header.rationale'), ": ", hit.howler.rationale] })), hit.howler?.outline && (_jsxs(_Fragment, { children: [_jsxs(Grid, { container: true, spacing: layout !== HitLayout.COMFY ? 1 : 2, sx: { ml: `${theme.spacing(-1)} !important` }, children: [hit.howler.outline.threat && (_jsx(Grid, { item: true, children: _jsx(Wrapper, { i18nKey: "hit.header.threat", value: hit.howler.outline.threat, field: "howler.outline.threat" }) })), hit.howler.outline.target && (_jsx(Grid, { item: true, children: _jsx(Wrapper, { i18nKey: "hit.header.target", value: hit.howler.outline.target, field: "howler.outline.target" }) }))] }), hit.howler.outline.indicators?.length > 0 && (_jsxs(Stack, { direction: "row", spacing: 1, children: [_jsxs(Typography, { component: "span", variant: textVariant, children: [t('hit.header.indicators'), ":"] }), _jsx(Grid, { container: true, spacing: 0.5, sx: { mt: `${theme.spacing(-0.5)} !important`, ml: `${theme.spacing(0.25)} !important` }, children: uniq(hit.howler.outline.indicators).map((_indicator, index) => {
84
+ ] }), children: [_jsx(AnalyticLink, { hit: hit }), hit.howler?.rationale && (_jsxs(Typography, { flex: 1, variant: textVariant, color: ESCALATION_COLORS[hit.howler.escalation] + '.main', sx: { fontWeight: 'bold' }, children: [t('hit.header.rationale'), ": ", hit.howler.rationale] })), hit.howler?.outline && (_jsxs(_Fragment, { children: [_jsxs(Grid, { container: true, spacing: layout !== HitLayout.COMFY ? 1 : 2, sx: { ml: `${theme.spacing(-1)} !important` }, children: [hit.howler.outline.threat && (_jsx(Grid, { item: true, children: _jsx(Wrapper, { i18nKey: "hit.header.threat", value: hit.howler.outline.threat, field: "howler.outline.threat" }) })), hit.howler.outline.target && (_jsx(Grid, { item: true, children: _jsx(Wrapper, { i18nKey: "hit.header.target", value: hit.howler.outline.target, field: "howler.outline.target" }) }))] }), hit.howler.outline.indicators?.length > 0 && (_jsxs(Stack, { direction: "row", spacing: 1, children: [_jsxs(Typography, { component: "span", variant: textVariant, children: [t('hit.header.indicators'), ":"] }), _jsx(Grid, { container: true, spacing: 0.5, sx: { mt: `${theme.spacing(-0.5)} !important`, ml: `${theme.spacing(0.25)} !important` }, children: uniq(hit.howler.outline.indicators).map((_indicator, index) => {
99
85
  return (_jsx(Grid, { item: true, children: _jsxs(Stack, { direction: "row", children: [_jsx(PluginTypography, { context: "indicators", variant: textVariant, value: _indicator, children: _indicator }), index < hit.howler.outline.indicators.length - 1 && (_jsx(Typography, { variant: textVariant, children: ',' }))] }) }, _indicator));
100
86
  }) })] })), hit.howler.outline.summary && (_jsx(Wrapper, { i18nKey: "hit.header.summary", value: hit.howler.outline.summary, paragraph: true, textOverflow: "wrap", sx: [compressed && { marginTop: `0 !important` }], field: "howler.outline.summary" }))] }))] }), _jsxs(Stack, { direction: "column", spacing: layout !== HitLayout.COMFY ? 0.5 : 1, alignSelf: "stretch", sx: [
101
87
  { minWidth: 0, alignItems: { sm: 'end', md: 'start' }, flex: 1, pl: 1 },
@@ -0,0 +1,8 @@
1
+ import type { Hit } from '@cccsaurora/howler-ui/models/entities/generated/Hit';
2
+ import { type FC } from 'react';
3
+ declare const AnalyticLink: FC<{
4
+ hit: Hit;
5
+ compressed?: boolean;
6
+ alignSelf?: string;
7
+ }>;
8
+ export default AnalyticLink;
@@ -0,0 +1,22 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { Typography } from '@mui/material';
3
+ import useMatchers from '@cccsaurora/howler-ui/components/app/hooks/useMatchers';
4
+ import { useEffect, useState } from 'react';
5
+ import { Link } from 'react-router-dom';
6
+ const AnalyticLink = ({ hit, compressed, alignSelf = 'start' }) => {
7
+ const { getMatchingAnalytic } = useMatchers();
8
+ const [analyticId, setAnalyticId] = useState();
9
+ useEffect(() => {
10
+ if (!hit?.howler.analytic) {
11
+ return;
12
+ }
13
+ getMatchingAnalytic(hit).then(analytic => setAnalyticId(analytic?.analytic_id));
14
+ // eslint-disable-next-line react-hooks/exhaustive-deps
15
+ }, [hit?.howler.analytic]);
16
+ return (_jsxs(Typography, { variant: compressed ? 'body1' : 'h6', fontWeight: compressed && 'bold', sx: { alignSelf, '& a': { color: 'text.primary' } }, children: [analyticId ? (_jsx(Link, { to: `/analytics/${analyticId}`, onAuxClick: e => {
17
+ e.stopPropagation();
18
+ }, onClick: e => {
19
+ e.stopPropagation();
20
+ }, children: hit.howler.analytic })) : (hit.howler.analytic), hit.howler.detection && ': ', hit.howler.detection] }));
21
+ };
22
+ export default AnalyticLink;
@@ -7,7 +7,7 @@ declare const useHitActions: (_hits: Hit | Hit[]) => {
7
7
  canAssess: boolean;
8
8
  loading: boolean;
9
9
  manage: (transition: string) => Promise<void>;
10
- assess: (assessment: string, skipRationale?: boolean) => Promise<void>;
10
+ assess: (assessment: string, skipRationale?: boolean, providedRationale?: any) => Promise<void>;
11
11
  vote: (v: string) => Promise<void>;
12
12
  selectedVote: string;
13
13
  };
@@ -90,9 +90,9 @@ const useHitActions = (_hits) => {
90
90
  }
91
91
  }
92
92
  }, [dispatchApi, hits, selectedVote, updateHit, user.email]);
93
- const assess = useCallback(async (assessment, skipRationale = false) => {
93
+ const assess = useCallback(async (assessment, skipRationale = false, providedRationale = null) => {
94
94
  const rationale = skipRationale
95
- ? t('rationale.default', { assessment })
95
+ ? (providedRationale ?? t('rationale.default', { assessment }))
96
96
  : await new Promise(res => {
97
97
  showModal(_jsx(RationaleModal, { hits: hits, onSubmit: _rationale => {
98
98
  res(_rationale);
@@ -25,7 +25,8 @@ const useMySitemap = () => {
25
25
  routes: [
26
26
  { path: '/', title: t('route.home'), isRoot: true, icon: _jsx(Dashboard, {}) },
27
27
  { path: '/cases', title: t('route.cases'), isRoot: true, icon: _jsx(BookRounded, {}) },
28
- { path: '/cases/:id', title: t('route.cases.view') },
28
+ { path: '/cases/:id', title: t('route.cases.view'), breadcrumbs: ['/cases'] },
29
+ { path: '/cases/:id/*', title: t('route.cases.view'), breadcrumbs: ['/cases'] },
29
30
  { path: '/admin/users', title: t('route.admin.user.search'), isRoot: true, icon: _jsx(PersonSearch, {}) },
30
31
  {
31
32
  path: '/admin/users/:id',
@@ -2,15 +2,18 @@ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
2
  import { Check, FormatListBulleted, HourglassBottom, Pause, People, WarningRounded } from '@mui/icons-material';
3
3
  import { Autocomplete, Card, Chip, Divider, LinearProgress, Skeleton, Stack, Table, TableBody, TableCell, TableRow, TextField, Typography } from '@mui/material';
4
4
  import { ApiConfigContext } from '@cccsaurora/howler-ui/components/app/providers/ApiConfigProvider';
5
+ import { ModalContext } from '@cccsaurora/howler-ui/components/app/providers/ModalProvider';
5
6
  import UserList from '@cccsaurora/howler-ui/components/elements/UserList';
6
7
  import dayjs from 'dayjs';
7
8
  import { useContext, useState } from 'react';
8
9
  import { useTranslation } from 'react-i18next';
9
10
  import useCase from '../hooks/useCase';
11
+ import ResolveModal from '../modals/ResolveModal';
10
12
  import SourceAggregate from './aggregates/SourceAggregate';
11
13
  const CaseDetails = ({ case: providedCase }) => {
12
14
  const { t } = useTranslation();
13
15
  const { case: _case, updateCase } = useCase({ case: providedCase });
16
+ const { showModal } = useContext(ModalContext);
14
17
  const { config } = useContext(ApiConfigContext);
15
18
  const [loading, setLoading] = useState(false);
16
19
  const wrappedUpdate = async (subset) => {
@@ -22,6 +25,15 @@ const CaseDetails = ({ case: providedCase }) => {
22
25
  setLoading(false);
23
26
  }
24
27
  };
28
+ const handleStatus = (status) => {
29
+ if (status === 'resolved') {
30
+ const onConfirm = () => wrappedUpdate({ status });
31
+ showModal(_jsx(ResolveModal, { case: _case, onConfirm: onConfirm }), { maxHeight: '80vh' });
32
+ }
33
+ else {
34
+ wrappedUpdate({ status });
35
+ }
36
+ };
25
37
  if (!_case) {
26
38
  return (_jsx(Card, { sx: {
27
39
  borderRadius: 0,
@@ -42,8 +54,8 @@ const CaseDetails = ({ case: providedCase }) => {
42
54
  position: 'relative'
43
55
  }, children: [_jsx(LinearProgress, { sx: { opacity: +loading, position: 'absolute', top: 0, left: 0, right: 0 } }), _jsxs(Stack, { spacing: 2, children: [_jsxs(Stack, { spacing: 1, children: [_jsxs(Stack, { direction: "row", spacing: 1, alignItems: "center", children: [{
44
56
  'in-progress': _jsx(HourglassBottom, { color: "warning" }),
45
- closed: _jsx(Check, { color: "success" }),
46
- 'on-hold': _jsx(Pause, { color: "disabled" })
47
- }[_case.status] ?? _jsx(WarningRounded, { fontSize: "small" }), _jsx(Typography, { variant: "body1", children: t('page.cases.detail.status') })] }), _jsx(Autocomplete, { size: "small", disabled: loading, value: _case.status, options: config.lookups['howler.status'], renderInput: params => _jsx(TextField, { ...params, size: "small" }), onChange: (_ev, status) => wrappedUpdate({ status }) })] }), _jsx(Divider, {}), _jsxs(Stack, { spacing: 1, children: [_jsxs(Stack, { direction: "row", spacing: 1, alignItems: "center", children: [_jsx(People, {}), _jsx(Typography, { variant: "body1", children: t('page.cases.detail.participants') })] }), _jsx(UserList, { buttonSx: { alignSelf: 'start' }, multiple: true, i18nLabel: "page.cases.detail.assignment", userIds: _case.participants ?? [], onChange: participants => wrappedUpdate({ participants }), disabled: loading })] }), _jsx(Divider, {}), _jsxs(Stack, { spacing: 1, children: [_jsxs(Stack, { direction: "row", spacing: 1, alignItems: "center", children: [_jsx(FormatListBulleted, {}), _jsx(Typography, { variant: "body1", children: t('page.cases.detail.properties') })] }), _jsx(Table, { sx: { '& td': { p: 1 } }, children: _jsxs(TableBody, { children: [_jsxs(TableRow, { children: [_jsx(TableCell, { children: _jsx(Typography, { variant: "caption", children: t('page.cases.escalation') }) }), _jsx(TableCell, { children: _jsx(Chip, { size: "small", label: _case.escalation }) })] }), _jsxs(TableRow, { children: [_jsx(TableCell, { children: _jsx(Typography, { variant: "caption", children: t('page.cases.created') }) }), _jsx(TableCell, { children: _jsx(Typography, { variant: "caption", children: dayjs(_case.created).toString() }) })] }), _jsxs(TableRow, { children: [_jsx(TableCell, { children: _jsx(Typography, { variant: "caption", children: t('page.cases.updated') }) }), _jsx(TableCell, { children: _jsx(Typography, { variant: "caption", children: dayjs(_case.updated).toString() }) })] }), _jsxs(TableRow, { children: [_jsx(TableCell, { children: _jsx(Typography, { variant: "caption", children: t('page.cases.sources') }) }), _jsx(TableCell, { children: _jsx(Typography, { variant: "caption", children: _jsx(SourceAggregate, { case: _case }) }) })] })] }) })] })] })] }));
57
+ 'on-hold': _jsx(Pause, { color: "disabled" }),
58
+ resolved: _jsx(Check, { color: "success" })
59
+ }[_case.status] ?? _jsx(WarningRounded, { fontSize: "small" }), _jsx(Typography, { variant: "body1", children: t('page.cases.detail.status') })] }), _jsx(Autocomplete, { size: "small", disabled: loading, value: _case.status, options: config.lookups['howler.status'], renderInput: params => _jsx(TextField, { ...params, size: "small" }), onChange: (_ev, status) => handleStatus(status) })] }), _jsx(Divider, {}), _jsxs(Stack, { spacing: 1, children: [_jsxs(Stack, { direction: "row", spacing: 1, alignItems: "center", children: [_jsx(People, {}), _jsx(Typography, { variant: "body1", children: t('page.cases.detail.participants') })] }), _jsx(UserList, { buttonSx: { alignSelf: 'start' }, multiple: true, i18nLabel: "page.cases.detail.assignment", userIds: _case.participants ?? [], onChange: participants => wrappedUpdate({ participants }), disabled: loading })] }), _jsx(Divider, {}), _jsxs(Stack, { spacing: 1, children: [_jsxs(Stack, { direction: "row", spacing: 1, alignItems: "center", children: [_jsx(FormatListBulleted, {}), _jsx(Typography, { variant: "body1", children: t('page.cases.detail.properties') })] }), _jsx(Table, { sx: { '& td': { p: 1 } }, children: _jsxs(TableBody, { children: [_jsxs(TableRow, { children: [_jsx(TableCell, { children: _jsx(Typography, { variant: "caption", children: t('page.cases.escalation') }) }), _jsx(TableCell, { children: _jsx(Chip, { size: "small", label: _case.escalation }) })] }), _jsxs(TableRow, { children: [_jsx(TableCell, { children: _jsx(Typography, { variant: "caption", children: t('page.cases.created') }) }), _jsx(TableCell, { children: _jsx(Typography, { variant: "caption", children: dayjs(_case.created).toString() }) })] }), _jsxs(TableRow, { children: [_jsx(TableCell, { children: _jsx(Typography, { variant: "caption", children: t('page.cases.updated') }) }), _jsx(TableCell, { children: _jsx(Typography, { variant: "caption", children: dayjs(_case.updated).toString() }) })] }), _jsxs(TableRow, { children: [_jsx(TableCell, { children: _jsx(Typography, { variant: "caption", children: t('page.cases.sources') }) }), _jsx(TableCell, { children: _jsx(Typography, { variant: "caption", children: _jsx(SourceAggregate, { case: _case }) }) })] })] }) })] })] })] }));
48
60
  };
49
61
  export default CaseDetails;
@@ -0,0 +1 @@
1
+ export default "<h3>Actions</h3>\n\n<ul class=\"actions_list\">\n\n<li>\n\n</li>\n\n<li>\n\n</li>\n\n</ul>\n\n<h3>Data</h3>\n\n<table class=\"table_overview\">\n\n<tbody>\n\n<tr>\n\n<td style=\"padding:8px;font-weight:bold;\">Tangent</td>\n\n<td style=\"padding:8px;\">Triangle</td>\n\n</tr>\n\n<tr>\n\n<td style=\"padding:8px;font-weight:bold;\">Start date</td>\n\n<td style=\"padding:8px;\">1</td>\n\n</tr>\n\n<tr>\n\n<td style=\"padding:8px;font-weight:bold;\">End date</td>\n\n<td style=\"padding:8px;\">1</td>\n\n</tr>\n\n<tr>\n\n<td style=\"padding:8px;font-weight:bold;\">Country</td>\n\n<td style=\"padding:8px;\"><span style=\"text-transform: uppercase;\">Canada</span> / <span style=\"text-transform: capitalize;\">Canada / <span style=\"text-transform: capitalize;\">Canada</span></td>\n\n</tr>\n\n</tbody>\n\n</table>\n\n<h3>Visualization</h3>\n\n```mermaid\n\ngraph LR\n\nA[Password Spraying IP] -- Login Failure Account A --> D{ Entra ID }\n\nA[Password Spraying IP] -- Login Failure Account B --> D{ Entra ID }\n\nA[Password Spraying IP] -- Login Failure Account C --> D{ Entra ID }\n\nA[Password Spraying IP] -- Login Failure Account D --> D{ Entra ID }\n\nA[Password Spraying IP] -- Login Failure Account E --> D{ Entra ID }\n\nA[Password Spraying IP] -- Login Failure Account E --> D{ Entra ID }\n\nA[Password Spraying IP] == Login Attempt Account E ==> D{ Entra ID }\n\nD{ Entra ID } == \u00a0Entra ID Returns Code {{error.code}} ==> A\n\nclassDef orange fill:#f96,stroke:#333,stroke-width:2px\n\nclassDef blue fill:#32bedd,stroke:#333,stroke-width:2px\n\nclass D blue\n\nclass A orange\n\n```\n\n<style>\n\n/* Actions */\n\n.actions_list li {\n\ndisplay:inline!important;\n\nmargin-right: 45px!important;\n\nmargin-bottom: 15px!important;\n\n}\n\n.actions_list {\n\nlist-style: none!important;\n\nmargin: 0px!important;\n\npadding: 0px!important;\n\n}\n\n.actions_list img {\n\nheight:20px!important;\n\nmargin-bottom:-7px!important;\n\npadding-right:5px!important;\n\n}\n\n/* Tables */\n\n.MuiPaper-root.MuiPaper-elevation.MuiPaper-rounded.MuiPaper-elevation1.MuiTableContainer-root {\n\nbox-shadow:unset !important;\n\nwidth: fit-content !important;\n\n}\n\n/* General */\n\nh3 {\n\nborder-bottom: 2px solid #2d7dc9;\n\n/*border-bottom: 5px solid rgba(255, 255, 255, 0.12);*/\n\n/*color: #393939;*/\n\n}\n\n/* Visualization */\n\n.mermaid {\n\nbackground-color:#fff;\n\npadding:15px;\n\ntext-align: center;\n\n}\n\n/* Boites */\n\n.actor.actor-top {\n\nstroke: #393939 !important;\n\nfill: lightgrey !important;\n\n}\n\n/* T\u00eate du bonhomme */\n\n.actor-man circle {\n\nstroke: #393939 !important;\n\nfill: #28A745 !important;\n\n}\n\n/* Corps du bonhomme */\n\n.actor-man line {\n\nstroke: #393939 !important;\n\nfill: #393939 !important;\n\n}\n\n/* Lignes verticales */\n\n.actor-line {\n\nstroke: #393939 !important;\n\nfill: #393939 !important;\n\n}\n\n/* Description des fl\u00e8ches horizontales */\n\n.messageText {\n\nfill: #393939 !important;\n\nstroke: none;\n\n}\n\n/* T\u00eates de fl\u00e8ches */\n\nmarker#arrowhead path {\n\nfill: #DC3545 !important;\n\n}\n\n/* Texte en-dessous du bonhomme */\n\ntext.actor > tspan {\n\nfill: #393939 !important;\n\n}\n\n/* Fl\u00e8ches horizontales */\n\n.messageLine0, .messageLine1 {\n\nstroke-width: 1.5;\n\nstroke-dasharray: none;\n\nstroke: #393939 !important;\n\n}\n\n/* Texte dans boites */\n\n.actor-box {\n\nfont-weight: bold !important;\n\ncolor: #393939 !important;\n\n}\n\n/* Cacher le bonhomme du bas */\n\n.actor-bottom {\n\ndisplay:None;\n\n}\n\n</style>\n"
@@ -6,7 +6,7 @@ import useMyApi from '@cccsaurora/howler-ui/components/hooks/useMyApi';
6
6
  import { get, last, omit, set } from 'lodash-es';
7
7
  import { useEffect, useMemo, useState } from 'react';
8
8
  import { Link, useLocation } from 'react-router-dom';
9
- import { ESCALATION_COLORS, STATUS_COLORS } from '@cccsaurora/howler-ui/utils/constants';
9
+ import { ESCALATION_COLORS } from '@cccsaurora/howler-ui/utils/constants';
10
10
  const buildTree = (items = []) => {
11
11
  // Root tree node stores direct children in `leaves` and nested folders as object keys.
12
12
  const tree = { leaves: [] };
@@ -44,34 +44,43 @@ const CaseFolder = ({ case: _case, folder, name, step = -1, rootCaseId, pathPref
44
44
  const currentRootCaseId = rootCaseId || _case?.case_id;
45
45
  // Metadata for hit-type items
46
46
  useEffect(() => {
47
- tree.leaves
48
- ?.filter(leaf => leaf.type?.toLowerCase() === 'hit')
49
- .forEach(leaf => {
50
- dispatchApi(api.hit.get(leaf.id), { throwError: false }).then(hit => {
51
- if (!hit)
52
- return;
53
- setHitMetadata(prev => ({
54
- ...prev,
55
- [leaf.id]: {
56
- status: hit.howler?.status,
57
- escalation: hit.howler?.escalation,
58
- assessment: hit.howler?.assessment
59
- }
60
- }));
61
- });
47
+ const ids = tree.leaves?.filter(leaf => leaf.type?.toLowerCase() === 'hit').map(leaf => leaf.id);
48
+ if (!ids || ids.length < 1) {
49
+ return;
50
+ }
51
+ dispatchApi(api.search.hit.post({ query: `howler.id:(${ids.join(' OR ')})` }), { throwError: false }).then(result => {
52
+ if (result?.items?.length < 1) {
53
+ return;
54
+ }
55
+ setHitMetadata(Object.fromEntries(result.items.map(hit => [hit.howler.id, hit.howler])));
62
56
  });
63
57
  }, [tree.leaves, dispatchApi]);
58
+ const getIconColor = (itemType, itemKey, leafId) => {
59
+ if (itemType === 'hit' && leafId) {
60
+ const meta = hitMetadata[leafId];
61
+ if (meta?.escalation && ESCALATION_COLORS[meta.escalation]) {
62
+ return ESCALATION_COLORS[meta.escalation];
63
+ }
64
+ }
65
+ if (itemType === 'case' && itemKey) {
66
+ const caseData = nestedCases[itemKey];
67
+ if (caseData?.escalation && ESCALATION_COLORS[caseData.escalation]) {
68
+ return ESCALATION_COLORS[caseData.escalation];
69
+ }
70
+ }
71
+ return 'default';
72
+ };
64
73
  const getItemColor = (itemType, itemKey, leafId) => {
65
74
  if (itemType === 'hit' && leafId) {
66
75
  const meta = hitMetadata[leafId];
67
- if (meta?.status && STATUS_COLORS[meta.status]) {
68
- return `${STATUS_COLORS[meta.status]}.main`;
76
+ if (meta?.escalation && ESCALATION_COLORS[meta.escalation]) {
77
+ return `${ESCALATION_COLORS[meta.escalation]}.light`;
69
78
  }
70
79
  }
71
80
  if (itemType === 'case' && itemKey) {
72
81
  const caseData = nestedCases[itemKey];
73
82
  if (caseData?.escalation && ESCALATION_COLORS[caseData.escalation]) {
74
- return `${ESCALATION_COLORS[caseData.escalation]}.main`;
83
+ return `${ESCALATION_COLORS[caseData.escalation]}.light`;
75
84
  }
76
85
  }
77
86
  return 'text.secondary';
@@ -123,22 +132,23 @@ const CaseFolder = ({ case: _case, folder, name, step = -1, rootCaseId, pathPref
123
132
  const itemPath = fullRelativePath
124
133
  ? `/cases/${currentRootCaseId}/${fullRelativePath}`
125
134
  : `/cases/${currentRootCaseId}`;
126
- const getIconForType = (type) => {
127
- switch (type) {
135
+ const getIconForType = () => {
136
+ const iconColor = getIconColor(itemType, itemKey, leaf.id);
137
+ switch (itemType) {
128
138
  case 'case':
129
- return _jsx(BookRounded, { fontSize: "small" });
139
+ return _jsx(BookRounded, { fontSize: "small", color: iconColor });
130
140
  case 'observable':
131
- return _jsx(Visibility, { fontSize: "small" });
141
+ return _jsx(Visibility, { fontSize: "small", color: iconColor });
132
142
  case 'hit':
133
- return _jsx(CheckCircle, { fontSize: "small" });
143
+ return _jsx(CheckCircle, { fontSize: "small", color: iconColor });
134
144
  case 'table':
135
- return _jsx(TableChart, { fontSize: "small" });
145
+ return _jsx(TableChart, { fontSize: "small", color: iconColor });
136
146
  case 'lead':
137
- return _jsx(Lightbulb, { fontSize: "small" });
147
+ return _jsx(Lightbulb, { fontSize: "small", color: iconColor });
138
148
  case 'reference':
139
- return _jsx(LinkIcon, { fontSize: "small" });
149
+ return _jsx(LinkIcon, { fontSize: "small", color: iconColor });
140
150
  default:
141
- return _jsx(Article, { fontSize: "small" });
151
+ return _jsx(Article, { fontSize: "small", color: iconColor });
142
152
  }
143
153
  };
144
154
  const leafColor = getItemColor(itemType, itemKey, leaf.id);
@@ -163,7 +173,7 @@ const CaseFolder = ({ case: _case, folder, name, step = -1, rootCaseId, pathPref
163
173
  transition: theme.transitions.create('transform', { duration: 100 }),
164
174
  transform: isCaseOpen ? 'rotate(90deg)' : 'rotate(0deg)'
165
175
  }
166
- ] }), getIconForType(itemType), _jsx(Typography, { variant: "caption", color: leafColor, sx: { userSelect: 'none', pl: 0.5, textWrap: 'nowrap' }, children: last(leaf.path?.split('/') || []) })] }), isCase && isCaseOpen && isCaseLoading && (_jsx(Stack, { pl: step * 1.5 + 4, py: 0.25, children: _jsx(Skeleton, { width: 140, height: 16 }) })), isCase && isCaseOpen && nestedCase && (_jsx(CaseFolder, { case: nestedCase, step: step + 1, rootCaseId: currentRootCaseId, pathPrefix: fullRelativePath }))] }, `${_case?.case_id}-${leaf.id}-${leaf.path}`));
176
+ ] }), getIconForType(), _jsx(Typography, { variant: "caption", color: leafColor, sx: { userSelect: 'none', pl: 0.5, textWrap: 'nowrap' }, children: last(leaf.path?.split('/') || []) })] }), isCase && isCaseOpen && isCaseLoading && (_jsx(Stack, { pl: step * 1.5 + 4, py: 0.25, children: _jsx(Skeleton, { width: 140, height: 16 }) })), isCase && isCaseOpen && nestedCase && (_jsx(CaseFolder, { case: nestedCase, step: step + 1, rootCaseId: currentRootCaseId, pathPrefix: fullRelativePath }))] }, `${_case?.case_id}-${leaf.id}-${leaf.path}`));
167
177
  })] }))] }));
168
178
  };
169
179
  export default CaseFolder;
@@ -0,0 +1,7 @@
1
+ import type { Case } from '@cccsaurora/howler-ui/models/entities/generated/Case';
2
+ import { type FC } from 'react';
3
+ declare const ResolveModal: FC<{
4
+ case: Case;
5
+ onConfirm: () => void;
6
+ }>;
7
+ export default ResolveModal;
@@ -0,0 +1,56 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { OpenInNew } from '@mui/icons-material';
3
+ import { Autocomplete, Box, Button, Card, Chip, Divider, IconButton, LinearProgress, Skeleton, Stack, TextField, Typography } from '@mui/material';
4
+ import api from '@cccsaurora/howler-ui/api';
5
+ import { ApiConfigContext } from '@cccsaurora/howler-ui/components/app/providers/ApiConfigProvider';
6
+ import { ModalContext } from '@cccsaurora/howler-ui/components/app/providers/ModalProvider';
7
+ import AnalyticLink from '@cccsaurora/howler-ui/components/elements/hit/elements/AnalyticLink';
8
+ import EscalationChip from '@cccsaurora/howler-ui/components/elements/hit/elements/EscalationChip';
9
+ import { HitLayout } from '@cccsaurora/howler-ui/components/elements/hit/HitLayout';
10
+ import useHitActions from '@cccsaurora/howler-ui/components/hooks/useHitActions';
11
+ import useMyApi from '@cccsaurora/howler-ui/components/hooks/useMyApi';
12
+ import { uniq } from 'lodash-es';
13
+ import { useContext, useEffect, useMemo, useState } from 'react';
14
+ import { useTranslation } from 'react-i18next';
15
+ import { Link } from 'react-router-dom';
16
+ import useCase from '../hooks/useCase';
17
+ const ResolveModal = ({ case: _case, onConfirm }) => {
18
+ const { t } = useTranslation();
19
+ const { dispatchApi } = useMyApi();
20
+ const { close } = useContext(ModalContext);
21
+ const { config } = useContext(ApiConfigContext);
22
+ const { updateCase } = useCase({ case: _case });
23
+ const [loading, setLoading] = useState(true);
24
+ const [rationale, setRationale] = useState('');
25
+ const [assessment, setAssessment] = useState(null);
26
+ const [hits, setHits] = useState([]);
27
+ const hitIds = useMemo(() => uniq((_case?.items ?? []).filter(item => item.type === 'hit').map(item => item.id)), [_case?.items]);
28
+ const { assess } = useHitActions(hits);
29
+ useEffect(() => {
30
+ (async () => {
31
+ try {
32
+ const result = await dispatchApi(api.search.hit.post({ query: `howler.id:(${hitIds.join(' OR ')}) AND -howler.status:resolved` }));
33
+ setHits(result.items);
34
+ }
35
+ finally {
36
+ setLoading(false);
37
+ }
38
+ })();
39
+ }, [dispatchApi, hitIds]);
40
+ const handleConfirm = async () => {
41
+ setLoading(true);
42
+ try {
43
+ await assess(assessment, true, rationale);
44
+ await updateCase({ status: 'resolved' });
45
+ onConfirm();
46
+ close();
47
+ }
48
+ finally {
49
+ setLoading(false);
50
+ }
51
+ };
52
+ return (_jsxs(Stack, { spacing: 2, p: 2, alignItems: "start", sx: { minWidth: 'min(1000px, 60vw)', maxHeight: '100%', height: '100%' }, children: [_jsx(Typography, { variant: "h4", children: t('modal.cases.resolve') }), _jsx(Typography, { children: t('modal.cases.resolve.description') }), _jsxs(Stack, { spacing: 1, overflow: "auto", width: "100%", flex: 1, children: [_jsxs(Stack, { direction: "row", spacing: 1, children: [_jsx(Box, { flex: 1, children: _jsx(TextField, { size: "small", fullWidth: true, placeholder: t('modal.rationale.label'), value: rationale, onChange: ev => setRationale(ev.target.value) }) }), _jsx(Box, { flex: 1, children: _jsx(Autocomplete, { size: "small", value: assessment, onChange: (_ev, _assessment) => setAssessment(_assessment), options: config.lookups['howler.assessment'], disablePortal: true, renderInput: params => (_jsx(TextField, { ...params, placeholder: t('hit.details.actions.assessment'), fullWidth: true })) }) })] }), _jsxs(Stack, { position: "relative", children: [_jsx(Divider, {}), _jsx(LinearProgress, { sx: { opacity: +loading } })] }), loading
53
+ ? hitIds.map(id => _jsx(Skeleton, { variant: "rounded", height: "40px", width: "100%" }, id))
54
+ : hits.map(hit => (_jsx(Card, { sx: { p: 1, flexShrink: 0 }, children: _jsxs(Stack, { direction: "row", alignItems: "center", spacing: 1, width: "100%", children: [_jsx(AnalyticLink, { hit: hit, compressed: true, alignSelf: "center" }), _jsx(EscalationChip, { hit: hit, layout: HitLayout.DENSE }), _jsx(Chip, { sx: { width: 'fit-content', display: 'inline-flex' }, label: hit.howler.status, size: "small", color: "primary" }), _jsx("div", { style: { flex: 1 } }), _jsx(IconButton, { size: "small", component: Link, to: `/hits/${hit.howler.id}`, children: _jsx(OpenInNew, { fontSize: "small" }) })] }) }, hit.howler.id)))] }), _jsxs(Stack, { direction: "row", spacing: 1, alignSelf: "end", children: [_jsx(Button, { variant: "outlined", color: "error", onClick: close, children: t('cancel') }), _jsx(Button, { variant: "outlined", color: "success", disabled: loading || !assessment || !rationale, onClick: handleConfirm, children: t('confirm') })] })] }));
55
+ };
56
+ export default ResolveModal;
@@ -292,6 +292,8 @@
292
292
  "modal.action.empty": "Action Name cannot be empty.",
293
293
  "modal.action.label": "Action Name",
294
294
  "modal.action.title": "Save Action",
295
+ "modal.cases.resolve": "Resolve Case",
296
+ "modal.cases.resolve.description": "When resolving a case, you must either assess all open alerts, or add an assessment to the alerts.",
295
297
  "modal.confirm.delete.description": "Are you sure you want to delete this item?",
296
298
  "modal.confirm.delete.title": "Confirm Deletion",
297
299
  "modal.rationale.description": "Provide a rationale that succinctly explains to other analysts why you are confident in this assessment.",
package/package.json CHANGED
@@ -101,7 +101,7 @@
101
101
  "internal-slot": "1.0.7"
102
102
  },
103
103
  "type": "module",
104
- "version": "2.17.0-dev.521",
104
+ "version": "2.17.0-dev.523",
105
105
  "exports": {
106
106
  "./i18n": "./i18n.js",
107
107
  "./index.css": "./index.css",
@@ -188,6 +188,7 @@
188
188
  "./components/routes/help/markdown/fr/*.md": "./components/routes/help/markdown/fr/*.md.js",
189
189
  "./components/routes/help/markdown/en/*.md": "./components/routes/help/markdown/en/*.md.js",
190
190
  "./components/routes/admin/users/*": "./components/routes/admin/users/*.js",
191
+ "./components/routes/cases/modals/*": "./components/routes/cases/modals/*.js",
191
192
  "./components/routes/cases/hooks/*": "./components/routes/cases/hooks/*.js",
192
193
  "./components/routes/cases/detail/*": "./components/routes/cases/detail/*.js",
193
194
  "./components/routes/cases/detail/sidebar/*": "./components/routes/cases/detail/sidebar/*.js",
@@ -5,9 +5,9 @@ export declare const VERSION: any;
5
5
  export declare const MY_LOCAL_STORAGE_PREFIX = "howler.ui";
6
6
  export declare const MY_SESSION_STORAGE_PREFIX = "howler.ui.cache";
7
7
  export declare const ESCALATION_COLORS: {
8
- alert: string;
9
- evidence: string;
10
- hit: string;
8
+ alert: "warning";
9
+ evidence: "error";
10
+ hit: "primary";
11
11
  };
12
12
  export declare const STATUS_COLORS: {
13
13
  open: string;