diff --git a/admin-ui/src/gql/types.ts b/admin-ui/src/gql/types.ts index a55749f4e..b21567612 100644 --- a/admin-ui/src/gql/types.ts +++ b/admin-ui/src/gql/types.ts @@ -728,6 +728,8 @@ export type IEventStatistics = { }; export enum IEventType { + AclDenied = 'ACL_DENIED', + AclGrantedSensitive = 'ACL_GRANTED_SENSITIVE', ApiLoginTokenCreated = 'API_LOGIN_TOKEN_CREATED', ApiLogout = 'API_LOGOUT', AssortmentAddFilter = 'ASSORTMENT_ADD_FILTER', @@ -1124,6 +1126,11 @@ export type IMutation = { loginWithWebAuthn?: Maybe; /** Log the user out. */ logout?: Maybe; + /** + * Log the user out of all sessions by invalidating all JWT tokens. + * This increments the token version, making all existing tokens invalid. + */ + logoutAllSessions?: Maybe; /** Make a proposal as answer to the RFP by changing its status to PROCESSED */ makeQuotationProposal: IQuotation; /** Make's the provided payment credential as the users preferred method of payment. */ @@ -3397,6 +3404,7 @@ export enum IRoleAction { LoginWithPassword = 'loginWithPassword', LoginWithWebAuthn = 'loginWithWebAuthn', Logout = 'logout', + LogoutAllSessions = 'logoutAllSessions', ManageAssortments = 'manageAssortments', ManageBookmarks = 'manageBookmarks', ManageCountries = 'manageCountries', @@ -15738,6 +15746,16 @@ export type IVerifyQuotationMutation = { }; }; +export type ITagsCountQueryVariables = Exact<{ + tag: Scalars['LowerCaseString']['input']; +}>; + +export type ITagsCountQuery = { + productsCount: number; + assortmentsCount: number; + usersCount: number; +}; + export type IInvalidateTokenMutationVariables = Exact<{ tokenId: Scalars['ID']['input']; }>; diff --git a/admin-ui/src/modules/common/components/Layout.tsx b/admin-ui/src/modules/common/components/Layout.tsx index 107a89734..4e2511390 100644 --- a/admin-ui/src/modules/common/components/Layout.tsx +++ b/admin-ui/src/modules/common/components/Layout.tsx @@ -15,6 +15,7 @@ import { CubeIcon, DocumentTextIcon, FolderArrowDownIcon, + TagIcon, } from '@heroicons/react/24/outline'; import Link from 'next/link'; import React, { useState } from 'react'; @@ -149,6 +150,12 @@ const Layout = ({ requiredRole: 'viewFilters', href: '/filters', }, + { + name: formatMessage({ id: 'tags', defaultMessage: 'Tags' }), + icon: TagIcon, + requiredRole: 'manageTags', + href: '/tags', + }, isSystemReady && { name: formatMessage({ id: 'users', defaultMessage: 'Users' }), icon: UsersIcon, diff --git a/admin-ui/src/modules/product/components/ProductAssignmentScaffoldForm.tsx b/admin-ui/src/modules/product/components/ProductAssignmentScaffoldForm.tsx index 9a3573586..e83c9ce74 100644 --- a/admin-ui/src/modules/product/components/ProductAssignmentScaffoldForm.tsx +++ b/admin-ui/src/modules/product/components/ProductAssignmentScaffoldForm.tsx @@ -11,7 +11,11 @@ import SelectField from '../../forms/components/SelectField'; import { IProductType } from '../../../gql/types'; import useApp from '../../common/hooks/useApp'; -const ProductAssignmentScaffoldForm = ({ proxyProduct, vectors, onSuccess }) => { +const ProductAssignmentScaffoldForm = ({ + proxyProduct, + vectors, + onSuccess, +}) => { const { formatMessage } = useIntl(); const { selectedLocale } = useApp(); const scaffoldProduct = useScaffoldVariationProduct({ @@ -22,7 +26,10 @@ const ProductAssignmentScaffoldForm = ({ proxyProduct, vectors, onSuccess }) => }); const { hasRole } = useAuth(); - const variations = useMemo(() => vectors.map(({ value }) => value), [vectors]); + const variations = useMemo( + () => vectors.map(({ value }) => value), + [vectors], + ); const defaultTitle = useMemo(() => { const baseTitle = proxyProduct?.texts?.title ?? ''; diff --git a/admin-ui/src/modules/tags/components/TagForm.tsx b/admin-ui/src/modules/tags/components/TagForm.tsx new file mode 100644 index 000000000..22e4d496e --- /dev/null +++ b/admin-ui/src/modules/tags/components/TagForm.tsx @@ -0,0 +1,150 @@ +import { useIntl } from 'react-intl'; + +import Button from '../../common/components/Button'; +import Form from '../../forms/components/Form'; +import TextField from '../../forms/components/TextField'; +import TextAreaField from '../../forms/components/TextAreaField'; +import SubmitButton from '../../forms/components/SubmitButton'; +import { Validator } from '../../forms/lib/validators'; +import useForm from '../../forms/hooks/useForm'; + +interface TagFormData { + name: string; + description?: string; + category?: string; +} + +interface TagFormProps { + initialValues?: Partial; + onSubmit: (data: TagFormData) => Promise; + submitButtonText?: string; + isLoading?: boolean; +} + +const TagForm = ({ + initialValues = {}, + onSubmit, + submitButtonText = 'Save', + isLoading = false, +}: TagFormProps) => { + const { formatMessage } = useIntl(); + + // Custom validator for tag name format + const validateTagName: Validator = { + isValid: (value) => { + if (!value || typeof value !== 'string') return false; + if (value.length < 2 || value.length > 50) return false; + return /^[a-z0-9\-_]+$/.test(value); + }, + intlMessageDescriptor: { + id: 'tag_name_format', + defaultMessage: + 'Tag name must be 2-50 characters and contain only lowercase letters, numbers, hyphens, and underscores', + }, + }; + + const form = useForm({ + initialValues: { + name: initialValues.name || '', + description: initialValues.description || '', + category: initialValues.category || '', + }, + submit: async (values: TagFormData) => { + await onSubmit(values); + return { success: true }; + }, + successMessage: formatMessage({ + id: 'tag_saved_successfully', + defaultMessage: 'Tag saved successfully', + }), + }); + + return ( +
+
+

+ {formatMessage({ + id: 'tag_information', + defaultMessage: 'Tag Information', + })} +

+ +
+ + + + + +
+
+ +
+ + +
+
+ ); +}; + +export default TagForm; diff --git a/admin-ui/src/modules/tags/components/TagList.tsx b/admin-ui/src/modules/tags/components/TagList.tsx new file mode 100644 index 000000000..dd7009bc4 --- /dev/null +++ b/admin-ui/src/modules/tags/components/TagList.tsx @@ -0,0 +1,69 @@ +import { useIntl } from 'react-intl'; +import Table from '../../common/components/Table'; +import TagListItem from './TagListItem'; + +interface TagListProps { + tags: string[]; + sortable?: boolean; +} + +const TagList = ({ tags, sortable = false }: TagListProps) => { + const { formatMessage } = useIntl(); + + if (!tags || tags.length === 0) { + return ( +
+ {formatMessage({ + id: 'no_tags_found', + defaultMessage: 'No tags found', + })} +
+ ); + } + + return ( +
+ + {/* Table Header */} + + + {formatMessage({ + id: 'tag_name', + defaultMessage: 'Tag Name', + })} + + + {formatMessage({ + id: 'total_usage', + defaultMessage: 'Total Usage', + })} + + + {formatMessage({ + id: 'products_usage', + defaultMessage: 'Products', + })} + + + {formatMessage({ + id: 'assortments_usage', + defaultMessage: 'Assortments', + })} + + + {formatMessage({ + id: 'users_usage', + defaultMessage: 'Users', + })} + + + + {tags.map((tag, index) => ( + + ))} +
+
+ ); +}; + +export default TagList; diff --git a/admin-ui/src/modules/tags/components/TagListItem.tsx b/admin-ui/src/modules/tags/components/TagListItem.tsx new file mode 100644 index 000000000..0d5663b33 --- /dev/null +++ b/admin-ui/src/modules/tags/components/TagListItem.tsx @@ -0,0 +1,83 @@ +import { useIntl } from 'react-intl'; +import Link from 'next/link'; +import Badge from '../../common/components/Badge'; +import Table from '../../common/components/Table'; +import useTagsCount from '../hooks/useTagsCount'; + +interface TagListItemProps { + tag: string; +} + +const TagListItem = ({ tag }: TagListItemProps) => { + const { formatMessage } = useIntl(); + + const { productsCount, assortmentsCount, usersCount } = useTagsCount({ tag }); + + return ( + + +
+ +
+
+ + +
+ {assortmentsCount + productsCount + usersCount} +
+
+ + +
+ {productsCount} + {productsCount > 0 && ( + + {formatMessage({ + id: 'view_products', + defaultMessage: 'view', + })} + + )} +
+
+ + +
+ {assortmentsCount} + {assortmentsCount > 0 && ( + + {formatMessage({ + id: 'view_assortments', + defaultMessage: 'view', + })} + + )} +
+
+ +
+ {usersCount} + {usersCount > 0 && ( + + {formatMessage({ + id: 'view_users', + defaultMessage: 'view', + })} + + )} +
+
+
+ ); +}; + +export default TagListItem; diff --git a/admin-ui/src/modules/tags/hooks/useTagsCount.ts b/admin-ui/src/modules/tags/hooks/useTagsCount.ts new file mode 100644 index 000000000..5702de648 --- /dev/null +++ b/admin-ui/src/modules/tags/hooks/useTagsCount.ts @@ -0,0 +1,31 @@ +import { gql } from '@apollo/client'; +import { useQuery } from '@apollo/client/react'; +import { ITagsCountQuery, ITagsCountQueryVariables } from '../../../gql/types'; + +const TagCountQuery = gql` + query TagsCount($tag: LowerCaseString!) { + productsCount(tags: [$tag]) + assortmentsCount(tags: [$tag]) + usersCount(tags: [$tag]) + } +`; + +const useTagsCount = ({ tag }) => { + const { data, loading } = useQuery( + TagCountQuery, + { + skip: !tag, + variables: { + tag, + }, + }, + ); + return { + assortmentsCount: data?.assortmentsCount ?? 0, + productsCount: data?.productsCount ?? 0, + usersCount: data?.usersCount ?? 0, + loading, + }; +}; + +export default useTagsCount; diff --git a/admin-ui/src/pages/tags/index.tsx b/admin-ui/src/pages/tags/index.tsx new file mode 100644 index 000000000..57708830f --- /dev/null +++ b/admin-ui/src/pages/tags/index.tsx @@ -0,0 +1,92 @@ +import { useIntl } from 'react-intl'; +import { useRouter } from 'next/router'; + +import BreadCrumbs from '../../modules/common/components/BreadCrumbs'; +import ListHeader from '../../modules/common/components/ListHeader'; +import TagList from '../../modules/tags/components/TagList'; + +import { normalizeQuery } from '../../modules/common/utils/utils'; +import SearchWithTags from '../../modules/common/components/SearchWithTags'; +import Loading from '../../modules/common/components/Loading'; +import useApp from '../../modules/common/hooks/useApp'; +import PageHeader from '../../modules/common/components/PageHeader'; +import AnimatedCounter from '../../modules/common/components/AnimatedCounter'; + +const Tags = () => { + const { formatMessage } = useIntl(); + const { query, push } = useRouter(); + const { shopInfo } = useApp(); + + const { queryString, tagId, ...restQuery } = query; + + const setQueryString = (searchString) => { + const { skip, ...withoutSkip } = restQuery; + if (searchString) { + push({ + query: normalizeQuery(withoutSkip, searchString, 'queryString'), + }); + } else { + push({ + query: normalizeQuery(restQuery), + }); + } + }; + + const tags = shopInfo + ? Array.from( + new Set([ + ...(shopInfo.adminUiConfig?.productTags || []), + ...(shopInfo?.adminUiConfig?.assortmentTags || []), + ...(shopInfo?.adminUiConfig?.userTags || []), + ]), + ).filter(Boolean) + : []; + const filteredTags = queryString + ? tags?.filter((tag) => + tag.toLowerCase().includes((queryString as string).toLowerCase() || ''), + ) + : tags; + return ( + <> + + }, + )} + /> +
+ +
+ {formatMessage({ + id: 'tags_management_description', + defaultMessage: + 'Manage tags used across products, assortments, and other content', + })} +
+
+ + + {!shopInfo && filteredTags?.length === 0 ? ( + + ) : ( + + )} + +
+ + ); +}; + +export default Tags;