diff --git a/backend/src/graphql/resolvers/threatActorResolver.ts b/backend/src/graphql/resolvers/threatActorResolver.ts index 88221fa..3286484 100644 --- a/backend/src/graphql/resolvers/threatActorResolver.ts +++ b/backend/src/graphql/resolvers/threatActorResolver.ts @@ -10,22 +10,14 @@ * * GraphQL Defs -> Resolver -> Repository -> Drizzle -> MySQL */ - - - -/** - * Resolvere for GraphQL-operasjoner relatert til ThreatActors. - * - * Resolveren mottar GraphQL-spørringer og sender arbeidet - * videre til threatActorRepository. - * - * GraphQL -> Resolver -> Repository -> Drizzle -> MySQL - */ - -import {getAllThreatActorsGrid, getThreatActorByMitreId, getThreatActorSoftwares, getThreatActorTechniques} from "../../repositories/threatActorRepository.js"; +import {getAllThreatActorsGrid, getThreatActorByMitreId, getThreatActorFilterOptions, getThreatActorSoftwares, getThreatActorTechniques} from "../../repositories/threatActorRepository.js"; export const threatActorResolvers = { Query: { + /** Henter ut alle filterOptions */ + threatActorFilterOptions: async (_parent: unknown) => { + return getThreatActorFilterOptions(); + }, /** * Henter alle ThreatActors for Grid i front end. */ @@ -48,5 +40,7 @@ export const threatActorResolvers = { getThreatActorSoftwares: async (_parent: unknown, args: {mitreId: string; limit: number; offset: number}) => { return getThreatActorSoftwares(args.mitreId, args.limit, args.offset); }, + + }, }; \ No newline at end of file diff --git a/backend/src/graphql/typeDefs.ts b/backend/src/graphql/typeDefs.ts index 0cc47e5..a55b390 100644 --- a/backend/src/graphql/typeDefs.ts +++ b/backend/src/graphql/typeDefs.ts @@ -2,6 +2,19 @@ * GraphQL schema: defines the types and operations available to the frontend. */ export const typeDefs = `#graphql + type ThreatActorFilterOption { + value: String! + label: String! + count: Int! + } + type ThreatActorFilterOptions { + countries: [ThreatActorFilterOption!]! + regions: [ThreatActorFilterOption!]! + motivations: [ThreatActorFilterOption!]! + sectors: [ThreatActorFilterOption!]! + statuses: [ThreatActorFilterOption!]! + danger_levels: [ThreatActorFilterOption!]! + } type ThreatActorGrid { name: String! mitreId: String! @@ -114,6 +127,7 @@ export const typeDefs = `#graphql getThreatActorByMitreId(mitreId: String!): ThreatActorDetailed getThreatActorTechniques(mitreId: String!, limit: Int!, offset: Int!): AssociatedItemsPage! getThreatActorSoftwares(mitreId: String!, limit: Int!, offset: Int!): AssociatedItemsPage! + threatActorFilterOptions: ThreatActorFilterOptions! ######################################## Techinques vvvv diff --git a/backend/src/repositories/threatActorRepository.ts b/backend/src/repositories/threatActorRepository.ts index d424ad5..06c0642 100644 --- a/backend/src/repositories/threatActorRepository.ts +++ b/backend/src/repositories/threatActorRepository.ts @@ -156,7 +156,6 @@ export async function getThreatActorTechniques(mitreId: string, limit: number, o }; } - export async function getThreatActorSoftwares(mitreId: string, limit: number, offset: number) { const [softwares, countResult] = await Promise.all([ @@ -184,4 +183,82 @@ export async function getThreatActorSoftwares(mitreId: string, limit: number, of items: softwares, totalCount: countResult[0]?.totalCount ?? 0, }; +} + +// Henter ut alle filteroptions for filtreringen i ThreatActor +export async function getThreatActorFilterOptions() { + /** Henter ut land options */ + const countryFilterOptions = db + .select({country: sql`COALESCE(${threatActor.country}, 'Unknown')`, totalCount: count()}) + .from(threatActor) + .groupBy(threatActor.country); + /** Henter ut region options */ + const regionFilterOptions = db + .select({region: sql`COALESCE(${threatActor.region}, 'Unknown')`, totalCount: count()}) + .from(threatActor) + .groupBy(threatActor.region); + /** Henter ut motivasjon options */ + const motivationFilterOptions = db + .select({motivation: sql`COALESCE(${threatActor.motivation}, 'Unknown')`, totalCount: count()}) + .from(threatActor) + .groupBy(threatActor.motivation); + /** Henter ut sector options */ + const sectorFilterOptions = db + .select({sector: threatActorSector.sector, totalCount: count()}) + .from(threatActorSector) + .groupBy(threatActorSector.sector); + /** Henter ut status options*/ + const statusFilterOptions = db + .select({status: threatActor.status, totalCount: count()}) + .from(threatActor) + .groupBy(threatActor.status); + /** Henter ut trusselnivå options*/ + const dangerFilterOptions = db + .select({danger: threatActor.danger_level, totalCount: count()}) + .from(threatActor) + .groupBy(threatActor.danger_level); + //**Kjører queires parallelt, og venter til alle queries er fullført */ + const [countries, regions, motivations, sectors, statuses, danger_levels] = await Promise.all([ + countryFilterOptions, + regionFilterOptions, + motivationFilterOptions, + sectorFilterOptions, + statusFilterOptions, + dangerFilterOptions, + ]) + + //Mapper det til objekter vi enkelt kan bruke i front + return { + countries: countries.map((row) => ({ + value: row.country, + label: row.country, + count: row.totalCount + })), + regions: regions.map((row) => ({ + value: row.region, + label: row.region, + count: row.totalCount + })), + motivations: motivations.map((row) => ({ + value: row.motivation, + label: row.motivation, + count: row.totalCount + })), + sectors: sectors.map((row) => ({ + value: row.sector, + label: row.sector, + count: row.totalCount + })), + //Lager en Label for ACTIVE = A + ctive = Active + statuses: statuses.map((row) => ({ + value: row.status, + label: row.status.charAt(0).toUpperCase() + row.status.slice(1).toLowerCase(), + count: row.totalCount + })), + danger_levels: danger_levels.map((row) => ({ + value: row.danger, + label: row.danger.charAt(0).toUpperCase() + row.danger.slice(1).toLowerCase(), + count: row.totalCount + })), + }; } \ No newline at end of file diff --git a/frontend/src/components/threatactor/ThreatActorMain/ThreatActorFilter/ThreatActorFilter.css b/frontend/src/components/threatactor/ThreatActorMain/ThreatActorFilter/ThreatActorFilter.css index 2ae09d3..1db74b1 100644 --- a/frontend/src/components/threatactor/ThreatActorMain/ThreatActorFilter/ThreatActorFilter.css +++ b/frontend/src/components/threatactor/ThreatActorMain/ThreatActorFilter/ThreatActorFilter.css @@ -1,3 +1,4 @@ + .threat-actor-filters { display: flex; flex-direction: column; @@ -6,86 +7,80 @@ .filters-title { color: var(--text-heading); - font-size: 14px; font-weight: 600; } +/* Fire kolonner på desktop */ .filters-content { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); - gap: 12px; + align-items: stretch; } -.filter-field { +/* Apply Filters fyller sin grid-celle */ +.filters-actions { + display: flex; + width: 100%; min-width: 0; - min-height: 56px; +} +.apply-filters-button { display: flex; align-items: center; - gap: 12px; + justify-content: center; + gap: 8px; + + width: 100%; + min-height: 52px; + padding: 10px 12px; - padding: 8px 14px; + background: var(--accent, #00ded4); + color: #001b1d; - background: var(--surface); + border: 1px solid var(--accent, #00ded4); + border-radius: 7px; - border: 1px solid var(--border); - border-radius: 8px; + font-family: inherit; + font-size: 12px; + font-weight: 600; + cursor: pointer; transition: - border-color 0.2s ease, - background-color 0.2s ease; + background-color 0.2s ease, + border-color 0.2s ease; } -.filter-field:hover { - border-color: #0b5558; - background: var(--surface-hover); +.apply-filters-button:hover { + background: #00bdb5; + border-color: #00bdb5; } -.filter-field:focus-within { - border-color: var(--accent); +.apply-filters-button:active { + background: #009f99; } -.filter-field > svg { - width: 21px; - height: 21px; - - flex-shrink: 0; - - color: var(--text-muted); +.apply-filters-button:focus-visible { + outline: 2px solid var(--text-heading, #ffffff); + outline-offset: 3px; } -.filter-field:focus-within > svg { - color: var(--accent); +.apply-filters-button:disabled { + opacity: 0.5; + cursor: not-allowed; } -.filter-text { - width: 100%; - min-width: 0; - - display: flex; - flex-direction: column; - gap: 2px; +/* Nettbrett */ +@media (max-width: 900px) { + .filters-content { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } } -.filter-text > span { - color: var(--text-heading); - - font-size: 12px; - font-weight: 600; +/* Mobil */ +@media (max-width: 520px) { + .filters-content { + grid-template-columns: minmax(0, 1fr); + } } - -.filter-text select { - width: 100%; - - border: 0; - outline: 0; - - background: transparent; - color: var(--text-muted); - - font-size: 12px; - - cursor: pointer; -} \ No newline at end of file diff --git a/frontend/src/components/threatactor/ThreatActorMain/ThreatActorFilter/ThreatActorFilter.tsx b/frontend/src/components/threatactor/ThreatActorMain/ThreatActorFilter/ThreatActorFilter.tsx index 9aa7c5c..daf171f 100644 --- a/frontend/src/components/threatactor/ThreatActorMain/ThreatActorFilter/ThreatActorFilter.tsx +++ b/frontend/src/components/threatactor/ThreatActorMain/ThreatActorFilter/ThreatActorFilter.tsx @@ -5,102 +5,153 @@ import { Globe2, MapPin, Target, + ArrowDownUp, } from "lucide-react"; - +import { useState } from "react"; +import FilterDropdown from "./ThreatActorFilterDropdown"; +import type { FilterOption } from "./ThreatActorFilterDropdown"; import "./ThreatActorFilter.css"; +import type { ThreatActorFilterOptionsData } from "../../../../types/ThreatActorTypes"; + +const sortOptions: FilterOption[] = [ + { value: "name-asc", label: "Name (A - Z)" }, + { value: "name-desc", label: "Name (Z - A)" }, + { value: "mitre-asc", label: "MITRE ID (Ascending)" }, + { value: "mitre-desc", label: "MITRE ID (Descending)" }, +]; + +export type ThreatActorFilterValues = { + country: string; + region: string; + motivation: string; + status: string; + dangerLevel: string; + sector: string; + sortBy: string; +}; + +type ThreatActorFiltersProps = { + filterOptions?: ThreatActorFilterOptionsData["threatActorFilterOptions"]; + onApply: (filters: ThreatActorFilterValues) => void; +}; + +function ThreatActorFilters({ onApply, filterOptions }: ThreatActorFiltersProps) { + const [openFilter, setOpenFilter] = useState(null); + + + const [country, setCountry] = useState(""); + const [region, setRegion] = useState(""); + const [motivation, setMotivation] = useState(""); + const [status, setStatus] = useState(""); + const [dangerLevel, setDangerLevel] = useState(""); + const [sector, setSector] = useState(""); + const [sortBy, setSortBy] = useState("name-asc"); + + + const toggleFilter = (filter: string) => { + setOpenFilter((previous) => + previous === filter ? null : filter + ); + }; + + const handleApplyFilters = () => { + setOpenFilter(null); + + onApply({country,region,motivation,status,dangerLevel,sector,sortBy,}); +}; -function ThreatActorFilters() { return (

Filters

- - - - - - - - - - - - - + } + options={filterOptions?.countries ?? []} + value={country} + onChange={setCountry} + isOpen={openFilter === "Country"} + onToggle={() => toggleFilter("Country")} + /> + + } + options={filterOptions?.regions ?? []} + value={region} + onChange={setRegion} + isOpen={openFilter === "Region"} + onToggle={() => toggleFilter("Region")} + /> + + } + options={filterOptions?.motivations ?? []} + value={motivation} + onChange={setMotivation} + isOpen={openFilter === "Motivation"} + onToggle={() => toggleFilter("Motivation")} + /> + + } + placeholder="All sectors" + options={filterOptions?.sectors ?? []} + value={sector} + onChange={setSector} + isOpen={openFilter === "Sector"} + onToggle={() => toggleFilter("Sector")} + /> + + } + options={filterOptions?.statuses ?? []} + value={status} + onChange={setStatus} + isOpen={openFilter === "Status"} + onToggle={() => toggleFilter("Status")} + /> + + } + options={filterOptions?.danger_levels ?? []} + value={dangerLevel} + onChange={setDangerLevel} + isOpen={openFilter === "Danger Level"} + onToggle={() => toggleFilter("Danger Level")} + /> + + } + options={sortOptions} + value={sortBy} + onChange={setSortBy} + isOpen={openFilter === "Sort by"} + onToggle={() => toggleFilter("Sort by")} + showAllOption={false} + /> + +
+ +
); diff --git a/frontend/src/components/threatactor/ThreatActorMain/ThreatActorFilter/ThreatActorFilterDropdown.css b/frontend/src/components/threatactor/ThreatActorMain/ThreatActorFilter/ThreatActorFilterDropdown.css new file mode 100644 index 0000000..4c882f3 --- /dev/null +++ b/frontend/src/components/threatactor/ThreatActorMain/ThreatActorFilter/ThreatActorFilterDropdown.css @@ -0,0 +1,209 @@ + +.filter-dropdown { + position: relative; + min-width: 0; + width: 100%; +} + +/* Selve filterboksen */ +.filter-dropdown-trigger { + display: flex; + align-items: center; + gap: 12px; + + width: 100%; + min-height: 52px; + padding: 10px 12px; + + background: var(--surface, #002629); + border: 1px solid var(--border, #0b393d); + border-radius: 7px; + + color: var(--text-heading, #e9f4f4); + font-family: inherit; + cursor: pointer; + text-align: left; + + transition: + border-color 0.2s ease, + background-color 0.2s ease; +} + +.filter-dropdown-trigger:hover { + background: var(--surface-hover, #083438); + border-color: #0b5558; +} + +.filter-dropdown-trigger[aria-expanded="true"] { + border-color: var(--accent, #00ded4); +} + +/* Ikon */ +.filter-dropdown-trigger > svg { + width: 21px; + height: 21px; + flex-shrink: 0; + color: var(--text-muted, #94a9ab); +} + +.filter-dropdown-trigger[aria-expanded="true"] > svg { + color: var(--accent, #00ded4); +} + +/* Label og valgt alternativ */ +.filter-dropdown-text { + display: flex; + flex: 1; + min-width: 0; + flex-direction: column; + gap: 3px; +} + +.filter-dropdown-text strong { + color: var(--text-heading, #e9f4f4); + font-size: 12px; + font-weight: 600; +} + +.filter-dropdown-text > span { + color: var(--text-muted, #94a9ab); + font-size: 12px; + + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +/* Dropdown-menyen */ +.filter-dropdown-menu { + position: absolute; + top: calc(100% + 6px); + left: 0; + z-index: 100; + + width: max(100%, 240px); + max-width: min(320px, 90vw); + max-height: 280px; + + overflow-y: auto; + overscroll-behavior: contain; + + padding: 8px; + + background: #082b2e; + border: 1px solid #31565a; + border-radius: 10px; + + box-shadow: 0 10px 25px rgba(0, 0, 0, 0.35); + + scrollbar-width: thin; + scrollbar-color: #28585b transparent; +} + +/* Scrollbar i Chrome, Safari og Edge */ +.filter-dropdown-menu::-webkit-scrollbar { + width: 6px; +} + +.filter-dropdown-menu::-webkit-scrollbar-track { + background: transparent; +} + +.filter-dropdown-menu::-webkit-scrollbar-thumb { + background: #28585b; + border-radius: 10px; +} + +.filter-dropdown-menu::-webkit-scrollbar-thumb:hover { + background: #00ded4; +} + +/* Alternativer */ +.filter-dropdown-option { + display: flex; + align-items: center; + gap: 10px; + + min-height: 36px; + padding: 9px 8px; + + border-radius: 5px; + color: #e4eeee; + font-size: 13px; + + cursor: pointer; + transition: background-color 0.15s ease; +} + +.filter-dropdown-option:hover { + background: #124348; +} + +/* Markering av valgt alternativ */ +.filter-dropdown-option:has(input:checked) { + background: #104347; +} + +/* Radio-knapper med firkantet utseende */ +.filter-dropdown-option input[type="radio"] { + appearance: none; + -webkit-appearance: none; + + width: 15px; + height: 15px; + flex-shrink: 0; + + margin: 0; + border: 1px solid #8da3a5; + border-radius: 3px; + background: #ffffff; + + cursor: pointer; + position: relative; +} + +.filter-dropdown-option input[type="radio"]:checked { + background: var(--accent, #00ded4); + border-color: var(--accent, #00ded4); +} + +.filter-dropdown-option input[type="radio"]:checked::after { + content: ""; + position: absolute; + + width: 7px; + height: 4px; + + top: 3px; + left: 3px; + + border-left: 2px solid #001b1d; + border-bottom: 2px solid #001b1d; + + transform: rotate(-45deg); +} + +/* Treffantall */ +.filter-dropdown-count { + margin-left: auto; + padding-left: 10px; + + color: #a3b8bb; + font-size: 12px; + font-variant-numeric: tabular-nums; +} + +/* Tastaturnavigasjon */ +.filter-dropdown-trigger:focus-visible, +.filter-dropdown-option:focus-within { + outline: 2px solid var(--accent, #00ded4); + outline-offset: 2px; +} + +/* Reduser animasjon ved brukerpreferanse */ +@media (prefers-reduced-motion: reduce) { + .filter-dropdown-trigger, + .filter-dropdown-option { + transition: none; + } +} diff --git a/frontend/src/components/threatactor/ThreatActorMain/ThreatActorFilter/ThreatActorFilterDropdown.tsx b/frontend/src/components/threatactor/ThreatActorMain/ThreatActorFilter/ThreatActorFilterDropdown.tsx new file mode 100644 index 0000000..884840f --- /dev/null +++ b/frontend/src/components/threatactor/ThreatActorMain/ThreatActorFilter/ThreatActorFilterDropdown.tsx @@ -0,0 +1,95 @@ +import type { ReactNode } from "react"; +import "./ThreatActorFilterDropdown.css"; + +export type FilterOption = { + value: string; + label: string; + count?: number; +}; + +type FilterDropdownProps = { + label: string; + placeholder: string; + icon?: ReactNode; + options: FilterOption[]; + value: string; + onChange: (value: string) => void; + isOpen: boolean; + onToggle: () => void; + showAllOption?: boolean; +}; + +export default function FilterDropdown({label, placeholder,icon,options,value,onChange,isOpen,onToggle,showAllOption = true}: FilterDropdownProps) { + // Sorterer filteralternativer etter antall treff. + // Alternativer uten count (f.eks. Sort by) beholder sin opprinnelige rekkefølge. + const sortedOptions = [...options].sort((a, b) => { + if (a.count === undefined || b.count === undefined) { + return 0; + } + + return b.count - a.count || a.label.localeCompare(b.label); + }); + + // Viser valgt alternativ i dropdown-knappen. + const selectedLabel =options.find((option) => option.value === value)?.label ?? placeholder; + return ( +
+ + + {isOpen && ( +
+ {/* Vises for filtre, men ikke for Sort by */} + {showAllOption && ( + + )} + + {sortedOptions.map((option) => ( + + ))} +
+ )} +
+ ); +} \ No newline at end of file diff --git a/frontend/src/graphql/queries/threatActorQueries.ts b/frontend/src/graphql/queries/threatActorQueries.ts index 1d4859b..769c730 100644 --- a/frontend/src/graphql/queries/threatActorQueries.ts +++ b/frontend/src/graphql/queries/threatActorQueries.ts @@ -1,5 +1,5 @@ import { gql, type TypedDocumentNode } from "@apollo/client"; -import type { ThreatActorAssociatedVariables, ThreatActorDetailedData, ThreatActorDetailedVariables, ThreatActorsGridData, ThreatActorsGridVariables, ThreatActorSoftwaresData, ThreatActorTechniquesData } from "../../types/ThreatActorTypes"; +import type { ThreatActorAssociatedVariables, ThreatActorDetailedData, ThreatActorDetailedVariables, ThreatActorFilterOptionsData, ThreatActorsGridData, ThreatActorsGridVariables, ThreatActorSoftwaresData, ThreatActorTechniquesData } from "../../types/ThreatActorTypes"; //Query for ThreatActorGrid; viarbler {limit, offset} @@ -79,4 +79,41 @@ export const GET_THREAT_ACTOR_SOFTWARES: TypedDocumentNode = gql` + query GetThreatActorFilterOptions { + threatActorFilterOptions { + countries { + value + label + count + } + regions { + value + label + count + } + motivations { + value + label + count + } + sectors { + value + label + count + } + statuses { + value + label + count + } + danger_levels { + value + label + count + } + } + } `; \ No newline at end of file diff --git a/frontend/src/pages/ThreatActors/ThreatActors.tsx b/frontend/src/pages/ThreatActors/ThreatActors.tsx index 1c54030..2c95a28 100644 --- a/frontend/src/pages/ThreatActors/ThreatActors.tsx +++ b/frontend/src/pages/ThreatActors/ThreatActors.tsx @@ -4,7 +4,7 @@ import ThreatActorPageHeader from "../../components/threatactor/ThreatActorMain/ import ThreatActorSearch from "../../components/threatactor/ThreatActorMain/ThreatActorSearch/ThreatActorSearch"; import "./ThreatActors.css" import { useQuery } from "@apollo/client/react"; -import { GET_ALL_THREAT_ACTOR_GRID } from "../../graphql/queries/threatActorQueries"; +import { GET_ALL_THREAT_ACTOR_GRID, GET_THREAT_ACTOR_FILTER_OPTIONS } from "../../graphql/queries/threatActorQueries"; import { useState } from "react"; @@ -24,12 +24,18 @@ function ThreatActor(){ const actors = data?.allThreatActorsGrid.items ?? []; const actorsCount = data?.allThreatActorsGrid.totalCount ?? 0; + //Henter ut data for filter + const {data: filterData} = useQuery(GET_THREAT_ACTOR_FILTER_OPTIONS, {fetchPolicy: "cache-first"}) + const filterOptions = filterData?.threatActorFilterOptions; return (
- + console.log("Selected filters:", filters)} + />