From 2febd5bd6c9acac5e9500edfad4870137ac1c96b Mon Sep 17 00:00:00 2001 From: Pedro Ribeiro <47680931+tubarao312@users.noreply.github.com> Date: Tue, 12 Mar 2024 14:51:26 +0000 Subject: [PATCH 1/2] Node Categories (#99) * Add categories to nodes * Update Header.tsx * Add labels to txn listing --- src/api/labels/labels.ts | 125 +++++++++++++++++- src/api/model/getCategory200.ts | 11 ++ src/api/model/getCategoryBody.ts | 11 ++ src/api/model/getCategoryParams.ts | 14 ++ src/api/model/getLabels200.ts | 12 ++ src/api/model/getLabels200Data.ts | 12 ++ src/api/model/index.ts | 5 + src/api/ward-analytics-api.yml | 60 +++++++++ src/components/common/RiskIndicator.tsx | 10 +- .../content/transactions/Transactions.tsx | 24 ++++ .../graph/analysis_window/header/Header.tsx | 40 +++++- .../AddressNode/AddressNode/AddressNode.tsx | 39 +++++- src/utils/categories/classes.tsx | 63 +++++++++ 13 files changed, 407 insertions(+), 19 deletions(-) create mode 100644 src/api/model/getCategory200.ts create mode 100644 src/api/model/getCategoryBody.ts create mode 100644 src/api/model/getCategoryParams.ts create mode 100644 src/api/model/getLabels200.ts create mode 100644 src/api/model/getLabels200Data.ts diff --git a/src/api/labels/labels.ts b/src/api/labels/labels.ts index 21d5b74c..a39aa2ed 100644 --- a/src/api/labels/labels.ts +++ b/src/api/labels/labels.ts @@ -6,12 +6,26 @@ * OpenAPI spec version: 1.0 */ import { - useMutation + useMutation, + useQuery } from 'react-query' import type { MutationFunction, - UseMutationOptions + QueryFunction, + QueryKey, + UseMutationOptions, + UseQueryOptions, + UseQueryResult } from 'react-query' +import type { + GetCategory200 +} from '../model/getCategory200' +import type { + GetCategoryBody +} from '../model/getCategoryBody' +import type { + GetLabels200 +} from '../model/getLabels200' import type { SearchLabels200 } from '../model/searchLabels200' @@ -22,6 +36,64 @@ import { instance } from '.././instance'; +/** + * Get labels for an address + */ +export const getLabels = ( + address: string, + signal?: AbortSignal +) => { + + + return instance( + {url: `/addresses/${address}/labels`, method: 'GET', signal + }, + ); + } + + +export const getGetLabelsQueryKey = (address: string,) => { + return [`/addresses/${address}/labels`] as const; + } + + +export const getGetLabelsQueryOptions = >, TError = unknown>(address: string, options?: { query?:UseQueryOptions>, TError, TData>, } +) => { + +const {query: queryOptions} = options ?? {}; + + const queryKey = queryOptions?.queryKey ?? getGetLabelsQueryKey(address); + + + + const queryFn: QueryFunction>> = ({ signal }) => getLabels(address, signal); + + + + + + return { queryKey, queryFn, enabled: !!(address), ...queryOptions} as UseQueryOptions>, TError, TData> & { queryKey: QueryKey } +} + +export type GetLabelsQueryResult = NonNullable>> +export type GetLabelsQueryError = unknown + +export const useGetLabels = >, TError = unknown>( + address: string, options?: { query?:UseQueryOptions>, TError, TData>, } + + ): UseQueryResult & { queryKey: QueryKey } => { + + const queryOptions = getGetLabelsQueryOptions(address,options) + + const query = useQuery(queryOptions) as UseQueryResult & { queryKey: QueryKey }; + + query.queryKey = queryOptions.queryKey ; + + return query; +} + + + /** * Search labels and returns the labels that match the query limited by the set limit */ @@ -71,4 +143,53 @@ export const getSearchLabelsMutationOptions = { + + + return instance( + {url: `/labels/get-category`, method: 'POST', + headers: {'Content-Type': 'application/json', }, + data: getCategoryBody + }, + ); + } + + + +export const getGetCategoryMutationOptions = (options?: { mutation?:UseMutationOptions>, TError,{data: GetCategoryBody}, TContext>, } +): UseMutationOptions>, TError,{data: GetCategoryBody}, TContext> => { + const {mutation: mutationOptions} = options ?? {}; + + + + + const mutationFn: MutationFunction>, {data: GetCategoryBody}> = (props) => { + const {data} = props ?? {}; + + return getCategory(data,) + } + + + + + return { mutationFn, ...mutationOptions }} + + export type GetCategoryMutationResult = NonNullable>> + export type GetCategoryMutationBody = GetCategoryBody + export type GetCategoryMutationError = unknown + + export const useGetCategory = (options?: { mutation?:UseMutationOptions>, TError,{data: GetCategoryBody}, TContext>, } +) => { + + const mutationOptions = getGetCategoryMutationOptions(options); + + return useMutation(mutationOptions); + } \ No newline at end of file diff --git a/src/api/model/getCategory200.ts b/src/api/model/getCategory200.ts new file mode 100644 index 00000000..42e7f426 --- /dev/null +++ b/src/api/model/getCategory200.ts @@ -0,0 +1,11 @@ +/** + * Generated by orval v6.23.0 🍺 + * Do not edit manually. + * compliance-queries-api + * The ward's compliance queires endpoints + * OpenAPI spec version: 1.0 + */ + +export type GetCategory200 = { + categories?: string[]; +}; diff --git a/src/api/model/getCategoryBody.ts b/src/api/model/getCategoryBody.ts new file mode 100644 index 00000000..1b382af1 --- /dev/null +++ b/src/api/model/getCategoryBody.ts @@ -0,0 +1,11 @@ +/** + * Generated by orval v6.23.0 🍺 + * Do not edit manually. + * compliance-queries-api + * The ward's compliance queires endpoints + * OpenAPI spec version: 1.0 + */ + +export type GetCategoryBody = { + labels?: string[]; +}; diff --git a/src/api/model/getCategoryParams.ts b/src/api/model/getCategoryParams.ts new file mode 100644 index 00000000..ce373034 --- /dev/null +++ b/src/api/model/getCategoryParams.ts @@ -0,0 +1,14 @@ +/** + * Generated by orval v6.23.0 🍺 + * Do not edit manually. + * compliance-queries-api + * The ward's compliance queires endpoints + * OpenAPI spec version: 1.0 + */ + +export type GetCategoryParams = { +/** + * The labels to get categories for + */ +labels: string[]; +}; diff --git a/src/api/model/getLabels200.ts b/src/api/model/getLabels200.ts new file mode 100644 index 00000000..10cb6d0f --- /dev/null +++ b/src/api/model/getLabels200.ts @@ -0,0 +1,12 @@ +/** + * Generated by orval v6.23.0 🍺 + * Do not edit manually. + * compliance-queries-api + * The ward's compliance queires endpoints + * OpenAPI spec version: 1.0 + */ +import type { GetLabels200Data } from './getLabels200Data'; + +export type GetLabels200 = { + data?: GetLabels200Data; +}; diff --git a/src/api/model/getLabels200Data.ts b/src/api/model/getLabels200Data.ts new file mode 100644 index 00000000..ea87db84 --- /dev/null +++ b/src/api/model/getLabels200Data.ts @@ -0,0 +1,12 @@ +/** + * Generated by orval v6.23.0 🍺 + * Do not edit manually. + * compliance-queries-api + * The ward's compliance queires endpoints + * OpenAPI spec version: 1.0 + */ +import type { Label } from './label'; + +export type GetLabels200Data = { + labels?: Label[]; +}; diff --git a/src/api/model/index.ts b/src/api/model/index.ts index dff7fc6d..edf6248e 100644 --- a/src/api/model/index.ts +++ b/src/api/model/index.ts @@ -15,9 +15,14 @@ export * from './category'; export * from './entity'; export * from './errorResponse'; export * from './exposure'; +export * from './getCategory200'; +export * from './getCategoryBody'; +export * from './getCategoryParams'; export * from './getCombinedTransactions200'; export * from './getCombinedTransactionsParams'; export * from './getCombinedTransactionsTransactionType'; +export * from './getLabels200'; +export * from './getLabels200Data'; export * from './getTokenMetadata200'; export * from './getTokenMetadataBody'; export * from './getTransactions200'; diff --git a/src/api/ward-analytics-api.yml b/src/api/ward-analytics-api.yml index f65cc735..d6a7d274 100644 --- a/src/api/ward-analytics-api.yml +++ b/src/api/ward-analytics-api.yml @@ -129,6 +129,34 @@ paths: type: integer page_size: type: integer + /addresses/{address}/labels: + get: + operationId: get-labels + description: Get labels for an address + tags: + - labels + parameters: + - name: address + in: path + description: The address to get labels for + required: true + schema: + type: string + responses: + "200": + description: OK + content: + application/json: + schema: + type: object + properties: + data: + type: object + properties: + labels: + type: array + items: + $ref: "#/components/schemas/Label" /labels/search-labels: post: operationId: search-labels @@ -160,6 +188,38 @@ paths: required: - labels + # Accepts a list of labels and returns a list of categories in {"categories": []} format + /labels/get-category: + post: + operationId: get-category + description: Get category for a list of labels + tags: + - labels + requestBody: + content: + application/json: + schema: + type: object + properties: + labels: + type: array + items: + type: string + responses: + "200": + description: OK + content: + application/json: + schema: + type: object + properties: + categories: + type: array + items: + type: string + required: + - categories + components: schemas: # Address Analysis diff --git a/src/components/common/RiskIndicator.tsx b/src/components/common/RiskIndicator.tsx index 49ea7f09..f15ff3b3 100644 --- a/src/components/common/RiskIndicator.tsx +++ b/src/components/common/RiskIndicator.tsx @@ -30,7 +30,7 @@ const RiskIndicator: FC = ({ return ( = ({
  • - Direct Exposure, both incoming and - outgoing + Direct Exposure, both incoming + and outgoing
  • - Indirect Exposure, both incoming and - outgoing + Indirect Exposure, both + incoming and outgoing
  • diff --git a/src/components/graph/analysis_window/content/transactions/Transactions.tsx b/src/components/graph/analysis_window/content/transactions/Transactions.tsx index 1d9f51be..220a7ea5 100644 --- a/src/components/graph/analysis_window/content/transactions/Transactions.tsx +++ b/src/components/graph/analysis_window/content/transactions/Transactions.tsx @@ -5,6 +5,7 @@ import { BarsArrowDownIcon, CheckIcon, PlusIcon, + TagIcon, } from "@heroicons/react/16/solid"; import { FC, useContext, useEffect, useState } from "react"; import { Output, Transaction } from "../../../../../api/model"; @@ -21,6 +22,7 @@ import { import { GraphContext } from "../../../Graph"; import TokenLogo from "../../../../common/TokenLogo"; +import { useGetLabels } from "../../../../../api/labels/labels"; interface TransactionRowProps { usdValue: number; @@ -50,6 +52,27 @@ const TransactionRow: FC = ({ day: "numeric", }); + const [label, setLabel] = useState(); + const { refetch } = useGetLabels(addresses[0], { + query: { + enabled: false, + refetchOnWindowFocus: false, + retry: true, + cacheTime: 1000, // 1 second + staleTime: 1000, // 1 second + onSuccess: (data) => { + const l = data.data!.labels!; + if (l.length > 0) { + setLabel(l[0].label); + } + }, + }, + }); + + useEffect(() => { + refetch(); + }, [addresses]); + const addressText: string = addresses.length === 1 ? addresses[0].slice(0, 8) + "..." + addresses[0].slice(-8) @@ -76,6 +99,7 @@ const TransactionRow: FC = ({ Icon={incoming ? ArrowDownLeftIcon : ArrowUpRightIcon} text={incoming ? "IN" : "OUT"} /> + {label && }
    diff --git a/src/components/graph/analysis_window/header/Header.tsx b/src/components/graph/analysis_window/header/Header.tsx index 9f1321c9..86f3283b 100644 --- a/src/components/graph/analysis_window/header/Header.tsx +++ b/src/components/graph/analysis_window/header/Header.tsx @@ -5,7 +5,7 @@ import { TrashIcon } from "@heroicons/react/24/outline"; import { XMarkIcon as XMarkSmallIcon } from "@heroicons/react/16/solid"; import clsx from "clsx"; -import { FC, useCallback, useContext } from "react"; +import { FC, useCallback, useContext, useEffect, useState } from "react"; import { addCustomAddressTag, @@ -33,6 +33,8 @@ import { import WithAuth, { WithAuthProps } from "../../../auth/WithAuth"; import useAuthState from "../../../../hooks/useAuthState"; import TagInput from "./TagInput"; +import { getCategory } from "../../../../api/labels/labels"; +import { CategoryClasses } from "../../../../utils/categories"; interface ModeButtonProps { isActive: boolean; @@ -112,6 +114,13 @@ const LabelsAndTags: FC = ({ // Labels get displayed first in the flex-wrap const labels = analysisData!.labels; + const [categories, setCategories] = useState([]); + + useEffect(() => { + getCategory({ labels }).then((res) => { + setCategories(res.categories!); + }); + }, [labels]); // Get user and address already existing tags from Firestore const { tags: userCustomTags } = useCustomUserTags(user ? user.uid : ""); @@ -141,13 +150,21 @@ const LabelsAndTags: FC = ({ // Display everything and user input at the end in a flex-wrap return ( + {categories.map((category) => ( + + ))} {labels.map((label) => ( - + ))} {addressCustomTags.map((tag) => ( { @@ -199,7 +216,10 @@ const Header: FC = ({ const { analysisData, address } = useContext(AnalysisContext); // When minimized, the address hash should be sliced off - const displayedAddress = address.slice(0, 8) + "..." + address.slice(-6); + const displayedAddress = + address.slice(0, analysisData?.labels.length ? 4 : 8) + + ".." + + address.slice(-2); const risk = analysisData!.risk; return ( @@ -212,11 +232,17 @@ const Header: FC = ({
    {/* Address Hash - Sliced when in non-expanded mode*/} -

    - {displayedAddress} +

    + {analysisData?.labels.length && analysisData.labels[0]} + + {analysisData?.labels.length && "("} + {displayedAddress} + {analysisData?.labels.length && ")"} + +

    diff --git a/src/components/graph/custom_elements/nodes/AddressNode/AddressNode/AddressNode.tsx b/src/components/graph/custom_elements/nodes/AddressNode/AddressNode/AddressNode.tsx index 3db4b3a4..b125b51d 100644 --- a/src/components/graph/custom_elements/nodes/AddressNode/AddressNode/AddressNode.tsx +++ b/src/components/graph/custom_elements/nodes/AddressNode/AddressNode/AddressNode.tsx @@ -13,6 +13,7 @@ import { AddressAnalysis } from "../../../../../../api/model"; import { useAnalysisAddressData } from "../../../../../../api/compliance/compliance"; import { useCustomAddressTags } from "../../../../../../services/firestore/user/custom_tags"; +import { CategoryClasses } from "../../../../../../utils/categories"; import EntityLogo from "../../../../../common/EntityLogo"; import RiskIndicator from "../../../../../common/RiskIndicator"; @@ -35,6 +36,8 @@ import { Transition } from "@headlessui/react"; import useAuthState from "../../../../../../hooks/useAuthState"; import getExpandWithAIPaths from "../../../../expand_with_ai"; +import { TagIcon } from "@heroicons/react/16/solid"; +import { getCategory } from "../../../../../../api/labels/labels"; /** Context data for the AddressNode */ @@ -64,14 +67,31 @@ interface LabelsAndTagsProps { } const LabelsAndTags: FC = ({ labels, tags }) => { + // Get all categories for this address + const [categories, setCategories] = useState([]); + + useEffect(() => { + getCategory({ labels }).then((res) => { + setCategories(res.categories!); + }); + }, [labels]); + // Display everything and user input at the end in a flex-wrap return ( + {categories.map((category) => ( + + ))} {labels.map((label) => ( - + ))} {tags.map((tag) => ( - + ))} ); @@ -262,15 +282,24 @@ const AddressNode: FC = ({

    - {`${address.slice(0, 5)}...${address.slice(-5)}`} + {analysisData?.labels && ( + <> + {analysisData.labels[0]} + + {analysisData.labels.length > 0 && "("} + {`${address.slice(0, 5)}...${address.slice(-5)}`} + {analysisData.labels.length > 0 && ")"} + + + )} {analysisData && analysisData.labels.length > 0 && ( )}

    diff --git a/src/utils/categories/classes.tsx b/src/utils/categories/classes.tsx index 2ff62fac..77e278de 100644 --- a/src/utils/categories/classes.tsx +++ b/src/utils/categories/classes.tsx @@ -21,157 +21,220 @@ import { BanknotesIcon, UserIcon, } from "@heroicons/react/24/solid"; + +// Also import all icons as small icons +import { + NoSymbolIcon as NoSymbolIconSmall, + CreditCardIcon as CreditCardIconSmall, + CpuChipIcon as CpuChipIconSmall, + LockClosedIcon as LockClosedIconSmall, + CurrencyDollarIcon as CurrencyDollarIconSmall, + EnvelopeIcon as EnvelopeIconSmall, + ExclamationCircleIcon as ExclamationCircleIconSmall, + ArrowPathRoundedSquareIcon as ArrowPathRoundedSquareIconSmall, + CubeIcon as CubeIconSmall, + ArrowsPointingInIcon as ArrowsPointingInIconSmall, + ArrowTopRightOnSquareIcon as ArrowTopRightOnSquareIconSmall, + BoltIcon as BoltIconSmall, + WalletIcon as WalletIconSmall, + BuildingLibraryIcon as BuildingLibraryIconSmall, + ShieldCheckIcon as ShieldCheckIconSmall, + PhotoIcon as PhotoIconSmall, + ShoppingCartIcon as ShoppingCartIconSmall, + CloudIcon as CloudIconSmall, + EyeDropperIcon as EyeDropperIconSmall, + BanknotesIcon as BanknotesIconSmall, + UserIcon as UserIconSmall, +} from "@heroicons/react/16/solid"; + import Category from "./enum"; type CategoryClass = { risk: number; icon: React.FC; + iconSmall: React.FC; }; const CategoryClasses: Record = { [Category.GOVERNMENT_CRIMINAL_BLACKLIST]: { risk: 10, icon: NoSymbolIcon, + iconSmall: NoSymbolIconSmall, }, [Category.DARKNET_MARKET]: { risk: 10, icon: NoSymbolIcon, + iconSmall: NoSymbolIconSmall, }, [Category.DARK_MARKET]: { risk: 10, icon: NoSymbolIcon, + iconSmall: NoSymbolIconSmall, }, [Category.DARKNET]: { risk: 10, icon: NoSymbolIcon, + iconSmall: NoSymbolIconSmall, }, [Category.PHISHING_HACKING]: { risk: 7.5, icon: CpuChipIcon, + iconSmall: CpuChipIconSmall, }, [Category.RANSOMWARE]: { risk: 7.5, icon: LockClosedIcon, + iconSmall: LockClosedIconSmall, }, [Category.MONEY_LAUNDERING]: { risk: 7.5, icon: CurrencyDollarIcon, + iconSmall: CurrencyDollarIconSmall, }, [Category.BLACKMAIL]: { risk: 7.5, icon: EnvelopeIcon, + iconSmall: EnvelopeIconSmall, }, [Category.SCAM]: { risk: 7.5, icon: ExclamationCircleIcon, + iconSmall: ExclamationCircleIconSmall, }, [Category.TUMBLER]: { risk: 7.5, icon: ArrowPathRoundedSquareIcon, + iconSmall: ArrowPathRoundedSquareIconSmall, }, [Category.MIXER]: { risk: 7.5, icon: ArrowPathRoundedSquareIcon, + iconSmall: ArrowPathRoundedSquareIconSmall, }, [Category.PARITY_BUG]: { risk: 7.5, icon: ExclamationCircleIcon, + iconSmall: ExclamationCircleIconSmall, }, [Category.PONZI_SCHEME]: { risk: 7.5, icon: CurrencyDollarIcon, + iconSmall: CurrencyDollarIconSmall, }, [Category.BLOCKED]: { risk: 5, icon: NoSymbolIcon, + iconSmall: NoSymbolIconSmall, }, [Category.GAMBLING]: { risk: 5, icon: CubeIcon, + iconSmall: CubeIconSmall, }, [Category.DEFI]: { risk: 5, icon: ArrowsPointingInIcon, + iconSmall: ArrowsPointingInIconSmall, }, [Category.DEX]: { risk: 5, icon: ArrowsPointingInIcon, + iconSmall: ArrowsPointingInIconSmall, }, [Category.BRIDGE]: { risk: 5, icon: ArrowTopRightOnSquareIcon, + iconSmall: ArrowTopRightOnSquareIconSmall, }, [Category.MEV_BOT]: { risk: 5, icon: BoltIcon, + iconSmall: BoltIconSmall, }, [Category.P2P_FINANCIAL_INFRASTRUCTURE_SERVICE]: { risk: 2.5, icon: ArrowsPointingInIcon, + iconSmall: ArrowsPointingInIconSmall, }, [Category.P2P_FINANCIAL_SERVICE]: { risk: 2.5, icon: ArrowsPointingInIcon, + iconSmall: ArrowsPointingInIconSmall, }, [Category.WALLET]: { risk: 2.5, icon: WalletIcon, + iconSmall: WalletIconSmall, }, [Category.CUSTODIAL_WALLET]: { risk: 1, icon: WalletIcon, + iconSmall: WalletIconSmall, }, [Category.CENTRALIZED_EXCHANGE]: { risk: 1, icon: BuildingLibraryIcon, + iconSmall: BuildingLibraryIconSmall, }, [Category.CYBER_SECURITY_SERVICE]: { risk: 1, icon: ShieldCheckIcon, + iconSmall: ShieldCheckIconSmall, }, [Category.EXCHANGE]: { risk: 1, icon: BuildingLibraryIcon, + iconSmall: BuildingLibraryIconSmall, }, [Category.NFT_MARKETPLACE]: { risk: 1, icon: PhotoIcon, + iconSmall: PhotoIconSmall, }, [Category.MARKETPLACE]: { risk: 1, icon: ShoppingCartIcon, + iconSmall: ShoppingCartIconSmall, }, [Category.CLOUD_MINING]: { risk: 1, icon: CloudIcon, + iconSmall: CloudIconSmall, }, [Category.FAUCET]: { risk: 1, icon: EyeDropperIcon, + iconSmall: EyeDropperIconSmall, }, [Category.PAYMENT_PROCESSOR]: { risk: 1, icon: CreditCardIcon, + iconSmall: CreditCardIconSmall, }, [Category.MINING_POOL]: { risk: 1, icon: CubeIcon, + iconSmall: CubeIconSmall, }, [Category.E_COMMERCE]: { risk: 1, icon: ShoppingCartIcon, + iconSmall: ShoppingCartIconSmall, }, [Category.FUND]: { risk: 1, icon: BanknotesIcon, + iconSmall: BanknotesIconSmall, }, [Category.FIAT_GATEWAY]: { risk: 1, icon: CurrencyDollarIcon, + iconSmall: CurrencyDollarIconSmall, }, [Category.OTHER]: { risk: 0, icon: UserIcon, + iconSmall: UserIconSmall, }, }; From 472d5ec8084326137bee51867ed0612127088bbb Mon Sep 17 00:00:00 2001 From: Pedro Ribeiro <47680931+tubarao312@users.noreply.github.com> Date: Tue, 12 Mar 2024 14:58:37 +0000 Subject: [PATCH 2/2] Turn all tags unique --- src/components/graph/analysis_window/header/Header.tsx | 2 +- .../nodes/AddressNode/AddressNode/AddressNode.tsx | 3 ++- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/src/components/graph/analysis_window/header/Header.tsx b/src/components/graph/analysis_window/header/Header.tsx index 86f3283b..e725de8a 100644 --- a/src/components/graph/analysis_window/header/Header.tsx +++ b/src/components/graph/analysis_window/header/Header.tsx @@ -118,7 +118,7 @@ const LabelsAndTags: FC = ({ useEffect(() => { getCategory({ labels }).then((res) => { - setCategories(res.categories!); + setCategories(Array.from(new Set(res.categories!))); }); }, [labels]); diff --git a/src/components/graph/custom_elements/nodes/AddressNode/AddressNode/AddressNode.tsx b/src/components/graph/custom_elements/nodes/AddressNode/AddressNode/AddressNode.tsx index b125b51d..41fcf366 100644 --- a/src/components/graph/custom_elements/nodes/AddressNode/AddressNode/AddressNode.tsx +++ b/src/components/graph/custom_elements/nodes/AddressNode/AddressNode/AddressNode.tsx @@ -72,7 +72,8 @@ const LabelsAndTags: FC = ({ labels, tags }) => { useEffect(() => { getCategory({ labels }).then((res) => { - setCategories(res.categories!); + // Only set unique categories + setCategories(Array.from(new Set(res.categories!))); }); }, [labels]);