diff --git a/.npmrc b/.npmrc deleted file mode 100644 index ae643592e..000000000 --- a/.npmrc +++ /dev/null @@ -1 +0,0 @@ -//registry.npmjs.org/:_authToken=${NPM_TOKEN} diff --git a/AGENTS.md b/AGENTS.md index db51dd1e2..50d7eaf8d 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -76,6 +76,11 @@ Year is auto-updated by eslint. The header is required in all files except: } ``` +### React Compiler +- This project uses the **React Compiler** (babel plugin) +- **Do not use `useMemo` or `useCallback`** — the compiler handles memoization automatically +- Writing manual memoization hooks is redundant and linters will flag them + ### React Components - Use named exports only, no default exports - Type components with `FC` from 'react': `const ComponentName: FC = () => {}` diff --git a/apps/admin-ui-backup/src/services/set-core-attributes.tsx b/apps/admin-ui-backup/src/services/set-core-attributes.tsx deleted file mode 100644 index 82e1f3bbb..000000000 --- a/apps/admin-ui-backup/src/services/set-core-attributes.tsx +++ /dev/null @@ -1,15 +0,0 @@ -/* - * SPDX-FileCopyrightText: 2021 Zextras - * - * SPDX-License-Identifier: AGPL-3.0-only - */ - -import { fetchExternalSoap } from '@zextras/ui-shared'; - -import type { CoreAttributeBody, SetCoreAttributesResponse } from '../../types'; - -export const setCoreAttributes = async (body: CoreAttributeBody): Promise => - fetchExternalSoap( - `/service/extension/zextras_admin/core/attribute/set`, - { ...body }, - ); diff --git a/apps/admin-ui-backup/src/views/backup/configuration/backup-configuration.tsx b/apps/admin-ui-backup/src/views/backup/configuration/backup-configuration.tsx index 980b54a71..e8307c167 100644 --- a/apps/admin-ui-backup/src/views/backup/configuration/backup-configuration.tsx +++ b/apps/admin-ui-backup/src/views/backup/configuration/backup-configuration.tsx @@ -20,6 +20,7 @@ import { fetchExternalSoap, getSoapFetchRequest, postSoapFetchRequest, + setCoreAttributes, useAllServers, useCurrentUserRights, useModuleLicenseInfo, @@ -55,7 +56,6 @@ import { ZIMBRA_ADMIN_URN, } from '../../../constants'; import { fetchSoap } from '../../../services/bucket-service'; -import { setCoreAttributes } from '../../../services/set-core-attributes'; import { useBackupStore } from '../../../store/backup/store'; import { RouteLeavingGuard } from '../../ui-extras/nav-guard'; @@ -432,8 +432,8 @@ const BackupConfiguration: FC = () => { body = { ...body, ...scanner }; } setIsSaveRequestInProgress(true); - setCoreAttributes(body) - .then((data: SetCoreAttributesResponse) => { + setCoreAttributes(body) + .then((data) => { setIsSaveRequestInProgress(false); if ((data?.errors && Array.isArray(data?.errors)) || data?.error) { let errorMessage = t( diff --git a/apps/admin-ui-backup/src/views/backup/server-advanced/server-advanced.tsx b/apps/admin-ui-backup/src/views/backup/server-advanced/server-advanced.tsx index c1e3d1b8b..100e0d3d8 100644 --- a/apps/admin-ui-backup/src/views/backup/server-advanced/server-advanced.tsx +++ b/apps/admin-ui-backup/src/views/backup/server-advanced/server-advanced.tsx @@ -14,7 +14,7 @@ import { Switch, useSnackbar, } from '@zextras/ui-components'; -import { getSoapFetchRequest, useAllServers, useCurrentUserRights } from '@zextras/ui-shared'; +import { getSoapFetchRequest, setCoreAttributes, useAllServers, useCurrentUserRights } from '@zextras/ui-shared'; import { find } from 'lodash-es'; import { type ChangeEvent, FC, useCallback, useEffect, useMemo, useState } from 'react'; import { useTranslation } from 'react-i18next'; @@ -29,7 +29,6 @@ import type { } from '../../../../types'; import { CONFIG, SERVER } from '../../../constants'; import { checkLdap } from '../../../services/check-ldap'; -import { setCoreAttributes } from '../../../services/set-core-attributes'; import { RouteLeavingGuard } from '../../ui-extras/nav-guard'; const ServerAdvanced: FC = () => { @@ -472,8 +471,8 @@ const ServerAdvanced: FC = () => { }, }; setIsRequestInProgress(true); - setCoreAttributes(body) - .then((data: SetCoreAttributesResponse) => { + setCoreAttributes(body) + .then((data) => { setIsRequestInProgress(false); if (data?.errors && Array.isArray(data?.errors)) { createSnackbar({ diff --git a/apps/admin-ui-console/public/mockServiceWorker.js b/apps/admin-ui-console/public/mockServiceWorker.js deleted file mode 100644 index 33dde9e77..000000000 --- a/apps/admin-ui-console/public/mockServiceWorker.js +++ /dev/null @@ -1,349 +0,0 @@ -/* eslint-disable */ -/* tslint:disable */ - -/** - * Mock Service Worker. - * @see https://github.com/mswjs/msw - * - Please do NOT modify this file. - */ - -const PACKAGE_VERSION = '2.14.6' -const INTEGRITY_CHECKSUM = '4db4a41e972cec1b64cc569c66952d82' -const IS_MOCKED_RESPONSE = Symbol('isMockedResponse') -const activeClientIds = new Set() - -addEventListener('install', function () { - self.skipWaiting() -}) - -addEventListener('activate', function (event) { - event.waitUntil(self.clients.claim()) -}) - -addEventListener('message', async function (event) { - const clientId = Reflect.get(event.source || {}, 'id') - - if (!clientId || !self.clients) { - return - } - - const client = await self.clients.get(clientId) - - if (!client) { - return - } - - const allClients = await self.clients.matchAll({ - type: 'window', - }) - - switch (event.data) { - case 'KEEPALIVE_REQUEST': { - sendToClient(client, { - type: 'KEEPALIVE_RESPONSE', - }) - break - } - - case 'INTEGRITY_CHECK_REQUEST': { - sendToClient(client, { - type: 'INTEGRITY_CHECK_RESPONSE', - payload: { - packageVersion: PACKAGE_VERSION, - checksum: INTEGRITY_CHECKSUM, - }, - }) - break - } - - case 'MOCK_ACTIVATE': { - activeClientIds.add(clientId) - - sendToClient(client, { - type: 'MOCKING_ENABLED', - payload: { - client: { - id: client.id, - frameType: client.frameType, - }, - }, - }) - break - } - - case 'CLIENT_CLOSED': { - activeClientIds.delete(clientId) - - const remainingClients = allClients.filter((client) => { - return client.id !== clientId - }) - - // Unregister itself when there are no more clients - if (remainingClients.length === 0) { - self.registration.unregister() - } - - break - } - } -}) - -addEventListener('fetch', function (event) { - const requestInterceptedAt = Date.now() - - // Bypass navigation requests. - if (event.request.mode === 'navigate') { - return - } - - // Opening the DevTools triggers the "only-if-cached" request - // that cannot be handled by the worker. Bypass such requests. - if ( - event.request.cache === 'only-if-cached' && - event.request.mode !== 'same-origin' - ) { - return - } - - // Bypass all requests when there are no active clients. - // Prevents the self-unregistered worked from handling requests - // after it's been terminated (still remains active until the next reload). - if (activeClientIds.size === 0) { - return - } - - const requestId = crypto.randomUUID() - event.respondWith(handleRequest(event, requestId, requestInterceptedAt)) -}) - -/** - * @param {FetchEvent} event - * @param {string} requestId - * @param {number} requestInterceptedAt - */ -async function handleRequest(event, requestId, requestInterceptedAt) { - const client = await resolveMainClient(event) - const requestCloneForEvents = event.request.clone() - const response = await getResponse( - event, - client, - requestId, - requestInterceptedAt, - ) - - // Send back the response clone for the "response:*" life-cycle events. - // Ensure MSW is active and ready to handle the message, otherwise - // this message will pend indefinitely. - if (client && activeClientIds.has(client.id)) { - const serializedRequest = await serializeRequest(requestCloneForEvents) - - // Clone the response so both the client and the library could consume it. - const responseClone = response.clone() - - sendToClient( - client, - { - type: 'RESPONSE', - payload: { - isMockedResponse: IS_MOCKED_RESPONSE in response, - request: { - id: requestId, - ...serializedRequest, - }, - response: { - type: responseClone.type, - status: responseClone.status, - statusText: responseClone.statusText, - headers: Object.fromEntries(responseClone.headers.entries()), - body: responseClone.body, - }, - }, - }, - responseClone.body ? [serializedRequest.body, responseClone.body] : [], - ) - } - - return response -} - -/** - * Resolve the main client for the given event. - * Client that issues a request doesn't necessarily equal the client - * that registered the worker. It's with the latter the worker should - * communicate with during the response resolving phase. - * @param {FetchEvent} event - * @returns {Promise} - */ -async function resolveMainClient(event) { - const client = await self.clients.get(event.clientId) - - if (activeClientIds.has(event.clientId)) { - return client - } - - if (client?.frameType === 'top-level') { - return client - } - - const allClients = await self.clients.matchAll({ - type: 'window', - }) - - return allClients - .filter((client) => { - // Get only those clients that are currently visible. - return client.visibilityState === 'visible' - }) - .find((client) => { - // Find the client ID that's recorded in the - // set of clients that have registered the worker. - return activeClientIds.has(client.id) - }) -} - -/** - * @param {FetchEvent} event - * @param {Client | undefined} client - * @param {string} requestId - * @param {number} requestInterceptedAt - * @returns {Promise} - */ -async function getResponse(event, client, requestId, requestInterceptedAt) { - // Clone the request because it might've been already used - // (i.e. its body has been read and sent to the client). - const requestClone = event.request.clone() - - function passthrough() { - // Cast the request headers to a new Headers instance - // so the headers can be manipulated with. - const headers = new Headers(requestClone.headers) - - // Remove the "accept" header value that marked this request as passthrough. - // This prevents request alteration and also keeps it compliant with the - // user-defined CORS policies. - const acceptHeader = headers.get('accept') - if (acceptHeader) { - const values = acceptHeader.split(',').map((value) => value.trim()) - const filteredValues = values.filter( - (value) => value !== 'msw/passthrough', - ) - - if (filteredValues.length > 0) { - headers.set('accept', filteredValues.join(', ')) - } else { - headers.delete('accept') - } - } - - return fetch(requestClone, { headers }) - } - - // Bypass mocking when the client is not active. - if (!client) { - return passthrough() - } - - // Bypass initial page load requests (i.e. static assets). - // The absence of the immediate/parent client in the map of the active clients - // means that MSW hasn't dispatched the "MOCK_ACTIVATE" event yet - // and is not ready to handle requests. - if (!activeClientIds.has(client.id)) { - return passthrough() - } - - // Notify the client that a request has been intercepted. - const serializedRequest = await serializeRequest(event.request) - const clientMessage = await sendToClient( - client, - { - type: 'REQUEST', - payload: { - id: requestId, - interceptedAt: requestInterceptedAt, - ...serializedRequest, - }, - }, - [serializedRequest.body], - ) - - switch (clientMessage.type) { - case 'MOCK_RESPONSE': { - return respondWithMock(clientMessage.data) - } - - case 'PASSTHROUGH': { - return passthrough() - } - } - - return passthrough() -} - -/** - * @param {Client} client - * @param {any} message - * @param {Array} transferrables - * @returns {Promise} - */ -function sendToClient(client, message, transferrables = []) { - return new Promise((resolve, reject) => { - const channel = new MessageChannel() - - channel.port1.onmessage = (event) => { - if (event.data && event.data.error) { - return reject(event.data.error) - } - - resolve(event.data) - } - - client.postMessage(message, [ - channel.port2, - ...transferrables.filter(Boolean), - ]) - }) -} - -/** - * @param {Response} response - * @returns {Response} - */ -function respondWithMock(response) { - // Setting response status code to 0 is a no-op. - // However, when responding with a "Response.error()", the produced Response - // instance will have status code set to 0. Since it's not possible to create - // a Response instance with status code 0, handle that use-case separately. - if (response.status === 0) { - return Response.error() - } - - const mockedResponse = new Response(response.body, response) - - Reflect.defineProperty(mockedResponse, IS_MOCKED_RESPONSE, { - value: true, - enumerable: true, - }) - - return mockedResponse -} - -/** - * @param {Request} request - */ -async function serializeRequest(request) { - return { - url: request.url, - mode: request.mode, - method: request.method, - headers: Object.fromEntries(request.headers.entries()), - cache: request.cache, - credentials: request.credentials, - destination: request.destination, - integrity: request.integrity, - redirect: request.redirect, - referrer: request.referrer, - referrerPolicy: request.referrerPolicy, - body: await request.arrayBuffer(), - keepalive: request.keepalive, - } -} diff --git a/apps/admin-ui-cos/package.json b/apps/admin-ui-cos/package.json index a5ae7bf30..70b85aee4 100644 --- a/apps/admin-ui-cos/package.json +++ b/apps/admin-ui-cos/package.json @@ -22,6 +22,7 @@ }, "dependencies": { "@posthog/react": "^1.8.1", + "@tanstack/react-form": "^1.32.0", "@vitest/browser-preview": "^4.1.0", "@zextras/ui-components": "workspace:*", "@zextras/ui-shared": "workspace:*", diff --git a/apps/admin-ui-cos/src/app.module.css b/apps/admin-ui-cos/src/app.module.css index fba50ee2f..599e67182 100644 --- a/apps/admin-ui-cos/src/app.module.css +++ b/apps/admin-ui-cos/src/app.module.css @@ -1,4 +1,5 @@ .primaryBarIcon { + padding: 0; } .primaryBarIcon:hover { diff --git a/apps/admin-ui-cos/src/app.tsx b/apps/admin-ui-cos/src/app.tsx index 0ead88228..e502df2e0 100644 --- a/apps/admin-ui-cos/src/app.tsx +++ b/apps/admin-ui-cos/src/app.tsx @@ -4,85 +4,37 @@ * SPDX-License-Identifier: AGPL-3.0-only */ -import { PrimaryBarTooltip } from '@zextras/ui-components'; import { addRoute, registerActions, removeRoute, useCurrentUserRights } from '@zextras/ui-shared'; -import { find } from 'lodash-es'; -import { FC, useCallback, useEffect, useMemo } from 'react'; -import { Trans, useTranslation } from 'react-i18next'; +import { useEffect } from 'react'; +import { useTranslation } from 'react-i18next'; import { useNavigate } from 'react-router'; import { APP_ID, - COS, COS_ROUTE_ID, - CREATE_COS, CREATE_NEW_COS_ROUTE_ID, - GLOBAL, - LIST_COS, MANAGE, MANAGE_APP_ID, PRIMARY_BAR_COS, } from './constants'; -import { useCosStore } from './store/cos/store'; -import AppView from './views/app-view'; +import { checkCreateCosRight, checkShowCOS } from './utils/check-rights'; +import { AppView } from './views/app-view'; +import { CosTooltipView } from './views/cos-tooltip-view'; -const App: FC = () => { +const App = () => { const [t] = useTranslation(); const navigate = useNavigate(); - const { setCosView } = useCosStore(); const { data: rights } = useCurrentUserRights(); - const showCOS = useMemo(() => { - const rightsConfig = find(rights, { type: COS }) ?? { all: [], type: COS }; - return !!( - rightsConfig?.all?.[0]?.getAttrs?.[0]?.all ?? - rightsConfig?.all?.[0]?.setAttrs?.[0]?.all ?? - find(rightsConfig?.all?.[0]?.right, { n: LIST_COS }) - ); - }, [rights]); + const showCOS = checkShowCOS(rights); + const createCosRight = checkCreateCosRight(rights); - const createCosRight = useMemo(() => { - const rightsConfig = find(rights, { type: GLOBAL }) ?? { all: [], type: GLOBAL }; - return !!( - rightsConfig?.all?.[0]?.getAttrs?.[0]?.all ?? - rightsConfig?.all?.[0]?.setAttrs?.[0]?.all ?? - find(rightsConfig?.all?.[0]?.right, { n: CREATE_COS }) - ); - }, [rights]); - - const managementSection = useMemo( - () => ({ - id: MANAGE_APP_ID, - label: t('label.management', 'Management'), - position: 3, - }), - [t], - ); - - const CosTooltipView: FC = useCallback( - () => ( - -

- }} - t={t} - /> -

-

- }} - t={t} - /> -

-
- ), - [t], - ); + const managementSection = { + id: MANAGE_APP_ID, + label: t('label.management', 'Management'), + position: 3, + }; useEffect(() => { if (showCOS) { @@ -93,24 +45,23 @@ const App: FC = () => { label: t('label.cos', 'COS') || '', primaryBar: 'SettingsModOutline', appView: AppView, - primarybarSection: { ...managementSection }, + primarybarSection: managementSection, tooltip: CosTooltipView, trackerLabel: PRIMARY_BAR_COS, }); } else { removeRoute(COS_ROUTE_ID); } - }, [CosTooltipView, managementSection, showCOS, t]); + }, [managementSection, showCOS, t]); useEffect(() => { registerActions({ - action: (): any => ({ + action: () => ({ id: 'new-cos', label: t('label.create_new_cos', 'Create New COS'), icon: '', - onClick: (): void => { + onClick: () => { navigate(`/${MANAGE}/${COS_ROUTE_ID}/${CREATE_NEW_COS_ROUTE_ID}`); - setCosView(CREATE_NEW_COS_ROUTE_ID); }, disabled: !createCosRight, group: APP_ID, @@ -119,7 +70,7 @@ const App: FC = () => { id: 'new-cos', type: 'new', }); - }, [createCosRight, navigate, setCosView, t]); + }, [createCosRight, navigate, t]); return null; }; diff --git a/apps/admin-ui-cos/src/hooks/use-debounced-value.ts b/apps/admin-ui-cos/src/hooks/use-debounced-value.ts new file mode 100644 index 000000000..51cdfba22 --- /dev/null +++ b/apps/admin-ui-cos/src/hooks/use-debounced-value.ts @@ -0,0 +1,25 @@ +/* + * SPDX-FileCopyrightText: 2026 Zextras + * + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import { useEffect, useState } from 'react'; + +function useDebouncedValue(value: T, delay = 700): T { + const [debouncedValue, setDebouncedValue] = useState(value); + + useEffect(() => { + const timer = setTimeout(() => { + setDebouncedValue(value); + }, delay); + + return () => { + clearTimeout(timer); + }; + }, [value, delay]); + + return debouncedValue; +} + +export { useDebouncedValue }; diff --git a/apps/admin-ui-cos/src/services/cos-general-information-service.ts b/apps/admin-ui-cos/src/services/cos-general-information-service.ts deleted file mode 100644 index b7ab99022..000000000 --- a/apps/admin-ui-cos/src/services/cos-general-information-service.ts +++ /dev/null @@ -1,18 +0,0 @@ -/* - * SPDX-FileCopyrightText: 2021 Zextras - * - * SPDX-License-Identifier: AGPL-3.0-only - */ - -import { soapFetch } from '@zextras/ui-shared'; - -import { CosResponse } from '../../types/cos'; - -export const getCosGeneralInformation = async (cosId: string): Promise => - soapFetch(`GetCos`, { - _jsns: 'urn:zimbraAdmin', - cos: { - by: 'id', - _content: cosId - } - }); diff --git a/apps/admin-ui-cos/src/services/cos-query-keys.ts b/apps/admin-ui-cos/src/services/cos-query-keys.ts new file mode 100644 index 000000000..4c5ab1d51 --- /dev/null +++ b/apps/admin-ui-cos/src/services/cos-query-keys.ts @@ -0,0 +1,22 @@ +/* + * SPDX-FileCopyrightText: 2026 Zextras + * + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import type { CoreAttributeRequest } from '@zextras/ui-shared'; + +export const cosQueryKeys = { + all: ['cos'] as const, + detail: (cosId: string) => [...cosQueryKeys.all, 'detail', cosId] as const, + totalAccounts: (cosId: string) => [...cosQueryKeys.all, 'total-accounts', cosId] as const, + totalDomains: (cosId: string) => [...cosQueryKeys.all, 'total-domains', cosId] as const, + coreAttributes: (body: Array) => + [...cosQueryKeys.all, 'core-attributes', body] as const, + fileQuota: (cosId: string) => [...cosQueryKeys.all, 'file-quota', cosId] as const, + cosQuota: (cosId: string) => [...cosQueryKeys.all, 'cos-quota', cosId] as const, + accounts: (cosId: string, searchStr: string, offset: number, limit: number) => + [...cosQueryKeys.all, 'accounts', cosId, searchStr, offset, limit] as const, + domains: (cosId: string, searchStr: string, offset: number, limit: number) => + [...cosQueryKeys.all, 'domains', cosId, searchStr, offset, limit] as const, +}; diff --git a/apps/admin-ui-cos/src/services/cos-search-utils.ts b/apps/admin-ui-cos/src/services/cos-search-utils.ts new file mode 100644 index 000000000..5b7cde6af --- /dev/null +++ b/apps/admin-ui-cos/src/services/cos-search-utils.ts @@ -0,0 +1,35 @@ +/* + * SPDX-FileCopyrightText: 2026 Zextras + * + * SPDX-License-Identifier: AGPL-3.0-only + */ + +function generateAccountSearchFilterQuery( + searchStr: string, + cosIdVal: string | undefined, +): string { + let filterQuery = `(&(zimbraCOSId=${cosIdVal})(!(zimbraIsSystemAccount=TRUE)))`; + if (searchStr) { + filterQuery += `(|(mail=*${searchStr}*)(cn=*${searchStr}*)(sn=*${searchStr}*)(gn=*${searchStr}*)(displayName=*${searchStr}*)(zimbraMailDeliveryAddress=*${searchStr}*))`; + } + if (searchStr) { + return `(&${filterQuery})`; + } + return filterQuery; +} + +function generateDomainSearchFilterQuery( + searchStr: string, + cosIdVal: string | undefined, +): string { + let filterQuery = `(|(zimbraDomainCOSMaxAccounts=${cosIdVal}*)(zimbraDomainDefaultCOSId=${cosIdVal}))`; + if (searchStr) { + filterQuery += `(|(zimbraDomainName=*${searchStr}*))`; + } + if (searchStr) { + return `(&${filterQuery})`; + } + return filterQuery; +} + +export { generateAccountSearchFilterQuery, generateDomainSearchFilterQuery }; diff --git a/apps/admin-ui-cos/src/services/get-core-attributes.ts b/apps/admin-ui-cos/src/services/get-core-attributes.ts deleted file mode 100644 index b76bf01c4..000000000 --- a/apps/admin-ui-cos/src/services/get-core-attributes.ts +++ /dev/null @@ -1,21 +0,0 @@ - -/* - * SPDX-FileCopyrightText: 2021 Zextras - * - * SPDX-License-Identifier: AGPL-3.0-only - */ - -import { fetchExternalSoap } from '@zextras/ui-shared'; - -import { GetCoreAttributesResponse } from '../../types/cos'; - -type CoreAttributeRequest = { - configType: string; - configName: Array; - attrName: Array; -}; - -export const getCoreAttributes = async ( - body: Array -): Promise => - fetchExternalSoap(`/service/extension/zextras_admin/core/attributes/get`, [...body]); diff --git a/apps/admin-ui-cos/src/services/get-file-quota.ts b/apps/admin-ui-cos/src/services/get-file-quota.ts deleted file mode 100644 index e597800eb..000000000 --- a/apps/admin-ui-cos/src/services/get-file-quota.ts +++ /dev/null @@ -1,15 +0,0 @@ -/* - * SPDX-FileCopyrightText: 2023 Zextras - * - * SPDX-License-Identifier: AGPL-3.0-only - */ -import { getSoapFetchRequest } from '@zextras/ui-shared'; - -import { FileQuotaResponse } from '../../types/cos'; -import { ACCOUNTS, COS } from '../constants'; - -export const getFileQuotaById = async (accId: string, type?: string): Promise => { - const fetchType = type === COS ? COS : ACCOUNTS; - const url = `/services/storages/admin/quota/${fetchType}/${accId}`; - return getSoapFetchRequest(url); -}; diff --git a/apps/admin-ui-cos/src/services/search-cos-service.ts b/apps/admin-ui-cos/src/services/search-cos-service.ts deleted file mode 100644 index e4a35f2ed..000000000 --- a/apps/admin-ui-cos/src/services/search-cos-service.ts +++ /dev/null @@ -1,34 +0,0 @@ -/* - * SPDX-FileCopyrightText: 2021 Zextras - * - * SPDX-License-Identifier: AGPL-3.0-only - */ - -import { soapFetch } from '@zextras/ui-shared'; - -import { SearchDirectoryResponse } from '../../types/cos'; - -export const getCosList = async ( - searchKeyWord: string, - limit?: number, - offset?: number -): Promise => - soapFetch(`SearchDirectory`, { - method: 'POST', - credentials: 'include', - headers: { - 'Content-Type': 'application/json' - }, - _jsns: 'urn:zimbraAdmin', - limit: limit ?? 50, - offset: offset ?? 0, - sortBy: 'cn', - sortAscending: '1', - applyCos: 'false', - applyConfig: 'false', - attrs: 'cn,description', - types: 'coses', - query: { - _content: !!searchKeyWord && searchKeyWord !== '' ? `(|(cn=*${searchKeyWord}*))` : '' - } - }); diff --git a/apps/admin-ui-cos/src/services/search-directory-service.ts b/apps/admin-ui-cos/src/services/search-directory-service.ts deleted file mode 100644 index 2a6ca3af0..000000000 --- a/apps/admin-ui-cos/src/services/search-directory-service.ts +++ /dev/null @@ -1,47 +0,0 @@ -/* - * SPDX-FileCopyrightText: 2021 Zextras - * - * SPDX-License-Identifier: AGPL-3.0-only - */ - -import { soapFetch } from '@zextras/ui-shared'; - -import { SearchDirectoryResponse } from '../../types/cos'; -import { ASC } from '../constants'; - -export const searchDirectory = async ( - attr: string, - type: string, - domainName: string, - query: string, - offset?: number, - limit?: number, - sortBy?: string, - sortAscending?: string -): Promise => { - const request: Record = { - _jsns: 'urn:zimbraAdmin', - limit: limit ?? 50, - offset: offset || 0, - sortAscending: '1', - applyCos: 'false', - applyConfig: 'false', - attrs: attr, - types: type - }; - if (domainName !== '') { - request.domain = domainName; - } - if (query !== '') { - request.query = query; - } - if (sortBy !== '') { - request.sortBy = sortBy; - } - if (sortAscending !== '') { - request.sortAscending = sortAscending === ASC ? 1 : 0; - } - return soapFetch(`SearchDirectory`, { - ...request - }); -}; diff --git a/apps/admin-ui-cos/src/services/set-core-attributes.tsx b/apps/admin-ui-cos/src/services/set-core-attributes.tsx deleted file mode 100644 index 3c40240d5..000000000 --- a/apps/admin-ui-cos/src/services/set-core-attributes.tsx +++ /dev/null @@ -1,12 +0,0 @@ -/* - * SPDX-FileCopyrightText: 2021 Zextras - * - * SPDX-License-Identifier: AGPL-3.0-only - */ - -import { fetchExternalSoap } from '@zextras/ui-shared'; - -export const setCoreAttributes = async (body: Record): Promise => - fetchExternalSoap(`/service/extension/zextras_admin/core/attribute/set`, { - ...body - }); diff --git a/apps/admin-ui-cos/src/services/tests/create-cos.test.ts b/apps/admin-ui-cos/src/services/tests/create-cos.test.ts new file mode 100644 index 000000000..e052c9798 --- /dev/null +++ b/apps/admin-ui-cos/src/services/tests/create-cos.test.ts @@ -0,0 +1,127 @@ +/* + * SPDX-FileCopyrightText: 2026 Zextras + * + * SPDX-License-Identifier: AGPL-3.0-only + */ +import { describe, expect, it, vi } from 'vitest'; + +import { createCos } from '../create-cos'; + +vi.mock('@zextras/ui-shared', () => ({ + soapFetch: vi.fn(), +})); + +const { soapFetch } = await import('@zextras/ui-shared'); + +describe('createCos', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + it('should call soapFetch with CreateCos and name only', async () => { + const mockResponse = { + cos: [{ id: 'cos-1', name: 'test-cos' }], + }; + vi.mocked(soapFetch).mockResolvedValue(mockResponse); + + const result = await createCos('test-cos'); + + expect(soapFetch).toHaveBeenCalledWith('CreateCos', { + _jsns: 'urn:zimbraAdmin', + name: { _content: 'test-cos' }, + }); + expect(result).toEqual(mockResponse); + }); + + it('should call soapFetch with name and attributes', async () => { + const mockResponse = { + cos: [{ id: 'cos-2', name: 'my-cos' }], + }; + const attributes = [ + { n: 'zimbraPrefLocale', _content: 'en_US' }, + { n: 'zimbraPrefTimeZoneId', _content: 'America/New_York' }, + ]; + vi.mocked(soapFetch).mockResolvedValue(mockResponse); + + const result = await createCos('my-cos', attributes); + + expect(soapFetch).toHaveBeenCalledWith('CreateCos', { + _jsns: 'urn:zimbraAdmin', + name: { _content: 'my-cos' }, + a: attributes, + }); + expect(result).toEqual(mockResponse); + }); + + it('should include attributes with the c flag', async () => { + const mockResponse = { + cos: [{ id: 'cos-3', name: 'flagged-cos' }], + }; + const attributes = [{ n: 'zimbraFeatureMailForwardingEnabled', _content: 'TRUE', c: true }]; + vi.mocked(soapFetch).mockResolvedValue(mockResponse); + + const result = await createCos('flagged-cos', attributes); + + expect(soapFetch).toHaveBeenCalledWith('CreateCos', { + _jsns: 'urn:zimbraAdmin', + name: { _content: 'flagged-cos' }, + a: attributes, + }); + expect(result).toEqual(mockResponse); + }); + + it('should omit name from request when name is empty string', async () => { + const mockResponse = { cos: [] }; + vi.mocked(soapFetch).mockResolvedValue(mockResponse); + + const result = await createCos(''); + + expect(soapFetch).toHaveBeenCalledWith('CreateCos', { + _jsns: 'urn:zimbraAdmin', + }); + expect(result).toEqual(mockResponse); + }); + + it('should omit a from request when attributes are not provided', async () => { + const mockResponse = { + cos: [{ id: 'cos-4', name: 'no-attrs' }], + }; + vi.mocked(soapFetch).mockResolvedValue(mockResponse); + + const result = await createCos('no-attrs'); + + expect(soapFetch).toHaveBeenCalledWith('CreateCos', { + _jsns: 'urn:zimbraAdmin', + name: { _content: 'no-attrs' }, + }); + expect(result).toEqual(mockResponse); + }); + + it('should include a in request when attributes is an empty array', async () => { + const mockResponse = { cos: [] }; + vi.mocked(soapFetch).mockResolvedValue(mockResponse); + + const result = await createCos('empty-attrs', []); + + expect(soapFetch).toHaveBeenCalledWith('CreateCos', { + _jsns: 'urn:zimbraAdmin', + name: { _content: 'empty-attrs' }, + a: [], + }); + expect(result).toEqual(mockResponse); + }); + + it('should call soapFetch exactly once per invocation', async () => { + vi.mocked(soapFetch).mockResolvedValue({ cos: [] }); + + await createCos('once'); + + expect(soapFetch).toHaveBeenCalledTimes(1); + }); + + it('should propagate errors from soapFetch', async () => { + const error = new Error('SOAP fault'); + vi.mocked(soapFetch).mockRejectedValue(error); + + await expect(createCos('error-cos')).rejects.toThrow('SOAP fault'); + }); +}); diff --git a/apps/admin-ui-cos/src/services/tests/delete-cos-service.test.ts b/apps/admin-ui-cos/src/services/tests/delete-cos-service.test.ts new file mode 100644 index 000000000..cbbb24728 --- /dev/null +++ b/apps/admin-ui-cos/src/services/tests/delete-cos-service.test.ts @@ -0,0 +1,42 @@ +/* + * SPDX-FileCopyrightText: 2026 Zextras + * + * SPDX-License-Identifier: AGPL-3.0-only + */ +import { describe, expect, it, vi } from 'vitest'; + +import { deleteCOS } from '../delete-cos-service'; + +vi.mock('@zextras/ui-shared', () => ({ + soapFetch: vi.fn(), +})); + +const { soapFetch } = await import('@zextras/ui-shared'); + +describe('deleteCOS', () => { + it('should call soapFetch with DeleteCos and the given id', async () => { + vi.mocked(soapFetch).mockResolvedValue({}); + + const result = await deleteCOS('cos-123'); + + expect(soapFetch).toHaveBeenCalledWith('DeleteCos', { + _jsns: 'urn:zimbraAdmin', + id: { _content: 'cos-123' }, + }); + expect(result).toEqual({}); + }); + + it('should call soapFetch exactly once per invocation', async () => { + vi.mocked(soapFetch).mockResolvedValue({}); + + await deleteCOS('cos-456'); + + expect(soapFetch).toHaveBeenCalledTimes(1); + }); + + it('should propagate errors from soapFetch', async () => { + vi.mocked(soapFetch).mockRejectedValue(new Error('SOAP fault')); + + await expect(deleteCOS('cos-err')).rejects.toThrow('SOAP fault'); + }); +}); diff --git a/apps/admin-ui-cos/src/services/tests/modify-cos-service.test.ts b/apps/admin-ui-cos/src/services/tests/modify-cos-service.test.ts new file mode 100644 index 000000000..9f49c1a82 --- /dev/null +++ b/apps/admin-ui-cos/src/services/tests/modify-cos-service.test.ts @@ -0,0 +1,72 @@ +/* + * SPDX-FileCopyrightText: 2026 Zextras + * + * SPDX-License-Identifier: AGPL-3.0-only + */ +import { describe, expect, it, vi } from 'vitest'; + +import { modifyCos } from '../modify-cos-service'; + +vi.mock('@zextras/ui-shared', () => ({ + soapFetch: vi.fn(), +})); + +const { soapFetch } = await import('@zextras/ui-shared'); + +describe('modifyCos', () => { + it('should call soapFetch with ModifyCos and the provided body', async () => { + const mockResponse = { cos: [{ id: 'cos-1', name: 'modified' }] }; + const body = { + _jsns: 'urn:zimbraAdmin', + id: { _content: 'cos-1' }, + a: [{ n: 'zimbraPrefLocale', _content: 'en_US' }], + }; + vi.mocked(soapFetch).mockResolvedValue(mockResponse); + + const result = await modifyCos(body); + + expect(soapFetch).toHaveBeenCalledWith('ModifyCos', body); + expect(result).toEqual(mockResponse); + }); + + it('should spread the body object correctly', async () => { + vi.mocked(soapFetch).mockResolvedValue({ cos: [] }); + + const body = { + _jsns: 'urn:zimbraAdmin', + id: { _content: 'cos-2' }, + a: [ + { n: 'zimbraPrefLocale', _content: 'it_IT' }, + { n: 'zimbraPrefTimeZoneId', _content: 'Europe/Rome' }, + ], + }; + + await modifyCos(body); + + expect(soapFetch).toHaveBeenCalledWith('ModifyCos', { ...body }); + }); + + it('should call soapFetch exactly once per invocation', async () => { + vi.mocked(soapFetch).mockResolvedValue({ cos: [] }); + + await modifyCos({ + _jsns: 'urn:zimbraAdmin', + id: { _content: 'cos-3' }, + a: [{ n: 'attr', _content: 'val' }], + }); + + expect(soapFetch).toHaveBeenCalledTimes(1); + }); + + it('should propagate errors from soapFetch', async () => { + vi.mocked(soapFetch).mockRejectedValue(new Error('SOAP fault')); + + await expect( + modifyCos({ + _jsns: 'urn:zimbraAdmin', + id: { _content: 'cos-err' }, + a: [{ n: 'attr', _content: 'val' }], + }), + ).rejects.toThrow('SOAP fault'); + }); +}); diff --git a/apps/admin-ui-cos/src/services/tests/rename-cos-service.test.ts b/apps/admin-ui-cos/src/services/tests/rename-cos-service.test.ts new file mode 100644 index 000000000..c80244aa5 --- /dev/null +++ b/apps/admin-ui-cos/src/services/tests/rename-cos-service.test.ts @@ -0,0 +1,68 @@ +/* + * SPDX-FileCopyrightText: 2026 Zextras + * + * SPDX-License-Identifier: AGPL-3.0-only + */ +import { describe, expect, it, vi } from 'vitest'; + +import { renameCos } from '../rename-cos-service'; + +vi.mock('@zextras/ui-shared', () => ({ + soapFetch: vi.fn(), +})); + +const { soapFetch } = await import('@zextras/ui-shared'); + +describe('renameCos', () => { + it('should call soapFetch with RenameCos and the provided body', async () => { + vi.mocked(soapFetch).mockResolvedValue(undefined); + + const body = { + _jsns: 'urn:zimbraAdmin', + id: { _content: 'cos-123' }, + newName: { _content: 'renamed-cos' }, + }; + + await renameCos(body); + + expect(soapFetch).toHaveBeenCalledWith('RenameCos', body); + }); + + it('should spread the body object correctly', async () => { + vi.mocked(soapFetch).mockResolvedValue(undefined); + + const body = { + _jsns: 'urn:zimbraAdmin', + id: { _content: 'cos-456' }, + newName: { _content: 'new-name' }, + }; + + await renameCos(body); + + expect(soapFetch).toHaveBeenCalledWith('RenameCos', { ...body }); + }); + + it('should call soapFetch exactly once per invocation', async () => { + vi.mocked(soapFetch).mockResolvedValue(undefined); + + await renameCos({ + _jsns: 'urn:zimbraAdmin', + id: { _content: 'cos-789' }, + newName: { _content: 'another-name' }, + }); + + expect(soapFetch).toHaveBeenCalledTimes(1); + }); + + it('should propagate errors from soapFetch', async () => { + vi.mocked(soapFetch).mockRejectedValue(new Error('SOAP fault')); + + await expect( + renameCos({ + _jsns: 'urn:zimbraAdmin', + id: { _content: 'cos-err' }, + newName: { _content: 'fail' }, + }), + ).rejects.toThrow('SOAP fault'); + }); +}); diff --git a/apps/admin-ui-cos/src/services/tests/use-core-attributes.test.tsx b/apps/admin-ui-cos/src/services/tests/use-core-attributes.test.tsx new file mode 100644 index 000000000..011a4219a --- /dev/null +++ b/apps/admin-ui-cos/src/services/tests/use-core-attributes.test.tsx @@ -0,0 +1,97 @@ +/* + * SPDX-FileCopyrightText: 2026 Zextras + * + * SPDX-License-Identifier: AGPL-3.0-only + */ +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import { renderHook, waitFor } from '@testing-library/react'; +import type { ReactNode } from 'react'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +import { useCoreAttributes } from '../use-core-attributes'; + +vi.mock('@zextras/ui-components', () => ({ + useSnackbar: vi.fn(), +})); + +vi.mock('react-i18next', () => ({ + useTranslation: () => [(key: string, fallback?: string) => fallback ?? key], +})); + +vi.mock('@zextras/ui-shared', async (importOriginal) => ({ + ...(await importOriginal()), + getCoreAttributes: vi.fn(), +})); + +import { useSnackbar } from '@zextras/ui-components'; +import { getCoreAttributes } from '@zextras/ui-shared'; + +const mockCreateSnackbar = vi.fn(); + +function createWrapper() { + const queryClient = new QueryClient({ + defaultOptions: { queries: { retry: false } }, + }); + const Wrapper = ({ children }: { children: ReactNode }) => ( + {children} + ); + Wrapper.displayName = 'Wrapper'; + return Wrapper; +} + +describe('useCoreAttributes', () => { + beforeEach(() => { + vi.mocked(useSnackbar).mockReturnValue(mockCreateSnackbar); + mockCreateSnackbar.mockClear(); + }); + + it('should not fetch when body is empty', () => { + const wrapper = createWrapper(); + const { result } = renderHook(() => useCoreAttributes([]), { wrapper }); + + expect(result.current.fetchStatus).toBe('idle'); + expect(getCoreAttributes).not.toHaveBeenCalled(); + }); + + it('should fetch core attributes when body is provided', async () => { + const mockResponse = { attributes: { attr1: [{ value: 'val1' }] } }; + vi.mocked(getCoreAttributes).mockResolvedValue(mockResponse); + const body = [{ configType: 'cos', configName: ['default'], attrName: ['attr1'] }]; + + const wrapper = createWrapper(); + const { result } = renderHook(() => useCoreAttributes(body), { wrapper }); + + await waitFor(() => expect(result.current.isSuccess).toBe(true)); + expect(getCoreAttributes).toHaveBeenCalledWith(body); + expect(result.current.data).toEqual(mockResponse); + }); + + it('should show error snackbar on failure', async () => { + vi.mocked(getCoreAttributes).mockRejectedValue(new Error('Fetch failed')); + const body = [{ configType: 'cos', configName: ['default'], attrName: ['attr1'] }]; + + const wrapper = createWrapper(); + renderHook(() => useCoreAttributes(body), { wrapper }); + + await waitFor(() => expect(mockCreateSnackbar).toHaveBeenCalledTimes(1), { timeout: 5000 }); + expect(mockCreateSnackbar).toHaveBeenCalledWith( + expect.objectContaining({ severity: 'error', label: 'Fetch failed' }), + ); + }); + + it('should show fallback error message when error has no message', async () => { + vi.mocked(getCoreAttributes).mockRejectedValue(new Error()); + const body = [{ configType: 'cos', configName: ['default'], attrName: ['attr1'] }]; + + const wrapper = createWrapper(); + renderHook(() => useCoreAttributes(body), { wrapper }); + + await waitFor(() => expect(mockCreateSnackbar).toHaveBeenCalledTimes(1), { timeout: 5000 }); + expect(mockCreateSnackbar).toHaveBeenCalledWith( + expect.objectContaining({ + severity: 'error', + label: 'Something went wrong. Please try again.', + }), + ); + }); +}); diff --git a/apps/admin-ui-cos/src/services/tests/use-cos-detail.test.tsx b/apps/admin-ui-cos/src/services/tests/use-cos-detail.test.tsx new file mode 100644 index 000000000..3f630d1cc --- /dev/null +++ b/apps/admin-ui-cos/src/services/tests/use-cos-detail.test.tsx @@ -0,0 +1,60 @@ +/* + * SPDX-FileCopyrightText: 2026 Zextras + * + * SPDX-License-Identifier: AGPL-3.0-only + */ +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import { renderHook, waitFor } from '@testing-library/react'; +import type { ReactNode } from 'react'; +import { describe, expect, it, vi } from 'vitest'; + +import { useCosDetail } from '../use-cos-detail'; + +vi.mock('@zextras/ui-shared', () => ({ + getCosGeneralInformation: vi.fn(), +})); + +import { getCosGeneralInformation } from '@zextras/ui-shared'; + +function createWrapper() { + const queryClient = new QueryClient({ + defaultOptions: { queries: { retry: false } }, + }); + const Wrapper = ({ children }: { children: ReactNode }) => ( + {children} + ); + Wrapper.displayName = 'Wrapper'; + return Wrapper; +} + +describe('useCosDetail', () => { + it('should not fetch when cosId is undefined', () => { + const wrapper = createWrapper(); + const { result } = renderHook(() => useCosDetail(undefined), { wrapper }); + + expect(result.current.fetchStatus).toBe('idle'); + expect(getCosGeneralInformation).not.toHaveBeenCalled(); + }); + + it('should fetch cos detail when cosId is provided', async () => { + const mockResponse = { cos: [{ id: 'cos-1', name: 'default' }] }; + vi.mocked(getCosGeneralInformation).mockResolvedValue(mockResponse); + + const wrapper = createWrapper(); + const { result } = renderHook(() => useCosDetail('cos-1'), { wrapper }); + + await waitFor(() => expect(result.current.isSuccess).toBe(true)); + expect(getCosGeneralInformation).toHaveBeenCalledWith('cos-1'); + expect(result.current.data).toEqual(mockResponse); + }); + + it('should handle fetch errors', async () => { + vi.mocked(getCosGeneralInformation).mockRejectedValue(new Error('Not found')); + + const wrapper = createWrapper(); + const { result } = renderHook(() => useCosDetail('cos-err'), { wrapper }); + + await waitFor(() => expect(result.current.isError).toBe(true), { timeout: 5000 }); + expect(result.current.error).toBeInstanceOf(Error); + }); +}); diff --git a/apps/admin-ui-cos/src/services/tests/use-cos-list.test.tsx b/apps/admin-ui-cos/src/services/tests/use-cos-list.test.tsx new file mode 100644 index 000000000..0e8bd57bd --- /dev/null +++ b/apps/admin-ui-cos/src/services/tests/use-cos-list.test.tsx @@ -0,0 +1,79 @@ +/* + * SPDX-FileCopyrightText: 2026 Zextras + * + * SPDX-License-Identifier: AGPL-3.0-only + */ +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import { renderHook, waitFor } from '@testing-library/react'; +import type { ReactNode } from 'react'; +import { describe, expect, it, vi } from 'vitest'; + +import { useCosList } from '../use-cos-list'; + +vi.mock('@zextras/ui-shared', () => ({ + getCosList: vi.fn(), +})); + +import { getCosList } from '@zextras/ui-shared'; + +function createWrapper() { + const queryClient = new QueryClient({ + defaultOptions: { queries: { retry: false } }, + }); + const Wrapper = ({ children }: { children: ReactNode }) => ( + {children} + ); + Wrapper.displayName = 'Wrapper'; + return Wrapper; +} + +describe('useCosList', () => { + it('should fetch cos list with provided parameters', async () => { + const mockResponse = { cos: [{ id: 'cos-1', name: 'default', a: [] }], more: false, searchTotal: 1 }; + vi.mocked(getCosList).mockResolvedValue(mockResponse); + + const wrapper = createWrapper(); + const { result } = renderHook(() => useCosList({ searchQuery: 'test', limit: 10, offset: 0 }), { + wrapper, + }); + + await waitFor(() => expect(result.current.isSuccess).toBe(true)); + expect(getCosList).toHaveBeenCalledWith('test', 10, 0); + expect(result.current.data).toEqual(mockResponse); + }); + + it('should not fetch when enabled is false', () => { + const wrapper = createWrapper(); + const { result } = renderHook( + () => useCosList({ searchQuery: '', limit: 10, offset: 0, enabled: false }), + { wrapper }, + ); + + expect(result.current.fetchStatus).toBe('idle'); + expect(getCosList).not.toHaveBeenCalled(); + }); + + it('should be enabled by default', async () => { + vi.mocked(getCosList).mockResolvedValue({ cos: [], more: false, searchTotal: 0 }); + + const wrapper = createWrapper(); + const { result } = renderHook(() => useCosList({ searchQuery: '', limit: 50, offset: 0 }), { + wrapper, + }); + + await waitFor(() => expect(result.current.isSuccess).toBe(true)); + expect(getCosList).toHaveBeenCalled(); + }); + + it('should handle fetch errors', async () => { + vi.mocked(getCosList).mockRejectedValue(new Error('Search failed')); + + const wrapper = createWrapper(); + const { result } = renderHook(() => useCosList({ searchQuery: 'test', limit: 10, offset: 0 }), { + wrapper, + }); + + await waitFor(() => expect(result.current.isError).toBe(true), { timeout: 5000 }); + expect(result.current.error).toBeInstanceOf(Error); + }); +}); diff --git a/apps/admin-ui-cos/src/services/tests/use-cos-quota.test.tsx b/apps/admin-ui-cos/src/services/tests/use-cos-quota.test.tsx new file mode 100644 index 000000000..834778d91 --- /dev/null +++ b/apps/admin-ui-cos/src/services/tests/use-cos-quota.test.tsx @@ -0,0 +1,73 @@ +/* + * SPDX-FileCopyrightText: 2026 Zextras + * + * SPDX-License-Identifier: AGPL-3.0-only + */ +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import { renderHook, waitFor } from '@testing-library/react'; +import type { ReactNode } from 'react'; +import { describe, expect, it, vi } from 'vitest'; + +import { useCosQuota } from '../use-cos-quota'; + +vi.mock('../get-cos-quota', () => ({ + getCosQuota: vi.fn(), +})); + +import { getCosQuota } from '../get-cos-quota'; + +function createWrapper() { + const queryClient = new QueryClient({ + defaultOptions: { queries: { retry: false } }, + }); + const Wrapper = ({ children }: { children: ReactNode }) => ( + {children} + ); + Wrapper.displayName = 'Wrapper'; + return Wrapper; +} + +describe('useCosQuota', () => { + it('should not fetch when cosId is undefined', () => { + const wrapper = createWrapper(); + const { result } = renderHook(() => useCosQuota(undefined, true), { wrapper }); + + expect(result.current.fetchStatus).toBe('idle'); + expect(getCosQuota).not.toHaveBeenCalled(); + }); + + it('should not fetch when enabled is false', () => { + const wrapper = createWrapper(); + const { result } = renderHook(() => useCosQuota('cos-1', false), { wrapper }); + + expect(result.current.fetchStatus).toBe('idle'); + expect(getCosQuota).not.toHaveBeenCalled(); + }); + + it('should fetch when cosId and enabled are truthy', async () => { + const mockResponse = { + type: 'success' as const, + totalComputedLimit: { type: 'limited' as const, value: 1024 }, + totalQuotaSource: 'cos' as const, + }; + vi.mocked(getCosQuota).mockResolvedValue(mockResponse); + + const wrapper = createWrapper(); + const { result } = renderHook(() => useCosQuota('cos-1', true), { wrapper }); + + await waitFor(() => expect(result.current.isSuccess).toBe(true)); + expect(getCosQuota).toHaveBeenCalledWith('cos-1'); + expect(result.current.data).toEqual(mockResponse); + }); + + it('should throw on error response type', async () => { + const errorResponse = { type: 'error' as const, error: 'Not found' }; + vi.mocked(getCosQuota).mockResolvedValue(errorResponse); + + const wrapper = createWrapper(); + const { result } = renderHook(() => useCosQuota('cos-err', true), { wrapper }); + + await waitFor(() => expect(result.current.isError).toBe(true), { timeout: 5000 }); + expect(result.current.error?.message).toBe('Not found'); + }); +}); diff --git a/apps/admin-ui-cos/src/services/tests/use-file-quota.test.tsx b/apps/admin-ui-cos/src/services/tests/use-file-quota.test.tsx new file mode 100644 index 000000000..dde9f8573 --- /dev/null +++ b/apps/admin-ui-cos/src/services/tests/use-file-quota.test.tsx @@ -0,0 +1,69 @@ +/* + * SPDX-FileCopyrightText: 2026 Zextras + * + * SPDX-License-Identifier: AGPL-3.0-only + */ +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import { renderHook, waitFor } from '@testing-library/react'; +import type { ReactNode } from 'react'; +import { describe, expect, it, vi } from 'vitest'; + +import { useFileQuota } from '../use-file-quota'; + +vi.mock('@zextras/ui-shared', async (importOriginal) => ({ + ...(await importOriginal()), + getFileQuotaById: vi.fn(), +})); + +import { getFileQuotaById } from '@zextras/ui-shared'; + +function createWrapper() { + const queryClient = new QueryClient({ + defaultOptions: { queries: { retry: false } }, + }); + const Wrapper = ({ children }: { children: ReactNode }) => ( + {children} + ); + Wrapper.displayName = 'Wrapper'; + return Wrapper; +} + +describe('useFileQuota', () => { + it('should not fetch when cosId is undefined', () => { + const wrapper = createWrapper(); + const { result } = renderHook(() => useFileQuota(undefined, true), { wrapper }); + + expect(result.current.fetchStatus).toBe('idle'); + expect(getFileQuotaById).not.toHaveBeenCalled(); + }); + + it('should not fetch when enabled is false', () => { + const wrapper = createWrapper(); + const { result } = renderHook(() => useFileQuota('cos-1', false), { wrapper }); + + expect(result.current.fetchStatus).toBe('idle'); + expect(getFileQuotaById).not.toHaveBeenCalled(); + }); + + it('should fetch file quota with cos type when cosId and enabled are truthy', async () => { + const mockResponse = { limit: '4096' }; + vi.mocked(getFileQuotaById).mockResolvedValue(mockResponse); + + const wrapper = createWrapper(); + const { result } = renderHook(() => useFileQuota('cos-1', true), { wrapper }); + + await waitFor(() => expect(result.current.isSuccess).toBe(true)); + expect(getFileQuotaById).toHaveBeenCalledWith('cos-1', 'cos'); + expect(result.current.data).toEqual(mockResponse); + }); + + it('should handle fetch errors', async () => { + vi.mocked(getFileQuotaById).mockRejectedValue(new Error('Network error')); + + const wrapper = createWrapper(); + const { result } = renderHook(() => useFileQuota('cos-err', true), { wrapper }); + + await waitFor(() => expect(result.current.isError).toBe(true), { timeout: 5000 }); + expect(result.current.error).toBeInstanceOf(Error); + }); +}); diff --git a/apps/admin-ui-cos/src/services/tests/use-invalidate-cos-quota.test.tsx b/apps/admin-ui-cos/src/services/tests/use-invalidate-cos-quota.test.tsx new file mode 100644 index 000000000..1f2e673b3 --- /dev/null +++ b/apps/admin-ui-cos/src/services/tests/use-invalidate-cos-quota.test.tsx @@ -0,0 +1,56 @@ +/* + * SPDX-FileCopyrightText: 2026 Zextras + * + * SPDX-License-Identifier: AGPL-3.0-only + */ +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import { renderHook } from '@testing-library/react'; +import type { ReactNode } from 'react'; +import { describe, expect, it, vi } from 'vitest'; + +import { useInvalidateCosQuota } from '../use-invalidate-cos-quota'; + +function createWrapper() { + const queryClient = new QueryClient(); + const Wrapper = ({ children }: { children: ReactNode }) => ( + {children} + ); + Wrapper.displayName = 'Wrapper'; + return { + wrapper: Wrapper, + queryClient, + }; +} + +describe('useInvalidateCosQuota', () => { + it('should return a function that invalidates cos quota queries', async () => { + const { wrapper, queryClient } = createWrapper(); + const invalidateSpy = vi.spyOn(queryClient, 'invalidateQueries'); + + const { result } = renderHook(() => useInvalidateCosQuota(), { wrapper }); + + await result.current('cos-123'); + + expect(invalidateSpy).toHaveBeenCalledWith({ + queryKey: ['cos', 'cos-quota', 'cos-123'], + }); + }); + + it('should invalidate with different cosIds', async () => { + const { wrapper, queryClient } = createWrapper(); + const invalidateSpy = vi.spyOn(queryClient, 'invalidateQueries'); + + const { result } = renderHook(() => useInvalidateCosQuota(), { wrapper }); + + await result.current('cos-1'); + await result.current('cos-2'); + + expect(invalidateSpy).toHaveBeenCalledTimes(2); + expect(invalidateSpy).toHaveBeenCalledWith({ + queryKey: ['cos', 'cos-quota', 'cos-1'], + }); + expect(invalidateSpy).toHaveBeenCalledWith({ + queryKey: ['cos', 'cos-quota', 'cos-2'], + }); + }); +}); diff --git a/apps/admin-ui-cos/src/services/tests/use-modify-cos.test.tsx b/apps/admin-ui-cos/src/services/tests/use-modify-cos.test.tsx new file mode 100644 index 000000000..a8800cdf3 --- /dev/null +++ b/apps/admin-ui-cos/src/services/tests/use-modify-cos.test.tsx @@ -0,0 +1,165 @@ +/* + * SPDX-FileCopyrightText: 2026 Zextras + * + * SPDX-License-Identifier: AGPL-3.0-only + */ +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import { renderHook, waitFor } from '@testing-library/react'; +import type { ReactNode } from 'react'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +import { useModifyCos } from '../use-modify-cos'; + +vi.mock('@zextras/ui-components', () => ({ + useSnackbar: vi.fn(), +})); + +vi.mock('react-i18next', () => ({ + useTranslation: () => [(key: string, fallback?: string) => fallback ?? key], +})); + +vi.mock('../modify-cos-service', () => ({ + modifyCos: vi.fn(), +})); + +vi.mock('@zextras/ui-shared', async (importOriginal) => ({ + ...(await importOriginal()), + flushCache: vi.fn(), +})); + +import { useSnackbar } from '@zextras/ui-components'; +import { flushCache } from '@zextras/ui-shared'; + +import { modifyCos } from '../modify-cos-service'; + +const mockCreateSnackbar = vi.fn(); + +function createWrapper() { + const queryClient = new QueryClient({ + defaultOptions: { queries: { retry: false } }, + }); + const Wrapper = ({ children }: { children: ReactNode }) => ( + {children} + ); + Wrapper.displayName = 'Wrapper'; + return { + wrapper: Wrapper, + queryClient, + }; +} + +describe('useModifyCos', () => { + beforeEach(() => { + vi.mocked(useSnackbar).mockReturnValue(mockCreateSnackbar); + mockCreateSnackbar.mockClear(); + }); + + const body = { + _jsns: 'urn:zimbraAdmin', + id: { _content: 'cos-1' }, + a: [{ n: 'zimbraPrefLocale', _content: 'en_US' }], + }; + + it('should call modifyCos with the provided body on mutate', async () => { + const mockResponse = { cos: [{ id: 'cos-1', name: 'default' }] }; + vi.mocked(modifyCos).mockResolvedValue(mockResponse); + vi.mocked(flushCache).mockResolvedValue(undefined); + + const { wrapper } = createWrapper(); + const { result } = renderHook(() => useModifyCos('cos-1'), { wrapper }); + + result.current.mutate(body); + + await waitFor(() => expect(result.current.isSuccess).toBe(true)); + expect(modifyCos).toHaveBeenCalledWith(body); + }); + + it('should flush cache on success', async () => { + vi.mocked(modifyCos).mockResolvedValue({ cos: [] }); + vi.mocked(flushCache).mockResolvedValue(undefined); + + const { wrapper } = createWrapper(); + const { result } = renderHook(() => useModifyCos('cos-1'), { wrapper }); + + result.current.mutate(body); + + await waitFor(() => expect(result.current.isSuccess).toBe(true)); + expect(flushCache).toHaveBeenCalledWith('cos', 'id', 'cos-1'); + }); + + it('should show success snackbar on success', async () => { + vi.mocked(modifyCos).mockResolvedValue({ cos: [] }); + vi.mocked(flushCache).mockResolvedValue(undefined); + + const { wrapper } = createWrapper(); + const { result } = renderHook(() => useModifyCos('cos-1'), { wrapper }); + + result.current.mutate(body); + + await waitFor(() => expect(mockCreateSnackbar).toHaveBeenCalledTimes(1)); + expect(mockCreateSnackbar).toHaveBeenCalledWith( + expect.objectContaining({ severity: 'success' }), + ); + }); + + it('should show error snackbar on failure', async () => { + vi.mocked(modifyCos).mockRejectedValue(new Error('Modify failed')); + + const { wrapper } = createWrapper(); + const { result } = renderHook(() => useModifyCos('cos-1'), { wrapper }); + + result.current.mutate(body); + + await waitFor(() => expect(mockCreateSnackbar).toHaveBeenCalledTimes(1)); + expect(mockCreateSnackbar).toHaveBeenCalledWith( + expect.objectContaining({ severity: 'error', label: 'Modify failed' }), + ); + }); + + it('should show fallback error message when error has no message', async () => { + vi.mocked(modifyCos).mockRejectedValue(new Error()); + + const { wrapper } = createWrapper(); + const { result } = renderHook(() => useModifyCos('cos-1'), { wrapper }); + + result.current.mutate(body); + + await waitFor(() => expect(mockCreateSnackbar).toHaveBeenCalledTimes(1)); + expect(mockCreateSnackbar).toHaveBeenCalledWith( + expect.objectContaining({ + severity: 'error', + label: 'Something went wrong. Please try again.', + }), + ); + }); + + it('should not invalidate detail queries when cosId is not provided', async () => { + vi.mocked(modifyCos).mockResolvedValue({ cos: [] }); + vi.mocked(flushCache).mockResolvedValue(undefined); + + const { wrapper, queryClient } = createWrapper(); + const invalidateSpy = vi.spyOn(queryClient, 'invalidateQueries'); + const { result } = renderHook(() => useModifyCos(), { wrapper }); + + result.current.mutate(body); + + await waitFor(() => expect(result.current.isSuccess).toBe(true)); + expect(invalidateSpy).not.toHaveBeenCalled(); + }); + + it('should invalidate detail queries when cosId is provided', async () => { + vi.mocked(modifyCos).mockResolvedValue({ cos: [] }); + vi.mocked(flushCache).mockResolvedValue(undefined); + + const { wrapper, queryClient } = createWrapper(); + const invalidateSpy = vi.spyOn(queryClient, 'invalidateQueries'); + const { result } = renderHook(() => useModifyCos('cos-1'), { wrapper }); + + result.current.mutate(body); + + await waitFor(() => expect(result.current.isSuccess).toBe(true)); + expect(invalidateSpy).toHaveBeenCalledWith({ + queryKey: ['cos', 'detail', 'cos-1'], + }); + }); +}); diff --git a/apps/admin-ui-cos/src/services/tests/use-total-accounts.test.tsx b/apps/admin-ui-cos/src/services/tests/use-total-accounts.test.tsx new file mode 100644 index 000000000..a12ce6438 --- /dev/null +++ b/apps/admin-ui-cos/src/services/tests/use-total-accounts.test.tsx @@ -0,0 +1,76 @@ +/* + * SPDX-FileCopyrightText: 2026 Zextras + * + * SPDX-License-Identifier: AGPL-3.0-only + */ +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import { renderHook, waitFor } from '@testing-library/react'; +import type { ReactNode } from 'react'; +import { describe, expect, it, vi } from 'vitest'; + +import { useTotalAccounts } from '../use-total-accounts'; + +vi.mock('@zextras/ui-shared', () => ({ + searchDirectory: vi.fn(), +})); + +import { searchDirectory } from '@zextras/ui-shared'; + +function createWrapper() { + const queryClient = new QueryClient({ + defaultOptions: { queries: { retry: false } }, + }); + const Wrapper = ({ children }: { children: ReactNode }) => ( + {children} + ); + Wrapper.displayName = 'Wrapper'; + return Wrapper; +} + +describe('useTotalAccounts', () => { + it('should not fetch when cosId is undefined', () => { + const wrapper = createWrapper(); + const { result } = renderHook(() => useTotalAccounts(undefined), { wrapper }); + + expect(result.current.fetchStatus).toBe('idle'); + expect(searchDirectory).not.toHaveBeenCalled(); + }); + + it('should search accounts with correct query', async () => { + vi.mocked(searchDirectory).mockResolvedValue({ account: [], searchTotal: 42 }); + + const wrapper = createWrapper(); + const { result } = renderHook(() => useTotalAccounts('cos-1'), { wrapper }); + + await waitFor(() => expect(result.current.isSuccess).toBe(true)); + expect(searchDirectory).toHaveBeenCalledWith({ + attr: '', + type: 'accounts', + domainName: '', + query: '(&(zimbraCOSId=cos-1)(!(zimbraIsSystemAccount=TRUE)))', + offset: 0, + limit: -1, + }); + expect(result.current.data).toBe(42); + }); + + it('should return 0 when searchTotal is undefined', async () => { + vi.mocked(searchDirectory).mockResolvedValue({ account: [] }); + + const wrapper = createWrapper(); + const { result } = renderHook(() => useTotalAccounts('cos-2'), { wrapper }); + + await waitFor(() => expect(result.current.isSuccess).toBe(true)); + expect(result.current.data).toBe(0); + }); + + it('should handle fetch errors', async () => { + vi.mocked(searchDirectory).mockRejectedValue(new Error('Search failed')); + + const wrapper = createWrapper(); + const { result } = renderHook(() => useTotalAccounts('cos-err'), { wrapper }); + + await waitFor(() => expect(result.current.isError).toBe(true), { timeout: 5000 }); + expect(result.current.error).toBeInstanceOf(Error); + }); +}); diff --git a/apps/admin-ui-cos/src/services/tests/use-total-domains.test.tsx b/apps/admin-ui-cos/src/services/tests/use-total-domains.test.tsx new file mode 100644 index 000000000..9468319a8 --- /dev/null +++ b/apps/admin-ui-cos/src/services/tests/use-total-domains.test.tsx @@ -0,0 +1,76 @@ +/* + * SPDX-FileCopyrightText: 2026 Zextras + * + * SPDX-License-Identifier: AGPL-3.0-only + */ +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import { renderHook, waitFor } from '@testing-library/react'; +import type { ReactNode } from 'react'; +import { describe, expect, it, vi } from 'vitest'; + +import { useTotalDomains } from '../use-total-domains'; + +vi.mock('@zextras/ui-shared', () => ({ + searchDirectory: vi.fn(), +})); + +import { searchDirectory } from '@zextras/ui-shared'; + +function createWrapper() { + const queryClient = new QueryClient({ + defaultOptions: { queries: { retry: false } }, + }); + const Wrapper = ({ children }: { children: ReactNode }) => ( + {children} + ); + Wrapper.displayName = 'Wrapper'; + return Wrapper; +} + +describe('useTotalDomains', () => { + it('should not fetch when cosId is undefined', () => { + const wrapper = createWrapper(); + const { result } = renderHook(() => useTotalDomains(undefined), { wrapper }); + + expect(result.current.fetchStatus).toBe('idle'); + expect(searchDirectory).not.toHaveBeenCalled(); + }); + + it('should search domains with correct query', async () => { + vi.mocked(searchDirectory).mockResolvedValue({ domain: [], searchTotal: 7 }); + + const wrapper = createWrapper(); + const { result } = renderHook(() => useTotalDomains('cos-1'), { wrapper }); + + await waitFor(() => expect(result.current.isSuccess).toBe(true)); + expect(searchDirectory).toHaveBeenCalledWith({ + attr: '', + type: 'domains', + domainName: '', + query: '(zimbraDomainDefaultCOSId=cos-1)', + offset: 0, + limit: -1, + }); + expect(result.current.data).toBe(7); + }); + + it('should return 0 when searchTotal is undefined', async () => { + vi.mocked(searchDirectory).mockResolvedValue({ domain: [] }); + + const wrapper = createWrapper(); + const { result } = renderHook(() => useTotalDomains('cos-2'), { wrapper }); + + await waitFor(() => expect(result.current.isSuccess).toBe(true)); + expect(result.current.data).toBe(0); + }); + + it('should handle fetch errors', async () => { + vi.mocked(searchDirectory).mockRejectedValue(new Error('Search failed')); + + const wrapper = createWrapper(); + const { result } = renderHook(() => useTotalDomains('cos-err'), { wrapper }); + + await waitFor(() => expect(result.current.isError).toBe(true), { timeout: 5000 }); + expect(result.current.error).toBeInstanceOf(Error); + }); +}); diff --git a/apps/admin-ui-cos/src/services/use-core-attributes.ts b/apps/admin-ui-cos/src/services/use-core-attributes.ts new file mode 100644 index 000000000..b4cb4e945 --- /dev/null +++ b/apps/admin-ui-cos/src/services/use-core-attributes.ts @@ -0,0 +1,44 @@ +/* + * SPDX-FileCopyrightText: 2026 Zextras + * + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import { useQuery } from '@tanstack/react-query'; +import { useSnackbar } from '@zextras/ui-components'; +import { type CoreAttributeRequest, getCoreAttributes } from '@zextras/ui-shared'; +import { useEffect } from 'react'; +import { useTranslation } from 'react-i18next'; + +import { cosQueryKeys } from './cos-query-keys'; + +export const useCoreAttributes = (body: Array) => { + const createSnackbar = useSnackbar(); + const [t] = useTranslation(); + + const result = useQuery({ + queryKey: cosQueryKeys.coreAttributes(body), + queryFn: () => getCoreAttributes(body), + enabled: body.length > 0, + staleTime: 30_000, + retry: 1, + refetchOnWindowFocus: false, + }); + + useEffect(() => { + if (result.error) { + createSnackbar({ + key: 'error', + severity: 'error', + label: result.error?.message + ? result.error.message + : t('label.something_wrong_error_msg', 'Something went wrong. Please try again.'), + autoHideTimeout: 3000, + hideButton: true, + replace: true, + }); + } + }, [result.error, createSnackbar, t]); + + return result; +}; diff --git a/apps/admin-ui-cos/src/services/use-cos-accounts.ts b/apps/admin-ui-cos/src/services/use-cos-accounts.ts new file mode 100644 index 000000000..99cdc1a09 --- /dev/null +++ b/apps/admin-ui-cos/src/services/use-cos-accounts.ts @@ -0,0 +1,45 @@ +/* + * SPDX-FileCopyrightText: 2026 Zextras + * + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import { keepPreviousData, useQuery } from '@tanstack/react-query'; +import { searchDirectory } from '@zextras/ui-shared'; + +import { cosQueryKeys } from './cos-query-keys'; +import { generateAccountSearchFilterQuery } from './cos-search-utils'; + +const ACCOUNT_ATTRS = + 'displayName,zimbraId,zimbraAliasTargetId,cn,sn,zimbraMailHost,uid,zimbraCOSId,zimbraAccountStatus,zimbraLastLogonTimestamp,description,zimbraIsSystemAccount,zimbraIsDelegatedAdminAccount,zimbraIsAdminAccount,zimbraIsSystemResource,zimbraAuthTokenValidityValue,zimbraIsExternalVirtualAccount,zimbraMailStatus,zimbraIsAdminGroup,zimbraCalResType,zimbraDomainType,zimbraDomainName,zimbraDomainStatus,zimbraIsDelegatedAdminAccount,zimbraIsAdminAccount,zimbraIsSystemResource,zimbraIsSystemAccount,zimbraIsExternalVirtualAccount,zimbraCreateTimestamp,zimbraLastLogonTimestamp,zimbraMailQuota,zimbraNotes,mail'; + +export const useCosAccounts = ( + cosId: string | undefined, + searchStr: string, + offset: number, + limit: number, +) => { + return useQuery({ + queryKey: cosQueryKeys.accounts(cosId ?? '', searchStr, offset, limit), + queryFn: async () => { + const query = generateAccountSearchFilterQuery(searchStr, cosId); + const data = await searchDirectory({ + attr: ACCOUNT_ATTRS, + type: 'accounts', + domainName: '', + query, + offset, + limit, + }); + return { + accounts: data?.account ?? [], + total: data?.searchTotal ?? 0, + }; + }, + enabled: !!cosId, + placeholderData: keepPreviousData, + staleTime: 30_000, + retry: 1, + refetchOnWindowFocus: false, + }); +}; diff --git a/apps/admin-ui-cos/src/services/use-cos-detail.ts b/apps/admin-ui-cos/src/services/use-cos-detail.ts new file mode 100644 index 000000000..02e9cd3c0 --- /dev/null +++ b/apps/admin-ui-cos/src/services/use-cos-detail.ts @@ -0,0 +1,21 @@ +/* + * SPDX-FileCopyrightText: 2026 Zextras + * + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import { useQuery } from '@tanstack/react-query'; +import { getCosGeneralInformation } from '@zextras/ui-shared'; + +import { cosQueryKeys } from './cos-query-keys'; + +export const useCosDetail = (cosId: string | undefined) => { + return useQuery({ + queryKey: cosQueryKeys.detail(cosId ?? ''), + queryFn: () => getCosGeneralInformation(cosId!), + enabled: !!cosId, + staleTime: 30_000, + retry: 1, + refetchOnWindowFocus: false, + }); +}; diff --git a/apps/admin-ui-cos/src/services/use-cos-domains.ts b/apps/admin-ui-cos/src/services/use-cos-domains.ts new file mode 100644 index 000000000..80fdf5965 --- /dev/null +++ b/apps/admin-ui-cos/src/services/use-cos-domains.ts @@ -0,0 +1,45 @@ +/* + * SPDX-FileCopyrightText: 2026 Zextras + * + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import { keepPreviousData, useQuery } from '@tanstack/react-query'; +import { searchDirectory } from '@zextras/ui-shared'; + +import { cosQueryKeys } from './cos-query-keys'; +import { generateDomainSearchFilterQuery } from './cos-search-utils'; + +const DOMAIN_ATTRS = + 'description,zimbraDomainName,zimbraDomainStatus,zimbraId,zimbraDomainType,zimbraDomainCOSMaxAccounts,zimbraDomainDefaultCOSId'; + +export const useCosDomains = ( + cosId: string | undefined, + searchStr: string, + offset: number, + limit: number, +) => { + return useQuery({ + queryKey: cosQueryKeys.domains(cosId ?? '', searchStr, offset, limit), + queryFn: async () => { + const query = generateDomainSearchFilterQuery(searchStr, cosId); + const data = await searchDirectory({ + attr: DOMAIN_ATTRS, + type: 'domains', + domainName: '', + query, + offset, + limit, + }); + return { + domains: data?.domain ?? [], + total: data?.searchTotal ?? 0, + }; + }, + enabled: !!cosId, + placeholderData: keepPreviousData, + staleTime: 30_000, + retry: 1, + refetchOnWindowFocus: false, + }); +}; diff --git a/apps/admin-ui-cos/src/services/use-cos-list.ts b/apps/admin-ui-cos/src/services/use-cos-list.ts new file mode 100644 index 000000000..f14c834eb --- /dev/null +++ b/apps/admin-ui-cos/src/services/use-cos-list.ts @@ -0,0 +1,29 @@ +/* + * SPDX-FileCopyrightText: 2026 Zextras + * + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import { keepPreviousData, useQuery } from '@tanstack/react-query'; +import { getCosList } from '@zextras/ui-shared'; + +import type { SearchDirectoryResponse } from '../../types/cos'; + +type UseCosListOptions = { + searchQuery: string; + limit: number; + offset: number; + enabled?: boolean; +}; + +export const useCosList = ({ searchQuery, limit, offset, enabled = true }: UseCosListOptions) => { + return useQuery({ + queryKey: ['cos-list', searchQuery, limit, offset], + queryFn: () => getCosList(searchQuery, limit, offset), + enabled, + staleTime: 30_000, + retry: 1, + refetchOnWindowFocus: false, + placeholderData: keepPreviousData, + }); +}; diff --git a/apps/admin-ui-cos/src/services/use-cos-quota.ts b/apps/admin-ui-cos/src/services/use-cos-quota.ts new file mode 100644 index 000000000..d33d851f6 --- /dev/null +++ b/apps/admin-ui-cos/src/services/use-cos-quota.ts @@ -0,0 +1,28 @@ + +/* + * SPDX-FileCopyrightText: 2026 Zextras + * + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import { useQuery } from '@tanstack/react-query'; + +import { cosQueryKeys } from './cos-query-keys'; +import { getCosQuota } from './get-cos-quota'; + +export const useCosQuota = (cosId: string | undefined, enabled: boolean) => { + return useQuery({ + queryKey: cosQueryKeys.cosQuota(cosId ?? ''), + queryFn: async () => { + const res = await getCosQuota(cosId!); + if (res.type === 'error') { + throw new Error(res.error); + } + return res; + }, + enabled: !!cosId && enabled, + staleTime: 30_000, + retry: 1, + refetchOnWindowFocus: false, + }); +}; diff --git a/apps/admin-ui-cos/src/services/use-file-quota.ts b/apps/admin-ui-cos/src/services/use-file-quota.ts new file mode 100644 index 000000000..a0eccd5c8 --- /dev/null +++ b/apps/admin-ui-cos/src/services/use-file-quota.ts @@ -0,0 +1,23 @@ + +/* + * SPDX-FileCopyrightText: 2026 Zextras + * + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import { useQuery } from '@tanstack/react-query'; +import { getFileQuotaById } from '@zextras/ui-shared'; + +import { COS } from '../constants'; +import { cosQueryKeys } from './cos-query-keys'; + +export const useFileQuota = (cosId: string | undefined, enabled: boolean) => { + return useQuery({ + queryKey: cosQueryKeys.fileQuota(cosId ?? ''), + queryFn: () => getFileQuotaById(cosId!, COS), + enabled: !!cosId && enabled, + staleTime: 30_000, + retry: 1, + refetchOnWindowFocus: false, + }); +}; diff --git a/apps/admin-ui-cos/src/services/use-invalidate-cos-quota.ts b/apps/admin-ui-cos/src/services/use-invalidate-cos-quota.ts new file mode 100644 index 000000000..4c338d61b --- /dev/null +++ b/apps/admin-ui-cos/src/services/use-invalidate-cos-quota.ts @@ -0,0 +1,15 @@ +/* + * SPDX-FileCopyrightText: 2026 Zextras + * + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import { useQueryClient } from '@tanstack/react-query'; + +import { cosQueryKeys } from './cos-query-keys'; + +export function useInvalidateCosQuota() { + const queryClient = useQueryClient(); + return (cosId: string) => + queryClient.invalidateQueries({ queryKey: cosQueryKeys.cosQuota(cosId) }); +} diff --git a/apps/admin-ui-cos/src/services/use-modify-cos.ts b/apps/admin-ui-cos/src/services/use-modify-cos.ts new file mode 100644 index 000000000..8e87a3785 --- /dev/null +++ b/apps/admin-ui-cos/src/services/use-modify-cos.ts @@ -0,0 +1,53 @@ +/* + * SPDX-FileCopyrightText: 2026 Zextras + * + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import { useMutation, useQueryClient } from '@tanstack/react-query'; +import { useSnackbar } from '@zextras/ui-components'; +import { flushCache } from '@zextras/ui-shared'; +import { useTranslation } from 'react-i18next'; + +import { CosResponse } from '../../types/cos'; +import { cosQueryKeys } from './cos-query-keys'; +import { modifyCos, ModifyCosBody } from './modify-cos-service'; + +export function useModifyCos(cosId?: string) { + const [t] = useTranslation(); + const createSnackbar = useSnackbar(); + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: (body: ModifyCosBody) => modifyCos(body), + onSuccess: async (_data, body) => { + await flushCache('cos', 'id', body.id._content); + if (cosId) { + queryClient.invalidateQueries({ queryKey: cosQueryKeys.detail(cosId) }); + } + createSnackbar({ + key: 'success', + severity: 'success', + label: t( + 'label.the_last_changes_has_been_saved_successfully', + 'Changes have been saved successfully', + ), + autoHideTimeout: 3000, + hideButton: true, + replace: true, + }); + }, + onError: (error) => { + createSnackbar({ + key: 'error', + severity: 'error', + label: error?.message + ? error?.message + : t('label.something_wrong_error_msg', 'Something went wrong. Please try again.'), + autoHideTimeout: 3000, + hideButton: true, + replace: true, + }); + }, + }); +} diff --git a/apps/admin-ui-cos/src/services/use-total-accounts.ts b/apps/admin-ui-cos/src/services/use-total-accounts.ts new file mode 100644 index 000000000..7b7de3549 --- /dev/null +++ b/apps/admin-ui-cos/src/services/use-total-accounts.ts @@ -0,0 +1,25 @@ +/* + * SPDX-FileCopyrightText: 2026 Zextras + * + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import { useQuery } from '@tanstack/react-query'; +import { searchDirectory } from '@zextras/ui-shared'; + +import { cosQueryKeys } from './cos-query-keys'; + +export const useTotalAccounts = (cosId: string | undefined) => { + return useQuery({ + queryKey: cosQueryKeys.totalAccounts(cosId ?? ''), + queryFn: async () => { + const query = `(&(zimbraCOSId=${cosId})(!(zimbraIsSystemAccount=TRUE)))`; + const data = await searchDirectory({ attr: '', type: 'accounts', domainName: '', query, offset: 0, limit: -1 }); + return data?.searchTotal ?? 0; + }, + enabled: !!cosId, + staleTime: 30_000, + retry: 1, + refetchOnWindowFocus: false, + }); +}; diff --git a/apps/admin-ui-cos/src/services/use-total-domains.ts b/apps/admin-ui-cos/src/services/use-total-domains.ts new file mode 100644 index 000000000..d827eb968 --- /dev/null +++ b/apps/admin-ui-cos/src/services/use-total-domains.ts @@ -0,0 +1,25 @@ +/* + * SPDX-FileCopyrightText: 2026 Zextras + * + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import { useQuery } from '@tanstack/react-query'; +import { searchDirectory } from '@zextras/ui-shared'; + +import { cosQueryKeys } from './cos-query-keys'; + +export const useTotalDomains = (cosId: string | undefined) => { + return useQuery({ + queryKey: cosQueryKeys.totalDomains(cosId ?? ''), + queryFn: async () => { + const query = `(zimbraDomainDefaultCOSId=${cosId})`; + const data = await searchDirectory({ attr: '', type: 'domains', domainName: '', query, offset: 0, limit: -1 }); + return data?.searchTotal ?? 0; + }, + enabled: !!cosId, + staleTime: 30_000, + retry: 1, + refetchOnWindowFocus: false, + }); +}; diff --git a/apps/admin-ui-cos/src/store/cos/store.ts b/apps/admin-ui-cos/src/store/cos/store.ts deleted file mode 100644 index dc61fa594..000000000 --- a/apps/admin-ui-cos/src/store/cos/store.ts +++ /dev/null @@ -1,59 +0,0 @@ -/* - * SPDX-FileCopyrightText: 2022 Zextras - * - * SPDX-License-Identifier: AGPL-3.0-only - */ -import { produce } from 'immer'; -import { create } from 'zustand'; -import { devtools } from 'zustand/middleware'; - -import { Cos } from '../../../types/cos'; - -type CosState = { - cos: Cos; - totalAccount: number; - totalDomain: number; - setCos: (cos: Cos) => void; - removeCos: () => void; - setTotalAccount: (totalAccount: number) => void; - setTotalDomain: (totalDomain: number) => void; - cosView: string; - setCosView: (cosView: string) => void; - reset: () => void; -}; - -const initialState = { - cos: {}, - totalAccount: 0, - totalDomain: 0, - cosView: '' -}; - -export const useCosStore = create()( - devtools((set) => ({ - cos: {}, - totalAccount: 0, - totalDomain: 0, - cosView: '', - setCos: (cos): void => set({ cos }, false, 'setCos'), - setTotalAccount: (totalAccount): void => set({ totalAccount }, false, 'setTotalAccount'), - setTotalDomain: (totalDomain): void => set({ totalDomain }, false, 'setTotalDomain'), - setCosView: (cosView): void => - set( - produce((state) => { - state.cosView = cosView; - }), - false, - 'setCosView' - ), - removeCos: (): void => - set( - produce((state) => { - state.cos = {}; - state.totalAccount = 0; - state.totalDomain = 0; - }) - ), - reset: (): void => set(initialState, false, 'reset') - })) -); diff --git a/apps/admin-ui-cos/src/tests/app.test.tsx b/apps/admin-ui-cos/src/tests/app.test.tsx new file mode 100644 index 000000000..65135a636 --- /dev/null +++ b/apps/admin-ui-cos/src/tests/app.test.tsx @@ -0,0 +1,189 @@ +/* + * SPDX-FileCopyrightText: 2026 Zextras + * + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import { render } from '@testing-library/react'; +import { type Mock, vi } from 'vitest'; + +vi.mock('@zextras/ui-components', () => ({ + PrimaryBarTooltip: ({ children }: { children: React.ReactNode }) => ( +
{children}
+ ), +})); + +vi.mock('react-i18next', () => ({ + useTranslation: () => [(key: string, fallback?: string) => fallback || key, { i18n: {} }], + Trans: ({ defaults }: { defaults: string }) => <>{defaults}, +})); + +vi.mock('react-router', () => ({ + useNavigate: vi.fn(), +})); + +vi.mock('../views/app-view', () => ({ + AppView: () =>
, +})); + +import { addRoute, registerActions, removeRoute, useCurrentUserRights } from '@zextras/ui-shared'; +import { useNavigate } from 'react-router'; + +import App from '../app'; +import { + APP_ID, + COS_ROUTE_ID, + CREATE_NEW_COS_ROUTE_ID, + MANAGE, + PRIMARY_BAR_COS, +} from '../constants'; +import { CosTooltipView } from '../views/cos-tooltip-view'; + +const RIGHTS_WITH_COS_AND_CREATE = [ + { + type: 'cos', + all: [{ getAttrs: [{ all: true }] }], + }, + { + type: 'global', + all: [{ getAttrs: [{ all: true }] }], + }, +]; + +const RIGHTS_WITH_COS_ONLY = [ + { + type: 'cos', + all: [{ getAttrs: [{ all: true }] }], + }, + { + type: 'global', + all: [], + }, +]; + +const RIGHTS_WITHOUT_COS = [ + { + type: 'account', + all: [{ getAttrs: [{ all: true }] }], + }, +]; + +describe('App', () => { + beforeEach(() => { + (useCurrentUserRights as Mock).mockReturnValue({ data: RIGHTS_WITH_COS_AND_CREATE }); + (useNavigate as Mock).mockReturnValue(vi.fn()); + }); + + it('should call addRoute with correct config when COS rights are present', () => { + render(); + + expect(addRoute).toHaveBeenCalledWith( + expect.objectContaining({ + route: COS_ROUTE_ID, + position: 2, + visible: true, + label: 'COS', + primaryBar: 'SettingsModOutline', + trackerLabel: PRIMARY_BAR_COS, + }), + ); + }); + + it('should pass AppView and CosTooltipView to addRoute', () => { + render(); + + const callArgs = (addRoute as Mock).mock.calls[0][0]; + expect(callArgs.appView).toBeDefined(); + expect(callArgs.tooltip).toBe(CosTooltipView); + }); + + it('should pass management section to addRoute', () => { + render(); + + const callArgs = (addRoute as Mock).mock.calls[0][0]; + expect(callArgs.primarybarSection).toEqual({ + id: 'manage', + label: 'Management', + position: 3, + }); + }); + + it('should call removeRoute when COS rights are absent', () => { + (useCurrentUserRights as Mock).mockReturnValue({ data: RIGHTS_WITHOUT_COS }); + + render(); + + expect(removeRoute).toHaveBeenCalledWith(COS_ROUTE_ID); + expect(addRoute).not.toHaveBeenCalled(); + }); + + it('should call removeRoute when rights data is undefined', () => { + (useCurrentUserRights as Mock).mockReturnValue({ data: undefined }); + + render(); + + expect(removeRoute).toHaveBeenCalledWith(COS_ROUTE_ID); + expect(addRoute).not.toHaveBeenCalled(); + }); + + it('should register action with id new-cos and type new', () => { + render(); + + expect(registerActions).toHaveBeenCalledWith( + expect.objectContaining({ + id: 'new-cos', + type: 'new', + }), + ); + }); + + it('should disable create COS action when createCosRight is false', () => { + (useCurrentUserRights as Mock).mockReturnValue({ data: RIGHTS_WITH_COS_ONLY }); + + render(); + + const registeredCall = (registerActions as Mock).mock.calls[0][0]; + const action = registeredCall.action(); + expect(action.disabled).toBe(true); + }); + + it('should enable create COS action when createCosRight is true', () => { + render(); + + const registeredCall = (registerActions as Mock).mock.calls[0][0]; + const action = registeredCall.action(); + expect(action.disabled).toBe(false); + }); + + it('should navigate to create COS route on action onClick', () => { + const mockNavigate = vi.fn(); + (useNavigate as Mock).mockReturnValue(mockNavigate); + + render(); + + const registeredCall = (registerActions as Mock).mock.calls[0][0]; + const action = registeredCall.action(); + action.onClick(); + + expect(mockNavigate).toHaveBeenCalledWith( + `/${MANAGE}/${COS_ROUTE_ID}/${CREATE_NEW_COS_ROUTE_ID}`, + ); + }); + + it('should register action with correct group, label, icon, and primary', () => { + render(); + + const registeredCall = (registerActions as Mock).mock.calls[0][0]; + const action = registeredCall.action(); + expect(action.group).toBe(APP_ID); + expect(action.id).toBe('new-cos'); + expect(action.label).toBe('Create New COS'); + expect(action.icon).toBe(''); + expect(action.primary).toBe(false); + }); + + it('should render null', () => { + const { container } = render(); + expect(container.innerHTML).toBe(''); + }); +}); diff --git a/apps/admin-ui-cos/src/utils/check-rights.ts b/apps/admin-ui-cos/src/utils/check-rights.ts new file mode 100644 index 000000000..7485e55b6 --- /dev/null +++ b/apps/admin-ui-cos/src/utils/check-rights.ts @@ -0,0 +1,36 @@ +/* + * SPDX-FileCopyrightText: 2022 Zextras + * + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import { find } from 'lodash-es'; + +import { COS, CREATE_COS, GLOBAL, LIST_COS } from '../constants'; + +export type RightEntry = { + all?: Array<{ + getAttrs?: Array<{ all?: boolean }>; + setAttrs?: Array<{ all?: boolean }>; + right?: Array<{ n: string }>; + }>; + type: string; +}; + +export function checkShowCOS(rights: RightEntry[] | undefined): boolean { + const rightsConfig = find(rights, { type: COS }) ?? { all: [], type: COS }; + return !!( + rightsConfig?.all?.[0]?.getAttrs?.[0]?.all ?? + rightsConfig?.all?.[0]?.setAttrs?.[0]?.all ?? + find(rightsConfig?.all?.[0]?.right, { n: LIST_COS }) + ); +} + +export function checkCreateCosRight(rights: RightEntry[] | undefined): boolean { + const rightsConfig = find(rights, { type: GLOBAL }) ?? { all: [], type: GLOBAL }; + return !!( + rightsConfig?.all?.[0]?.getAttrs?.[0]?.all ?? + rightsConfig?.all?.[0]?.setAttrs?.[0]?.all ?? + find(rightsConfig?.all?.[0]?.right, { n: CREATE_COS }) + ); +} diff --git a/apps/admin-ui-cos/src/utils/tests/check-rights.test.ts b/apps/admin-ui-cos/src/utils/tests/check-rights.test.ts new file mode 100644 index 000000000..09817dd3c --- /dev/null +++ b/apps/admin-ui-cos/src/utils/tests/check-rights.test.ts @@ -0,0 +1,175 @@ +/* + * SPDX-FileCopyrightText: 2026 Zextras + * + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import { describe, expect, it } from 'vitest'; + +import { checkCreateCosRight, checkShowCOS } from '../check-rights'; + +const COS_RIGHTS_WITH_GET_ATTRS = [ + { + type: 'cos', + all: [{ getAttrs: [{ all: true }] }], + }, +]; + +const COS_RIGHTS_WITH_SET_ATTRS = [ + { + type: 'cos', + all: [{ setAttrs: [{ all: true }] }], + }, +]; + +const COS_RIGHTS_WITH_LIST_COS = [ + { + type: 'cos', + all: [{ right: [{ n: 'listCos' }] }], + }, +]; + +const COS_RIGHTS_EMPTY = [ + { + type: 'cos', + all: [{ getAttrs: [{ all: false }], setAttrs: [{ all: false }], right: [] }], + }, +]; + +const GLOBAL_RIGHTS_WITH_GET_ATTRS = [ + { + type: 'global', + all: [{ getAttrs: [{ all: true }] }], + }, +]; + +const GLOBAL_RIGHTS_WITH_SET_ATTRS = [ + { + type: 'global', + all: [{ setAttrs: [{ all: true }] }], + }, +]; + +const GLOBAL_RIGHTS_WITH_CREATE_COS = [ + { + type: 'global', + all: [{ right: [{ n: 'createCos' }] }], + }, +]; + +const GLOBAL_RIGHTS_EMPTY = [ + { + type: 'global', + all: [{ getAttrs: [{ all: false }], setAttrs: [{ all: false }], right: [] }], + }, +]; + +describe('checkShowCOS', () => { + it('should return true when COS rights have getAttrs.all set to true', () => { + expect(checkShowCOS(COS_RIGHTS_WITH_GET_ATTRS)).toBe(true); + }); + + it('should return true when COS rights have setAttrs.all set to true', () => { + expect(checkShowCOS(COS_RIGHTS_WITH_SET_ATTRS)).toBe(true); + }); + + it('should return true when COS rights contain listCos right', () => { + expect(checkShowCOS(COS_RIGHTS_WITH_LIST_COS)).toBe(true); + }); + + it('should return false when COS entry exists but no matching attrs or rights', () => { + expect(checkShowCOS(COS_RIGHTS_EMPTY)).toBe(false); + }); + + it('should return false when rights is undefined', () => { + expect(checkShowCOS(undefined)).toBe(false); + }); + + it('should return false when no COS type entry exists in rights', () => { + const rights = [{ type: 'account', all: [{ getAttrs: [{ all: true }] }] }]; + expect(checkShowCOS(rights)).toBe(false); + }); + + it('should fall through to listCos check when getAttrs and setAttrs are absent', () => { + const rights = [ + { + type: 'cos', + all: [{ right: [{ n: 'listCos' }] }], + }, + ]; + expect(checkShowCOS(rights)).toBe(true); + }); + + it('should return false when COS all array is empty', () => { + const rights = [{ type: 'cos', all: [] }]; + expect(checkShowCOS(rights)).toBe(false); + }); + + it('should return false when COS all is undefined', () => { + const rights = [{ type: 'cos' }]; + expect(checkShowCOS(rights)).toBe(false); + }); + + it('should ignore non-COS type entries', () => { + const rights = [ + { type: 'domain', all: [{ getAttrs: [{ all: true }] }] }, + { type: 'cos', all: [] }, + ]; + expect(checkShowCOS(rights)).toBe(false); + }); +}); + +describe('checkCreateCosRight', () => { + it('should return true when GLOBAL rights have getAttrs.all set to true', () => { + expect(checkCreateCosRight(GLOBAL_RIGHTS_WITH_GET_ATTRS)).toBe(true); + }); + + it('should return true when GLOBAL rights have setAttrs.all set to true', () => { + expect(checkCreateCosRight(GLOBAL_RIGHTS_WITH_SET_ATTRS)).toBe(true); + }); + + it('should return true when GLOBAL rights contain createCos right', () => { + expect(checkCreateCosRight(GLOBAL_RIGHTS_WITH_CREATE_COS)).toBe(true); + }); + + it('should return false when GLOBAL entry exists but no matching attrs or rights', () => { + expect(checkCreateCosRight(GLOBAL_RIGHTS_EMPTY)).toBe(false); + }); + + it('should return false when rights is undefined', () => { + expect(checkCreateCosRight(undefined)).toBe(false); + }); + + it('should return false when no GLOBAL type entry exists in rights', () => { + const rights = [{ type: 'account', all: [{ getAttrs: [{ all: true }] }] }]; + expect(checkCreateCosRight(rights)).toBe(false); + }); + + it('should fall through to createCos check when getAttrs and setAttrs are absent', () => { + const rights = [ + { + type: 'global', + all: [{ right: [{ n: 'createCos' }] }], + }, + ]; + expect(checkCreateCosRight(rights)).toBe(true); + }); + + it('should return false when GLOBAL all array is empty', () => { + const rights = [{ type: 'global', all: [] }]; + expect(checkCreateCosRight(rights)).toBe(false); + }); + + it('should return false when GLOBAL all is undefined', () => { + const rights = [{ type: 'global' }]; + expect(checkCreateCosRight(rights)).toBe(false); + }); + + it('should ignore non-GLOBAL type entries', () => { + const rights = [ + { type: 'cos', all: [{ getAttrs: [{ all: true }] }] }, + { type: 'global', all: [] }, + ]; + expect(checkCreateCosRight(rights)).toBe(false); + }); +}); diff --git a/apps/admin-ui-cos/src/views/app-view.tsx b/apps/admin-ui-cos/src/views/app-view.tsx index deb68d146..b0a0645c8 100644 --- a/apps/admin-ui-cos/src/views/app-view.tsx +++ b/apps/admin-ui-cos/src/views/app-view.tsx @@ -19,7 +19,7 @@ function getContainerStyle(isPrimaryBarExpanded: boolean) { }; } -const AppView: FC = () => { +export const AppView: FC = () => { const isPrimaryBarExpanded = usePrimaryBarState(); return ( @@ -52,5 +52,3 @@ const AppView: FC = () => { ); }; - -export default AppView; diff --git a/apps/admin-ui-cos/src/views/cos-tooltip-view.tsx b/apps/admin-ui-cos/src/views/cos-tooltip-view.tsx new file mode 100644 index 000000000..55a3502fa --- /dev/null +++ b/apps/admin-ui-cos/src/views/cos-tooltip-view.tsx @@ -0,0 +1,32 @@ +/* + * SPDX-FileCopyrightText: 2026 Zextras + * + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import { PrimaryBarTooltip } from '@zextras/ui-components'; +import { Trans, useTranslation } from 'react-i18next'; + +export const CosTooltipView = () => { + const [t] = useTranslation(); + return ( + +

+ }} + t={t} + /> +

+

+ }} + t={t} + /> +

+
+ ); +}; diff --git a/apps/admin-ui-cos/src/views/cos/advanced/advanced-form.tsx b/apps/admin-ui-cos/src/views/cos/advanced/advanced-form.tsx new file mode 100644 index 000000000..d2edd4e55 --- /dev/null +++ b/apps/admin-ui-cos/src/views/cos/advanced/advanced-form.tsx @@ -0,0 +1,257 @@ +/* + * SPDX-FileCopyrightText: 2022 Zextras + * + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import { useForm } from '@tanstack/react-form'; +import { useQueryClient } from '@tanstack/react-query'; +import { useSelector } from '@tanstack/react-store'; +import { Container, useSnackbar } from '@zextras/ui-components'; +import { type GetCoreAttributesResponse, setCoreAttributes } from '@zextras/ui-shared'; +import { useTranslation } from 'react-i18next'; +import { useParams } from 'react-router'; + +import { AccountType } from '../../../../types/account'; +import { TimeItems } from '../../../../types/general'; +import { + BACKUP_ENABLED, + BACKUP_SELF_UNDELETE_ALLOWED, + COS, + ZIMBRA_ADMIN_URN, +} from '../../../constants'; +import { cosQueryKeys } from '../../../services/cos-query-keys'; +import { type ComputedLimit, type QuotaSource } from '../../../services/get-cos-quota'; +import { ModifyCosBody } from '../../../services/modify-cos-service'; +import { useModifyCos } from '../../../services/use-modify-cos'; +import { FormPageLayout } from '../../form-page-layout'; +import { useCosQuotaState } from './hooks/use-cos-quota-state'; +import { cosAdvancedSchema } from './schema'; +import COSEmailRetentionPolicy from './sections/email-retention-policy'; +import COSFailedLoginPolicy from './sections/failed-login-policy'; +import COSForwarding from './sections/forwarding'; +import COSGeneralOptions from './sections/general-options'; +import COSPassword from './sections/password'; +import { COSQuotas } from './sections/quotas'; +import { COSTimeoutPolicy } from './sections/timeout-policy'; +import { CosAdvancedFormValues } from './types'; + +const EXCLUDED_ATTRIBUTES_WHEN_TOTAL_QUOTA_ACTIVE: Array = [ + 'zimbraMailQuota', + 'zimbraQuotaWarnPercent', + 'zimbraQuotaWarnInterval', + 'zimbraQuotaWarnMessage', +] satisfies Array; + +const COS_ADVANCED_FIELD_DEFAULTS: Array<[keyof AccountType, string]> = [ + ['zimbraMailForwardingAddressMaxLength', ''], + ['zimbraMailForwardingAddressMaxNumAddrs', ''], + ['zimbraMailQuota', ''], + ['zimbraContactMaxNumEntries', ''], + ['zimbraQuotaWarnPercent', ''], + ['zimbraQuotaWarnInterval', ''], + ['zimbraQuotaWarnMessage', ''], + ['zimbraPasswordLocked', 'FALSE'], + ['zimbraPasswordMinLength', ''], + ['zimbraPasswordMaxLength', ''], + ['zimbraPasswordMinUpperCaseChars', ''], + ['zimbraPasswordMinLowerCaseChars', ''], + ['zimbraPasswordMinPunctuationChars', ''], + ['zimbraPasswordMinNumericChars', ''], + ['zimbraPasswordMinDigitsOrPuncs', ''], + ['zimbraPasswordMinAge', ''], + ['zimbraPasswordMaxAge', ''], + ['zimbraPasswordEnforceHistory', ''], + ['zimbraPasswordBlockCommonEnabled', 'FALSE'], + ['zimbraPasswordLockoutEnabled', 'FALSE'], + ['zimbraPasswordLockoutMaxFailures', ''], + ['zimbraPasswordLockoutDuration', ''], + ['zimbraPasswordLockoutFailureLifetime', ''], + ['zimbraAdminAuthTokenLifetime', ''], + ['zimbraAuthTokenLifetime', ''], + ['zimbraMailIdleSessionTimeout', ''], + ['zimbraMailMessageLifetime', ''], + ['zimbraMailTrashLifetime', ''], + ['zimbraMailSpamLifetime', ''], + ['zimbraFreebusyExchangeUserOrg', ''], +]; + +const ADVANCED_FIELD_KEYS = new Set(COS_ADVANCED_FIELD_DEFAULTS.map(([key]) => key)); + +const BACKUP_FIELD_KEYS: ReadonlySet = new Set([ + BACKUP_ENABLED, + BACKUP_SELF_UNDELETE_ALLOWED, +]); + +function saveBackupAttributes( + value: CosAdvancedFormValues, + cosName: string | undefined, +): Promise { + const backupAttributes: Record = { + [BACKUP_ENABLED]: { + value: value.backupEnabled, + objectName: cosName, + configType: COS, + }, + [BACKUP_SELF_UNDELETE_ALLOWED]: { + value: value.backupSelfUndeleteAllowed, + objectName: cosName, + configType: COS, + }, + }; + return setCoreAttributes(backupAttributes); +} + +type CosQuotaData = { + totalComputedLimit: ComputedLimit; + totalQuotaSource: QuotaSource; +}; + +type CosAdvancedFormProps = { + cosData: AccountType; + cosName: string | undefined; + cosQuotaData: CosQuotaData | undefined; + coreAttributesData: GetCoreAttributesResponse | undefined; + readonlyCOS: boolean; + isAdvanced: boolean; + isTotalQuotaActive: boolean; +}; + +export const CosAdvancedForm = ({ + cosData, + cosName, + cosQuotaData, + coreAttributesData, + readonlyCOS, + isAdvanced, + isTotalQuotaActive, +}: CosAdvancedFormProps) => { + const { cosId } = useParams(); + const [t] = useTranslation(); + const createSnackbar = useSnackbar(); + const queryClient = useQueryClient(); + const modifyCosMutation = useModifyCos(cosId); + + const quotaState = useCosQuotaState({ cosData, cosQuotaData, isTotalQuotaActive, isAdvanced }); + + const timeItems: TimeItems = [ + { label: t('label.seconds', 'Seconds'), value: 's' }, + { label: t('label.minutes', 'Minutes'), value: 'm' }, + { label: t('label.hours', 'Hours'), value: 'h' }, + { label: t('label.days', 'Days'), value: 'd' }, + ]; + + const errorMessage = t( + 'label.something_wrong_error_msg', + 'Something went wrong. Please try again.', + ); + + const pageTitle = t('cos.advanced', 'Advanced'); + + const formDefaultValues = { + ...cosData, + backupEnabled: !!coreAttributesData?.attributes?.[BACKUP_ENABLED]?.[0]?.value, + backupSelfUndeleteAllowed: + !!coreAttributesData?.attributes?.[BACKUP_SELF_UNDELETE_ALLOWED]?.[0]?.value, + }; + + const form = useForm({ + defaultValues: formDefaultValues, + validators: { + onChange: cosAdvancedSchema, + onSubmit: cosAdvancedSchema, + }, + onSubmit: async ({ value }) => { + const { zimbraId = '' } = cosData; + + try { + await saveBackupAttributes(value, cosName); + if (isAdvanced && cosName) { + queryClient.invalidateQueries({ + queryKey: cosQueryKeys.coreAttributes([ + { + configType: COS, + configName: [cosName], + attrName: [BACKUP_SELF_UNDELETE_ALLOWED, BACKUP_ENABLED], + }, + ]), + }); + } + } catch { + createSnackbar({ + key: 'error', + severity: 'error', + label: errorMessage, + autoHideTimeout: 3000, + hideButton: true, + replace: true, + }); + return; + } + await quotaState.save(zimbraId); + + const cosAdvancedToSave = isTotalQuotaActive + ? Object.fromEntries( + Object.entries(value).filter( + ([key]) => !EXCLUDED_ATTRIBUTES_WHEN_TOTAL_QUOTA_ACTIVE.includes(key), + ), + ) + : value; + + const attributes = Object.keys(cosAdvancedToSave) + .filter( + (key) => ADVANCED_FIELD_KEYS.has(key as keyof AccountType) && !BACKUP_FIELD_KEYS.has(key), + ) + .map((ele) => ({ + n: ele, + _content: cosAdvancedToSave[ele as keyof AccountType]?.toString() ?? '', + })); + + const body: ModifyCosBody = { + _jsns: ZIMBRA_ADMIN_URN, + id: { _content: zimbraId }, + a: attributes, + }; + + modifyCosMutation.mutate(body, { + onSuccess: () => { + quotaState.handleSuccess(zimbraId); + form.reset(value, { keepDefaultValues: true }); + quotaState.reset(); + }, + }); + }, + }); + + const isFormDirty = useSelector(form.store, (state) => !state.isDefaultValue); + const isDirty = isFormDirty || quotaState.isDirty; + + return ( + form.handleSubmit()} + onCancel={() => { + form.reset(); + quotaState.reset(); + }} + unsavedChanges={isDirty} + > + + {isAdvanced && } + + + + + + + + + ); +}; diff --git a/apps/admin-ui-cos/src/views/cos/advanced/cos-advanced.tsx b/apps/admin-ui-cos/src/views/cos/advanced/cos-advanced.tsx new file mode 100644 index 000000000..6c72b286c --- /dev/null +++ b/apps/admin-ui-cos/src/views/cos/advanced/cos-advanced.tsx @@ -0,0 +1,114 @@ +/* + * SPDX-FileCopyrightText: 2022 Zextras + * + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import { useCurrentUserRights, useIsAdvanced, useTotalQuotaActive } from '@zextras/ui-shared'; +import { find } from 'lodash-es'; +import { useParams } from 'react-router'; + +import { AccountType } from '../../../../types/account'; +import { Attribute } from '../../../../types/attribute'; +import { BACKUP_ENABLED, BACKUP_SELF_UNDELETE_ALLOWED, COS } from '../../../constants'; +import { useCoreAttributes } from '../../../services/use-core-attributes'; +import { useCosDetail } from '../../../services/use-cos-detail'; +import { useCosQuota } from '../../../services/use-cos-quota'; +import { CosAdvancedForm } from './advanced-form'; + +const COS_ADVANCED_FIELD_DEFAULTS: Array<[keyof AccountType, string]> = [ + ['zimbraMailForwardingAddressMaxLength', ''], + ['zimbraMailForwardingAddressMaxNumAddrs', ''], + ['zimbraMailQuota', ''], + ['zimbraContactMaxNumEntries', ''], + ['zimbraQuotaWarnPercent', ''], + ['zimbraQuotaWarnInterval', ''], + ['zimbraQuotaWarnMessage', ''], + ['zimbraPasswordLocked', 'FALSE'], + ['zimbraPasswordMinLength', ''], + ['zimbraPasswordMaxLength', ''], + ['zimbraPasswordMinUpperCaseChars', ''], + ['zimbraPasswordMinLowerCaseChars', ''], + ['zimbraPasswordMinPunctuationChars', ''], + ['zimbraPasswordMinNumericChars', ''], + ['zimbraPasswordMinDigitsOrPuncs', ''], + ['zimbraPasswordMinAge', ''], + ['zimbraPasswordMaxAge', ''], + ['zimbraPasswordEnforceHistory', ''], + ['zimbraPasswordBlockCommonEnabled', 'FALSE'], + ['zimbraPasswordLockoutEnabled', 'FALSE'], + ['zimbraPasswordLockoutMaxFailures', ''], + ['zimbraPasswordLockoutDuration', ''], + ['zimbraPasswordLockoutFailureLifetime', ''], + ['zimbraAdminAuthTokenLifetime', ''], + ['zimbraAuthTokenLifetime', ''], + ['zimbraMailIdleSessionTimeout', ''], + ['zimbraMailMessageLifetime', ''], + ['zimbraMailTrashLifetime', ''], + ['zimbraMailSpamLifetime', ''], + ['zimbraFreebusyExchangeUserOrg', ''], +]; + +function buildCosData(cosInformation: Array | undefined): AccountType { + if (!cosInformation?.length) return {} as AccountType; + const obj: AccountType = {}; + cosInformation.forEach((item) => { + obj[item?.n as keyof AccountType] = item._content; + }); + COS_ADVANCED_FIELD_DEFAULTS.forEach(([key, defaultVal]) => { + if (!obj[key]) obj[key] = defaultVal; + }); + return obj; +} + +export const CosAdvanced = () => { + const { cosId } = useParams(); + const { data: cosDetailData, isPending } = useCosDetail(cosId); + const cosInformation = cosDetailData?.cos?.[0]?.a; + const cosName = cosDetailData?.cos?.[0]?.name; + const { data: rights = [] } = useCurrentUserRights(); + const isAdvanced = useIsAdvanced(); + const isTotalQuotaActive = useTotalQuotaActive(); + const cosData = buildCosData(cosInformation); + + const { data: cosQuotaData, isPending: isCosQuotaPending } = useCosQuota( + cosData?.zimbraId, + !!cosData?.zimbraId && isAdvanced && isTotalQuotaActive, + ); + + const coreAttributesBody = + isAdvanced && cosName + ? [ + { + configType: COS, + configName: [cosName], + attrName: [BACKUP_SELF_UNDELETE_ALLOWED, BACKUP_ENABLED], + }, + ] + : []; + + const { data: coreAttributesData, isPending: isCoreAttributesPending } = + useCoreAttributes(coreAttributesBody); + + const rightsConfig = find(rights, { type: COS }) || { all: [], type: COS }; + const readonlyCOS = !rightsConfig?.all?.[0]?.setAttrs?.[0]?.all; + + const isQuotaLoading = isTotalQuotaActive && isCosQuotaPending; + const isBackupLoading = isAdvanced && isCoreAttributesPending; + + if (isPending || isQuotaLoading || isBackupLoading) { + return ; + } + + return ( + + ); +}; diff --git a/apps/admin-ui-cos/src/views/cos/advanced/cos-email-retention-policy.tsx b/apps/admin-ui-cos/src/views/cos/advanced/cos-email-retention-policy.tsx deleted file mode 100644 index 6ad9a42c8..000000000 --- a/apps/admin-ui-cos/src/views/cos/advanced/cos-email-retention-policy.tsx +++ /dev/null @@ -1,184 +0,0 @@ -/* - * SPDX-FileCopyrightText: 2026 Zextras - * - * SPDX-License-Identifier: AGPL-3.0-only - */ - -import { - Container, - Input, - ListRow, - Row, - Select, - SingleSelectionOnChange, -} from '@zextras/ui-components'; -import { ChangeEvent, FC } from 'react'; -import { useTranslation } from 'react-i18next'; - -import { TimeItems } from '../../../../types/general'; - -type EmailRetentionPolicyProps = { - zimbraMailMessageLifetimeNum: string | undefined; - zimbraMailMessageLifetimeType: string | undefined; - zimbraMailTrashLifetimeNum: string | undefined; - zimbraMailTrashLifetimeType: string | undefined; - zimbraMailSpamLifetimeNum: string | undefined; - zimbraMailSpamLifetimeType: string | undefined; - readonlyCOS: boolean; - timeItems: TimeItems; - onZimbraMailMessageLifetimeNumChange: (e: ChangeEvent) => void; - onZimbraMailMessageLifetimeTypeChange: SingleSelectionOnChange; - onZimbraMailTrashLifetimeNumChange: (e: ChangeEvent) => void; - onZimbraMailTrashLifetimeTypeChange: SingleSelectionOnChange; - onZimbraMailSpamLifetimeNumChange: (e: ChangeEvent) => void; - onZimbraMailSpamLifetimeTypeChange: SingleSelectionOnChange; -}; - -const COSEmailRetentionPolicy: FC = ({ - zimbraMailMessageLifetimeNum, - zimbraMailMessageLifetimeType, - zimbraMailTrashLifetimeNum, - zimbraMailTrashLifetimeType, - zimbraMailSpamLifetimeNum, - zimbraMailSpamLifetimeType, - readonlyCOS, - timeItems, - onZimbraMailMessageLifetimeNumChange, - onZimbraMailMessageLifetimeTypeChange, - onZimbraMailTrashLifetimeNumChange, - onZimbraMailTrashLifetimeTypeChange, - onZimbraMailSpamLifetimeNumChange, - onZimbraMailSpamLifetimeTypeChange, -}) => { - const [t] = useTranslation(); - const labels = { - timeRange: t('cos.time_range', 'Time Range'), - email: { - retentionPolicy: t('cos.email_retention_policy', 'Email Retention Policy'), - messageLifetime: t('cos.email_message_lifetime', 'E-mail message lifetime'), - }, - trashedMessageLifetime: t('cos.trashed_message_lifetime', 'Trashed message lifetime'), - spamMessageLifetime: t('cos.spam_message_lifetime', 'Spam message lifetime'), - }; - return ( - - - {labels.email.retentionPolicy} - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - ); -}; - -export default COSForwarding; diff --git a/apps/admin-ui-cos/src/views/cos/advanced/cos-general-options.tsx b/apps/admin-ui-cos/src/views/cos/advanced/cos-general-options.tsx deleted file mode 100644 index 4567d1087..000000000 --- a/apps/admin-ui-cos/src/views/cos/advanced/cos-general-options.tsx +++ /dev/null @@ -1,89 +0,0 @@ -/* - * SPDX-FileCopyrightText: 2025 Zextras - * - * SPDX-License-Identifier: AGPL-3.0-only - */ -import { Container, ListRow, Row, Switch } from '@zextras/ui-components'; -import { FC } from 'react'; -import { useTranslation } from 'react-i18next'; - -import { BACKUP_ENABLED, BACKUP_SELF_UNDELETE_ALLOWED } from '../../../constants'; - -type AdvancedBackupAttributes = { - [BACKUP_ENABLED]: boolean | undefined; - [BACKUP_SELF_UNDELETE_ALLOWED]: boolean | undefined; -}; - -type AdvancedBackupAttributesKeys = keyof AdvancedBackupAttributes; - -const COSGeneralOptions: FC<{ - cosAdvancedBackupAttributes: AdvancedBackupAttributes; - readonlyCOS: boolean; - changeBackupAttribute: (key: AdvancedBackupAttributesKeys) => void; -}> = ({ cosAdvancedBackupAttributes, readonlyCOS, changeBackupAttribute }) => { - const [t] = useTranslation(); - - const labels = { - backup: { - selfUndelete: t('label.allow_restore_message', 'Allow user to restore messages'), - enableDisable: t('label.backup_enabled', 'Enable / Disable Backup') - }, - generalOptions: t('cos.general_options', 'General Options') - }; - return ( - - - {labels.generalOptions} - - - - - - - changeBackupAttribute(BACKUP_ENABLED)} - iconColor="primary" - disabled={readonlyCOS} - /> - - - changeBackupAttribute(BACKUP_SELF_UNDELETE_ALLOWED)} - iconColor="primary" - disabled={readonlyCOS} - /> - - - - - - - ); -}; - -export default COSGeneralOptions; diff --git a/apps/admin-ui-cos/src/views/cos/advanced/cos-quotas-new.tsx b/apps/admin-ui-cos/src/views/cos/advanced/cos-quotas-new.tsx deleted file mode 100644 index d6dea856b..000000000 --- a/apps/admin-ui-cos/src/views/cos/advanced/cos-quotas-new.tsx +++ /dev/null @@ -1,160 +0,0 @@ -/* - * SPDX-FileCopyrightText: 2026 Zextras - * - * SPDX-License-Identifier: AGPL-3.0-only - */ -import { - Container, - IconCheckbox, - Input, - Padding, - Switch, - SwitchProps, - Tooltip, -} from '@zextras/ui-components'; -import React, { FC, useCallback, useEffect, useMemo, useState } from 'react'; -import { useTranslation } from 'react-i18next'; - -import { ComputedLimit, QuotaSource } from '../../../services/get-cos-quota'; -import { BytesToGB, GbToBytes } from '../../utility/utils'; - -type COSQuotasNewProps = { - totalComputedQuotaLimit: ComputedLimit | undefined; - totalQuotaSource?: QuotaSource; - initialTotalComputedQuotaLimit: ComputedLimit | undefined; - onChange: (value?: ComputedLimit) => void; - readonlyCOS: boolean; -}; - -const COSQuotasNew: FC = ({ - totalComputedQuotaLimit, - totalQuotaSource, - initialTotalComputedQuotaLimit, - onChange, - readonlyCOS, -}) => { - const [t] = useTranslation(); - const [quotaValue, setQuotaValue] = useState(undefined); - - useEffect(() => { - if (totalComputedQuotaLimit === undefined) { - setQuotaValue(undefined); - } else if (totalComputedQuotaLimit.type === 'unlimited') { - setQuotaValue('unlimited'); - } else { - setQuotaValue( - totalComputedQuotaLimit.value > 0 ? BytesToGB(totalComputedQuotaLimit.value) : undefined, - ); - } - }, [totalComputedQuotaLimit]); - - const inputOnChange = useCallback( - (e: React.ChangeEvent) => { - const filteredStringValue = e.target.value.replaceAll(/\D/g, ''); - const parsedValue = - filteredStringValue === '' ? undefined : Number.parseInt(filteredStringValue, 10); - const valueInGB = parsedValue !== undefined && parsedValue > 0 ? parsedValue : undefined; - const valueInBytes = valueInGB === undefined ? undefined : (GbToBytes(valueInGB) as number); - onChange(valueInBytes ? { type: 'limited', value: valueInBytes } : undefined); - setQuotaValue(valueInGB); - }, - [onChange], - ); - - const switchOnChange = useCallback>(() => { - setQuotaValue((prevState) => { - if (prevState === 'unlimited') { - if (initialTotalComputedQuotaLimit && initialTotalComputedQuotaLimit.type === 'limited') { - onChange({ type: 'limited', value: initialTotalComputedQuotaLimit.value }); - return BytesToGB(initialTotalComputedQuotaLimit.value); - } else { - onChange({ type: 'limited', value: GbToBytes(1) }); - return 1; - } - } else { - onChange({ type: 'unlimited' }); - return 'unlimited'; - } - }); - }, [initialTotalComputedQuotaLimit, onChange]); - - const switchValue = useMemo(() => { - return quotaValue === 'unlimited'; - }, [quotaValue]); - - const inputValue = useMemo(() => { - return typeof quotaValue === 'number' ? String(quotaValue) : ''; - }, [quotaValue]); - - const icon = totalQuotaSource === 'global' ? 'GlobeOutline' : undefined; - - const tooltipLabel = - totalQuotaSource === 'global' - ? t('label.quota.source.global', 'Quota inherited from the global configuration') - : undefined; - - const showQuotaSourceIcon = totalQuotaSource !== undefined && totalQuotaSource !== 'cos'; - - const onChangeReset = useCallback(() => { - setQuotaValue(undefined); - onChange(undefined); - }, [onChange]); - - const CustomElement = () => ( - - - - {t('cos_quota.click_to_revert', 'Click to revert to the inherited value')} - - - - } - > - null} - /> - - ); - - return ( - - - - {t('label.unlimited_quota', 'Unlimited quota')} - - - - {showQuotaSourceIcon && ( - - - - )} - - - ); -}; - -export default COSQuotasNew; diff --git a/apps/admin-ui-cos/src/views/cos/advanced/cos-quotas.tsx b/apps/admin-ui-cos/src/views/cos/advanced/cos-quotas.tsx deleted file mode 100644 index 59dcae49c..000000000 --- a/apps/admin-ui-cos/src/views/cos/advanced/cos-quotas.tsx +++ /dev/null @@ -1,259 +0,0 @@ -/* - * SPDX-FileCopyrightText: 2025 Zextras - * - * SPDX-License-Identifier: AGPL-3.0-only - */ -import { - Container, - CustomTextArea, - Input, - ListRow, - Padding, - Row, - Select, - SingleSelectionOnChange, -} from '@zextras/ui-components'; -import { ChangeEvent, FC } from 'react'; -import { useTranslation } from 'react-i18next'; - -import { AccountType } from '../../../../types/account'; -import { TimeItems } from '../../../../types/general'; -import { ComputedLimit, QuotaSource } from '../../../services/get-cos-quota'; -import COSQuotasNew from './cos-quotas-new'; - -type QuotaProps = { - isTotalQuotaActive: boolean; - isAdvanced: boolean; - showFileQuotaLimitMsg: boolean; - showAccountQuotaLimitMsg: boolean; - readonlyCOS: boolean; - cosAdvanced: AccountType; - initFileQuotaLimitGBValue: string | undefined; - fileQuotaLimitGBValue: string | undefined; - accountQuotaGBValue: string; - zimbraQuotaWarnIntervalNum: string | undefined; - timeItems: TimeItems; - zimbraQuotaWarnIntervalType: string; - onFileQuotaChange: (e: ChangeEvent) => void; - onZimbraMailQuotaChange: (e: ChangeEvent) => void; - changeValue: (e: ChangeEvent) => void; - onZimbraQuotaWarnIntervalNumChange: (e: ChangeEvent) => void; - onZimbraQuotaWarnIntervalTypeChange: SingleSelectionOnChange; - totalComputedQuotaLimit?: ComputedLimit; - totalQuotaSource?: QuotaSource; - initialTotalComputedQuotaLimit?: ComputedLimit; - onTotalQuotaChange: (value?: ComputedLimit) => void; -}; - -const COSQuotas: FC = ({ - isTotalQuotaActive, - isAdvanced, - showFileQuotaLimitMsg, - showAccountQuotaLimitMsg, - readonlyCOS, - cosAdvanced, - initFileQuotaLimitGBValue, - fileQuotaLimitGBValue, - accountQuotaGBValue, - zimbraQuotaWarnIntervalNum, - timeItems, - zimbraQuotaWarnIntervalType, - onFileQuotaChange, - onZimbraMailQuotaChange, - changeValue, - onZimbraQuotaWarnIntervalNumChange, - onZimbraQuotaWarnIntervalTypeChange, - totalComputedQuotaLimit, - totalQuotaSource, - initialTotalComputedQuotaLimit, - onTotalQuotaChange, -}) => { - const [t] = useTranslation(); - - const labels = { - quotas: t('cos.quotas', 'Quotas'), - filesAccountQuotaGB: t('cos.files_account_quota_gb', 'Files Account quota (GB)'), - mailsAccountQuotaGB: t('cos.mails_account_quota_gb', 'Mails Account quota (GB)'), - maximumDigitsAllowed: t( - 'label.maximum_3_digits_allowed_decimal_point', - 'Maximum 3 digits allowed after the decimal point', - ), - maxContactsAllowedInTheFolder: t( - 'cos.max_contacts_allowed_in_the_folder', - 'Max contacts allowed in the folder', - ), - percentageThresholdForQuotaWarningMessages: t( - 'cos.percentage_threshold_for_quota_warning', - 'Percentage threshold for quota warning messages (%)', - ), - minimumDurationOfTimeBetweenQuotaWarnings: t( - 'cos.minimum_duration_of_time_between_quota_warnings', - 'Minimum duration of time between quota warnings', - ), - timeRange: t('cos.time_range', 'Time Range'), - quotaWarningMessageTemplate: t( - 'cos.quota_warning_message_template', - 'Quota warning message template', - ), - }; - return ( - - {labels.quotas} - - - - {!isTotalQuotaActive ? ( - <> - {isAdvanced && initFileQuotaLimitGBValue && ( - - - {showFileQuotaLimitMsg && ( - - - - {labels.maximumDigitsAllowed} - - - - )} - - )} - - - {showAccountQuotaLimitMsg && ( - - - - {labels.maximumDigitsAllowed} - - - - )} - - - ) : ( - - )} - - - - - - - {!isTotalQuotaActive && ( - - - - - - - - - - - - - - - - - - - - ) => { + if (!isValidDecimalInput(e.target.value)) return; + const dp = e.target.value?.split('.')[1]; + if (dp && dp.length > 3) { + setShowMsg(true); + return; + } + setShowMsg(false); + isUserEditing.current = true; + setRawGB(e.target.value); + fieldState.handleChange( + e.target.value ? String(Math.round(GbToBytes(e.target.value))) : '', + ); + }} + onBlur={onBlur} + hasError={hasError} + description={description} + disabled={disabled} + CustomIcon={RevertIcon} + /> + {showMsg && ( + + + + {maximumDigitsLabel} + + + + )} + + ); +}; + +type QuotaGBFieldProps = { + form: CosFormApi; + name: keyof CosAdvancedFormValues; + label: string; + maximumDigitsLabel: string; + disabled: boolean; +}; + +export const QuotaGBField = ({ + form, + name, + label, + maximumDigitsLabel, + disabled, +}: QuotaGBFieldProps) => { + const [t] = useTranslation(); + const isSubmitted = useSelector(form.store, (s) => s.submissionAttempts > 0); + return ( + + {(field) => { + const error = getFieldErrorProps(field, isSubmitted, t); + return ( + field.handleBlur()} + /> + ); + }} + + ); +}; diff --git a/apps/admin-ui-cos/src/views/cos/advanced/fields/quota-revert-icon.tsx b/apps/admin-ui-cos/src/views/cos/advanced/fields/quota-revert-icon.tsx new file mode 100644 index 000000000..4e393b730 --- /dev/null +++ b/apps/admin-ui-cos/src/views/cos/advanced/fields/quota-revert-icon.tsx @@ -0,0 +1,32 @@ +/* + * SPDX-FileCopyrightText: 2026 Zextras + * + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import { IconCheckbox, Padding, Tooltip } from '@zextras/ui-components'; + +type QuotaRevertIconProps = { + label: string; + onClick: () => void; +}; + +export const QuotaRevertIcon = ({ label, onClick }: QuotaRevertIconProps) => ( + + + {label} + + + } + > + null} + iconAriaLabel={label} + /> + +); diff --git a/apps/admin-ui-cos/src/views/cos/advanced/fields/time-field-group.tsx b/apps/admin-ui-cos/src/views/cos/advanced/fields/time-field-group.tsx new file mode 100644 index 000000000..38995d456 --- /dev/null +++ b/apps/admin-ui-cos/src/views/cos/advanced/fields/time-field-group.tsx @@ -0,0 +1,80 @@ +/* + * SPDX-FileCopyrightText: 2026 Zextras + * + * SPDX-License-Identifier: AGPL-3.0-only + */ +import { useSelector } from '@tanstack/react-store'; +import { Container, Input, ListRow, Select } from '@zextras/ui-components'; +import { ChangeEvent, FC } from 'react'; +import { useTranslation } from 'react-i18next'; + +import { AccountType } from '../../../../../types/account'; +import { TimeItems } from '../../../../../types/general'; +import { CosFormApi } from '../types'; +import { getFieldErrorProps } from './field-error'; + +type TimeFieldGroupProps = { + form: CosFormApi; + name: keyof AccountType; + label: string; + readonlyCOS: boolean; + timeItems: TimeItems; + disabled?: boolean; +}; + +export const TimeFieldGroup: FC = ({ + form, + name, + label, + readonlyCOS, + timeItems, + disabled, +}) => { + const [t] = useTranslation(); + const isSubmitted = useSelector(form.store, (s) => s.submissionAttempts > 0); + return ( + + {(field) => { + const raw = String(field.state.value ?? ''); + const hasUnit = raw.length >= 2; + const num = hasUnit ? raw.slice(0, -1) : ''; + const unit = hasUnit ? raw.slice(-1) : ''; + const isDisabled = disabled || readonlyCOS; + const error = getFieldErrorProps(field, isSubmitted, t); + return ( + + + ) => { + const v = e.target.value; + field.handleChange(v ? `${v}${unit}` : ''); + }} + onBlur={() => field.handleBlur()} + hasError={error.hasError} + description={error.description} + disabled={isDisabled} + /> + + + ) => field.handleChange(e.target.value)} + onBlur={() => field.handleBlur()} + hasError={error.hasError} + description={error.description} + disabled={disabled} + /> + ); + }} + + ); +}; diff --git a/apps/admin-ui-cos/src/views/cos/advanced/hooks/tests/use-cos-quota-state.test.ts b/apps/admin-ui-cos/src/views/cos/advanced/hooks/tests/use-cos-quota-state.test.ts new file mode 100644 index 000000000..c7be82fbd --- /dev/null +++ b/apps/admin-ui-cos/src/views/cos/advanced/hooks/tests/use-cos-quota-state.test.ts @@ -0,0 +1,52 @@ +/* + * SPDX-FileCopyrightText: 2026 Zextras + * + * SPDX-License-Identifier: AGPL-3.0-only + */ +import { act, renderHook } from '@testing-library/react'; +import { describe, expect, it, vi } from 'vitest'; + +vi.mock('../../../../../services/use-file-quota', () => ({ + useFileQuota: () => ({ data: undefined }), +})); +vi.mock('../../../../../services/use-invalidate-cos-quota', () => ({ + useInvalidateCosQuota: () => vi.fn(), +})); +vi.mock('../../../../../services/set-cos-quota', () => ({ setCosQuota: vi.fn() })); +vi.mock('../../../../../services/unset-cos-quota', () => ({ unsetCosQuota: vi.fn() })); +vi.mock('@zextras/ui-shared', async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + isValidDecimalInput: (v: string) => /^\d*\.?\d*$/.test(v), + setFileQuotaLimitById: vi.fn().mockResolvedValue(undefined), + resetFileQuotaLimitById: vi.fn().mockResolvedValue(undefined), + }; +}); + +import { AccountType } from '../../../../../../types/account'; +import { useCosQuotaState } from '../use-cos-quota-state'; + +const cosData = { zimbraId: 'cos-1', zimbraMailQuota: '' } as AccountType; + +describe('useCosQuotaState', () => { + it('isDirty is false by default', () => { + const { result } = renderHook(() => + useCosQuotaState({ cosData, cosQuotaData: undefined, isTotalQuotaActive: false, isAdvanced: false }), + ); + expect(result.current.isDirty).toBe(false); + }); + + it('isDirty is false after reset', () => { + const { result } = renderHook(() => + useCosQuotaState({ cosData, cosQuotaData: undefined, isTotalQuotaActive: false, isAdvanced: true }), + ); + act(() => { + result.current.onFileQuotaChange({ + target: { value: '5' }, + } as React.ChangeEvent); + }); + act(() => result.current.reset()); + expect(result.current.isDirty).toBe(false); + }); +}); diff --git a/apps/admin-ui-cos/src/views/cos/advanced/hooks/use-cos-quota-state.ts b/apps/admin-ui-cos/src/views/cos/advanced/hooks/use-cos-quota-state.ts new file mode 100644 index 000000000..dc663f7e7 --- /dev/null +++ b/apps/admin-ui-cos/src/views/cos/advanced/hooks/use-cos-quota-state.ts @@ -0,0 +1,187 @@ +/* + * SPDX-FileCopyrightText: 2026 Zextras + * + * SPDX-License-Identifier: AGPL-3.0-only + */ +import { + isValidDecimalInput, + resetFileQuotaLimitById, + setFileQuotaLimitById, +} from '@zextras/ui-shared'; +import { ChangeEvent, useState } from 'react'; + +import { AccountType } from '../../../../../types/account'; +import { COS } from '../../../../constants'; +import { ComputedLimit, QuotaSource } from '../../../../services/get-cos-quota'; +import { setCosQuota } from '../../../../services/set-cos-quota'; +import { unsetCosQuota } from '../../../../services/unset-cos-quota'; +import { useFileQuota } from '../../../../services/use-file-quota'; +import { useInvalidateCosQuota } from '../../../../services/use-invalidate-cos-quota'; +import { BytesToGB, GbToBytes } from '../../../utility/utils'; + +type CosQuotaData = { + totalComputedLimit: ComputedLimit; + totalQuotaSource: QuotaSource; +}; + +type Params = { + cosData: AccountType; + cosQuotaData: CosQuotaData | undefined; + isTotalQuotaActive: boolean; + isAdvanced: boolean; +}; + +function computedLimitsEqual(a: ComputedLimit, b: ComputedLimit): boolean { + if (a.type !== b.type) return false; + if (a.type === 'limited' && b.type === 'limited') return a.value === b.value; + return true; +} + +function getQuotaSource( + override: ComputedLimit | null | undefined, + initialSource: QuotaSource | undefined, +): QuotaSource | undefined { + if (override === null) return initialSource; + if (override === undefined) return 'global' as QuotaSource; + return 'cos' as QuotaSource; +} + +type UseCosQuotaState = { + fileQuotaLimitGBValue: string | undefined; + initFileQuotaLimitGBValue: string | undefined; + showFileQuotaLimitMsg: boolean; + totalComputedQuotaLimit: ComputedLimit | undefined; + totalQuotaSource: QuotaSource | undefined; + initialTotalComputedQuotaLimit: ComputedLimit | undefined; + showQuotaRevertButton: boolean; + onFileQuotaChange: (e: ChangeEvent) => void; + onTotalQuotaChange: (value?: ComputedLimit) => void; + isDirty: boolean; + save: (zimbraId: string) => Promise; + handleSuccess: (zimbraId: string) => void; + reset: () => void; +}; + +export function useCosQuotaState({ + cosData, + cosQuotaData, + isTotalQuotaActive, + isAdvanced, +}: Params): UseCosQuotaState { + const invalidateCosQuota = useInvalidateCosQuota(); + + const [fileQuotaOverride, setFileQuotaOverride] = useState(undefined); + const [showFileQuotaLimitMsg, setShowFileQuotaLimitMsg] = useState(false); + + const { data: fileQuotaData } = useFileQuota( + cosData?.zimbraId, + !!cosData?.zimbraId && isAdvanced && !isTotalQuotaActive, + ); + + const initFileQuotaLimitGBValue = fileQuotaData?.limit + ? BytesToGB(fileQuotaData.limit).toFixed(2) + : undefined; + const fileQuotaLimitGBValue = fileQuotaOverride ?? initFileQuotaLimitGBValue; + + const initTotalComputedQuotaLimit = cosQuotaData?.totalComputedLimit; + const initTotalQuotaSource = cosQuotaData?.totalQuotaSource; + + const [initialQuota] = useState<{ + limit: ComputedLimit; + source: QuotaSource; + } | null>(() => + cosQuotaData + ? { limit: cosQuotaData.totalComputedLimit, source: cosQuotaData.totalQuotaSource } + : null, + ); + + const [totalQuotaOverride, setTotalQuotaOverride] = useState( + null, + ); + + const totalComputedQuotaLimit = + totalQuotaOverride === null ? initTotalComputedQuotaLimit : totalQuotaOverride; + const totalQuotaSource = getQuotaSource(totalQuotaOverride, initTotalQuotaSource); + + const showQuotaRevertButton = + totalQuotaSource === 'cos' && + initialQuota !== null && + !computedLimitsEqual(totalComputedQuotaLimit ?? initialQuota.limit, initialQuota.limit); + + function onFileQuotaChange(e: ChangeEvent): void { + if (!isValidDecimalInput(e.target.value)) return; + const dp = e.target.value?.split('.')[1]; + if (dp && dp.length > 3) { + setShowFileQuotaLimitMsg(true); + return; + } + setShowFileQuotaLimitMsg(false); + setFileQuotaOverride(e.target.value); + } + + function onTotalQuotaChange(value?: ComputedLimit): void { + if ( + value && + initialQuota?.source === 'global' && + computedLimitsEqual(value, initialQuota.limit) + ) { + setTotalQuotaOverride(undefined); + } else { + setTotalQuotaOverride(value); + } + } + + const isDirty = + (fileQuotaLimitGBValue !== undefined && initFileQuotaLimitGBValue !== fileQuotaLimitGBValue) || + (isTotalQuotaActive && + totalQuotaOverride !== null && + initialQuota !== null && + !computedLimitsEqual(totalComputedQuotaLimit ?? initialQuota.limit, initialQuota.limit)); + + async function save(zimbraId: string): Promise { + if (!isTotalQuotaActive || totalQuotaOverride === null) return; + if (totalQuotaOverride) { + await setCosQuota(zimbraId, totalQuotaOverride); + } else { + await unsetCosQuota(zimbraId); + } + await invalidateCosQuota(zimbraId); + setTotalQuotaOverride(null); + } + + function handleSuccess(zimbraId: string): void { + if (!isTotalQuotaActive && isAdvanced && initFileQuotaLimitGBValue !== fileQuotaLimitGBValue) { + if (fileQuotaLimitGBValue) { + setFileQuotaLimitById( + zimbraId, + Math.round(GbToBytes(fileQuotaLimitGBValue)).toString(), + COS, + ).then(() => setShowFileQuotaLimitMsg(false)); + } else { + resetFileQuotaLimitById(zimbraId, COS).then(() => setShowFileQuotaLimitMsg(false)); + } + } + } + + function reset(): void { + setFileQuotaOverride(undefined); + setShowFileQuotaLimitMsg(false); + setTotalQuotaOverride(null); + } + + return { + fileQuotaLimitGBValue, + initFileQuotaLimitGBValue, + showFileQuotaLimitMsg, + totalComputedQuotaLimit, + totalQuotaSource, + initialTotalComputedQuotaLimit: initTotalComputedQuotaLimit, + showQuotaRevertButton, + onFileQuotaChange, + onTotalQuotaChange, + isDirty, + save, + handleSuccess, + reset, + }; +} diff --git a/apps/admin-ui-cos/src/views/cos/advanced/schema.ts b/apps/admin-ui-cos/src/views/cos/advanced/schema.ts new file mode 100644 index 000000000..b1abf006e --- /dev/null +++ b/apps/admin-ui-cos/src/views/cos/advanced/schema.ts @@ -0,0 +1,101 @@ +/* + * SPDX-FileCopyrightText: 2026 Zextras + * + * SPDX-License-Identifier: AGPL-3.0-only + */ +import { z } from 'zod'; + +export const COS_VALIDATION_MESSAGES: Record = { + 'cos.validation.non_negative_integer': 'Enter a whole number of 0 or more', + 'cos.validation.percent_range': 'Enter a whole number between 0 and 100', + 'cos.validation.invalid_duration': 'Enter a whole number of 0 or more', + 'cos.validation.max_less_than_min_length': + 'Maximum length must be greater than or equal to the minimum length', + 'cos.validation.max_less_than_min_age': + 'Maximum age must be greater than or equal to the minimum age', +}; + +// Form values are string-encoded; empty means "inherit / no limit" and is always valid. +function isNonNegativeInteger(value: string): boolean { + return value === '' || /^\d+$/.test(value); +} + +function isPercent(value: string): boolean { + return value === '' || (/^\d+$/.test(value) && Number(value) <= 100); +} + +// Composite time fields store "" (e.g. "7d"); validate the numeric portion. +function isDuration(value: string): boolean { + return value === '' || /^\d+[smhd]?$/.test(value); +} + +function isComparableInteger(value: unknown): value is string { + return typeof value === 'string' && /^\d+$/.test(value); +} + +const optionalNonNegativeInt = z + .string() + .refine(isNonNegativeInteger, { message: 'cos.validation.non_negative_integer' }) + .optional(); + +const optionalPercent = z + .string() + .refine(isPercent, { message: 'cos.validation.percent_range' }) + .optional(); + +const optionalDuration = z + .string() + .refine(isDuration, { message: 'cos.validation.invalid_duration' }) + .optional(); + +export const cosAdvancedSchema = z + .object({ + // Required by the form-value type; not validated beyond their boolean shape. + backupEnabled: z.boolean(), + backupSelfUndeleteAllowed: z.boolean(), + // Forwarding + zimbraMailForwardingAddressMaxLength: optionalNonNegativeInt, + zimbraMailForwardingAddressMaxNumAddrs: optionalNonNegativeInt, + // Password + zimbraPasswordMinLength: optionalNonNegativeInt, + zimbraPasswordMaxLength: optionalNonNegativeInt, + zimbraPasswordMinUpperCaseChars: optionalNonNegativeInt, + zimbraPasswordMinLowerCaseChars: optionalNonNegativeInt, + zimbraPasswordMinPunctuationChars: optionalNonNegativeInt, + zimbraPasswordMinNumericChars: optionalNonNegativeInt, + zimbraPasswordMinDigitsOrPuncs: optionalNonNegativeInt, + zimbraPasswordMinAge: optionalNonNegativeInt, + zimbraPasswordMaxAge: optionalNonNegativeInt, + zimbraPasswordEnforceHistory: optionalNonNegativeInt, + // Quotas + zimbraMailQuota: optionalNonNegativeInt, + zimbraContactMaxNumEntries: optionalNonNegativeInt, + zimbraQuotaWarnPercent: optionalPercent, + zimbraQuotaWarnInterval: optionalDuration, + // Failed login policy + zimbraPasswordLockoutMaxFailures: optionalNonNegativeInt, + zimbraPasswordLockoutDuration: optionalDuration, + zimbraPasswordLockoutFailureLifetime: optionalDuration, + // Timeout policy + zimbraAdminAuthTokenLifetime: optionalDuration, + zimbraAuthTokenLifetime: optionalDuration, + zimbraMailIdleSessionTimeout: optionalDuration, + // Email retention policy + zimbraMailMessageLifetime: optionalDuration, + zimbraMailTrashLifetime: optionalDuration, + zimbraMailSpamLifetime: optionalDuration, + }) + .refine( + (data) => + !isComparableInteger(data.zimbraPasswordMaxLength) || + !isComparableInteger(data.zimbraPasswordMinLength) || + Number(data.zimbraPasswordMaxLength) >= Number(data.zimbraPasswordMinLength), + { message: 'cos.validation.max_less_than_min_length', path: ['zimbraPasswordMaxLength'] }, + ) + .refine( + (data) => + !isComparableInteger(data.zimbraPasswordMaxAge) || + !isComparableInteger(data.zimbraPasswordMinAge) || + Number(data.zimbraPasswordMaxAge) >= Number(data.zimbraPasswordMinAge), + { message: 'cos.validation.max_less_than_min_age', path: ['zimbraPasswordMaxAge'] }, + ); diff --git a/apps/admin-ui-cos/src/views/cos/advanced/sections/email-retention-policy.tsx b/apps/admin-ui-cos/src/views/cos/advanced/sections/email-retention-policy.tsx new file mode 100644 index 000000000..946105c77 --- /dev/null +++ b/apps/admin-ui-cos/src/views/cos/advanced/sections/email-retention-policy.tsx @@ -0,0 +1,97 @@ +/* + * SPDX-FileCopyrightText: 2026 Zextras + * + * SPDX-License-Identifier: AGPL-3.0-only + */ +import { Container, Row } from '@zextras/ui-components'; +import { FC } from 'react'; +import { useTranslation } from 'react-i18next'; + +import { TimeItems } from '../../../../../types/general'; +import { TimeFieldGroup } from '../fields/time-field-group'; +import { CosFormApi } from '../types'; + +type EmailRetentionPolicyProps = { + form: CosFormApi; + readonlyCOS: boolean; + timeItems: TimeItems; +}; + +const COSEmailRetentionPolicy: FC = ({ + form, + readonlyCOS, + timeItems, +}) => { + const [t] = useTranslation(); + const labels = { + email: { + retentionPolicy: t('cos.email_retention_policy', 'Email Retention Policy'), + messageLifetime: t('cos.email_message_lifetime', 'E-mail message lifetime'), + }, + trashedMessageLifetime: t('cos.trashed_message_lifetime', 'Trashed message lifetime'), + spamMessageLifetime: t('cos.spam_message_lifetime', 'Spam message lifetime'), + }; + return ( + + + {labels.email.retentionPolicy} + + + + + + + + + + + + + + + + + + + ); +}; + +export default COSEmailRetentionPolicy; diff --git a/apps/admin-ui-cos/src/views/cos/advanced/sections/failed-login-policy.tsx b/apps/admin-ui-cos/src/views/cos/advanced/sections/failed-login-policy.tsx new file mode 100644 index 000000000..fd65d07cd --- /dev/null +++ b/apps/admin-ui-cos/src/views/cos/advanced/sections/failed-login-policy.tsx @@ -0,0 +1,199 @@ +/* + * SPDX-FileCopyrightText: 2025 Zextras + * + * SPDX-License-Identifier: AGPL-3.0-only + */ +import { useSelector } from '@tanstack/react-store'; +import { Container, Input, ListRow, Row, Select, Switch } from '@zextras/ui-components'; +import { ChangeEvent, FC } from 'react'; +import { useTranslation } from 'react-i18next'; + +import { TimeItems } from '../../../../../types/general'; +import { getFieldErrorProps } from '../fields/field-error'; +import { CosValidatedInput } from '../fields/validated-input'; +import { CosFormApi } from '../types'; + +type FailedLoginPolicyProps = { + form: CosFormApi; + readonlyCOS: boolean; + timeItems: TimeItems; +}; + +const COSFailedLoginPolicy: FC = ({ form, readonlyCOS, timeItems }) => { + const [t] = useTranslation(); + const isLockoutEnabled = useSelector( + form.store, + (s) => s.values.zimbraPasswordLockoutEnabled === 'TRUE', + ); + const isSubmitted = useSelector(form.store, (s) => s.submissionAttempts > 0); + const labels = { + failedLoginPolicy: t('cos.failed_login_policy', 'Failed Login Policy'), + timeRange: t('cos.time_range', 'Time Range'), + passwordLockout: { + enabled: t('cos.enable_failed_login_lockout', 'Enable failed login lockout'), + maxFailures: t( + 'cos.number_of_consecutive_failed_login_allowed', + 'Number of consecutive failed logins allowed', + ), + duration: t('cos.time_to_lockout_account', 'Time to lockout the account'), + failureLifetime: t( + 'cos.time_window_failed_logins_must_occur_to_lock_account', + 'Time window in which the failed logins must occur to lock the account:', + ), + }, + }; + + return ( + + + {labels.failedLoginPolicy} + + + + + + + {(field) => ( + + field.handleChange(field.state.value === 'TRUE' ? 'FALSE' : 'TRUE') + } + iconColor="primary" + disabled={readonlyCOS} + /> + )} + + + + + + + + + + + + + + + + + + + {(field) => { + const raw = String(field.state.value ?? ''); + const hasUnit = raw.length >= 2; + const num = hasUnit ? raw.slice(0, -1) : ''; + const unit = hasUnit ? raw.slice(-1) : ''; + const error = getFieldErrorProps(field, isSubmitted, t); + return ( + <> + + ) => + field.handleChange(e.target.value ? `${e.target.value}${unit}` : '') + } + onBlur={() => field.handleBlur()} + hasError={error.hasError} + description={error.description} + disabled={!isLockoutEnabled || readonlyCOS} + /> + + + ) => + field.handleChange(e.target.value ? `${e.target.value}${unit}` : '') + } + onBlur={() => field.handleBlur()} + hasError={error.hasError} + description={error.description} + disabled={!isLockoutEnabled || readonlyCOS} + /> + + + - - - @@ -184,42 +166,34 @@ const COSPassword: FC = ({ > - - - - @@ -235,22 +209,18 @@ const COSPassword: FC = ({ > - - @@ -266,13 +236,19 @@ const COSPassword: FC = ({ > - changeSwitchOption('zimbraPasswordBlockCommonEnabled')} - iconColor="primary" - disabled={readonlyCOS} - /> + + {(field) => ( + + field.handleChange(field.state.value === 'TRUE' ? 'FALSE' : 'TRUE') + } + iconColor="primary" + disabled={readonlyCOS} + /> + )} + diff --git a/apps/admin-ui-cos/src/views/cos/advanced/sections/quotas-new.tsx b/apps/admin-ui-cos/src/views/cos/advanced/sections/quotas-new.tsx new file mode 100644 index 000000000..b509f82bc --- /dev/null +++ b/apps/admin-ui-cos/src/views/cos/advanced/sections/quotas-new.tsx @@ -0,0 +1,129 @@ +/* + * SPDX-FileCopyrightText: 2026 Zextras + * + * SPDX-License-Identifier: AGPL-3.0-only + */ +import { Container, Input, Switch, Tooltip } from '@zextras/ui-components'; +import React, { FC, useState } from 'react'; +import { useTranslation } from 'react-i18next'; + +import { ComputedLimit, QuotaSource } from '../../../../services/get-cos-quota'; +import { BytesToGB, GbToBytes } from '../../../utility/utils'; +import { QuotaRevertIcon } from '../fields/quota-revert-icon'; + +type COSQuotasNewProps = { + totalComputedQuotaLimit: ComputedLimit | undefined; + totalQuotaSource?: QuotaSource; + initialTotalComputedQuotaLimit: ComputedLimit | undefined; + onChange: (value?: ComputedLimit) => void; + readonlyCOS: boolean; + showRevertButton: boolean; +}; + +export const COSQuotasNew: FC = ({ + totalComputedQuotaLimit, + totalQuotaSource, + initialTotalComputedQuotaLimit, + onChange, + readonlyCOS, + showRevertButton, +}) => { + const [t] = useTranslation(); + + const derivedQuotaValue: number | 'unlimited' | undefined = (() => { + if (totalComputedQuotaLimit === undefined) return undefined; + if (totalComputedQuotaLimit.type === 'unlimited') return 'unlimited'; + if (totalComputedQuotaLimit.value > 0) return BytesToGB(totalComputedQuotaLimit.value); + return undefined; + })(); + + const [quotaOverride, setQuotaOverride] = useState(undefined); + const quotaValue = quotaOverride ?? derivedQuotaValue; + + const inputOnChange = (e: React.ChangeEvent) => { + const filteredStringValue = e.target.value.replaceAll(/\D/g, ''); + const parsedValue = + filteredStringValue === '' ? undefined : Number.parseInt(filteredStringValue, 10); + const valueInGB = parsedValue !== undefined && parsedValue > 0 ? parsedValue : undefined; + const valueInBytes = valueInGB === undefined ? undefined : GbToBytes(valueInGB); + onChange(valueInBytes ? { type: 'limited', value: valueInBytes } : undefined); + setQuotaOverride(valueInGB); + }; + + const switchOnChange = () => { + if (quotaValue === 'unlimited') { + if (initialTotalComputedQuotaLimit?.type === 'limited') { + onChange({ type: 'limited', value: initialTotalComputedQuotaLimit.value }); + setQuotaOverride(BytesToGB(initialTotalComputedQuotaLimit.value)); + } else { + onChange({ type: 'limited', value: GbToBytes(1) }); + setQuotaOverride(1); + } + } else { + onChange({ type: 'unlimited' }); + setQuotaOverride('unlimited'); + } + }; + + const switchValue = quotaValue === 'unlimited'; + + const inputValue = typeof quotaValue === 'number' ? String(quotaValue) : ''; + + const icon = totalQuotaSource === 'global' ? 'GlobeOutline' : undefined; + + const tooltipLabel = + totalQuotaSource === 'global' + ? t('label.quota.source.global', 'Quota inherited from the global configuration') + : undefined; + + const showQuotaSourceIcon = totalQuotaSource !== undefined && totalQuotaSource !== 'cos'; + + const onChangeReset = () => { + setQuotaOverride(undefined); + onChange(undefined); + }; + + const revertLabel = t('cos_quota.click_to_revert', 'Click to revert to the inherited value'); + + const RevertIcon = showRevertButton + ? () => + : undefined; + + return ( + + + + + {t('label.unlimited_quota', 'Unlimited quota')} + + + + + {showQuotaSourceIcon && ( + + + + )} + + + ); +}; diff --git a/apps/admin-ui-cos/src/views/cos/advanced/sections/quotas.tsx b/apps/admin-ui-cos/src/views/cos/advanced/sections/quotas.tsx new file mode 100644 index 000000000..dbc644664 --- /dev/null +++ b/apps/admin-ui-cos/src/views/cos/advanced/sections/quotas.tsx @@ -0,0 +1,244 @@ +/* + * SPDX-FileCopyrightText: 2025 Zextras + * + * SPDX-License-Identifier: AGPL-3.0-only + */ +import { useSelector } from '@tanstack/react-store'; +import { + Container, + CustomTextArea, + Input, + ListRow, + Padding, + Row, + Select, +} from '@zextras/ui-components'; +import { ChangeEvent } from 'react'; +import { useTranslation } from 'react-i18next'; + +import { TimeItems } from '../../../../../types/general'; +import { getFieldErrorProps } from '../fields/field-error'; +import { QuotaGBField } from '../fields/quota-gb-field'; +import { CosValidatedInput } from '../fields/validated-input'; +import { useCosQuotaState } from '../hooks/use-cos-quota-state'; +import { CosFormApi } from '../types'; +import { COSQuotasNew } from './quotas-new'; + +type QuotaProps = { + form: CosFormApi; + quotaState: ReturnType; + isTotalQuotaActive: boolean; + isAdvanced: boolean; + readonlyCOS: boolean; + timeItems: TimeItems; +}; + +export const COSQuotas = ({ + form, + quotaState, + isTotalQuotaActive, + isAdvanced, + readonlyCOS, + timeItems, +}: QuotaProps) => { + const [t] = useTranslation(); + const isSubmitted = useSelector(form.store, (s) => s.submissionAttempts > 0); + + const labels = { + quotas: t('cos.quotas', 'Quotas'), + filesAccountQuotaGB: t('cos.files_account_quota_gb', 'Files Account quota (GB)'), + mailsAccountQuotaGB: t('cos.mails_account_quota_gb', 'Mails Account quota (GB)'), + maximumDigitsAllowed: t( + 'label.maximum_3_digits_allowed_decimal_point', + 'Maximum 3 digits allowed after the decimal point', + ), + maxContactsAllowedInTheFolder: t( + 'cos.max_contacts_allowed_in_the_folder', + 'Max contacts allowed in the folder', + ), + percentageThresholdForQuotaWarningMessages: t( + 'cos.percentage_threshold_for_quota_warning', + 'Percentage threshold for quota warning messages (%)', + ), + minimumDurationOfTimeBetweenQuotaWarnings: t( + 'cos.minimum_duration_of_time_between_quota_warnings', + 'Minimum duration of time between quota warnings', + ), + timeRange: t('cos.time_range', 'Time Range'), + quotaWarningMessageTemplate: t( + 'cos.quota_warning_message_template', + 'Quota warning message template', + ), + }; + + return ( + + + {labels.quotas} + + + + + {isTotalQuotaActive ? ( + + ) : ( + <> + {isAdvanced && quotaState.initFileQuotaLimitGBValue && ( + + + {quotaState.showFileQuotaLimitMsg && ( + + + + {labels.maximumDigitsAllowed} + + + + )} + + )} + + + + + )} + + + + + + + {!isTotalQuotaActive && ( + + + + + + + + {(field) => { + const raw = String(field.state.value ?? ''); + const hasUnit = raw.length >= 2; + const num = hasUnit ? raw.slice(0, -1) : ''; + const unit = hasUnit ? raw.slice(-1) : ''; + const error = getFieldErrorProps(field, isSubmitted, t); + return ( + <> + + ) => + field.handleChange(e.target.value ? `${e.target.value}${unit}` : '') + } + onBlur={() => field.handleBlur()} + hasError={error.hasError} + description={error.description} + disabled={readonlyCOS} + /> + + + ): void => { - setCosName(e.target.value); - }} - disabled={canDeleteCOS || readonlyCOS} - /> - - - - - { - // - }} - /> - - - { - // - }} - /> - - - - - - - - - - - - - ): void => { - setDescription(e.target.value); - }} - disabled={readonlyCOS} - /> - - - - - ): void => { - setZimbraNotes(e.target.value); - }} - disabled={readonlyCOS} - /> - - - - - - - - {t('cos.domains_that_use_this_cos', 'Domains that use this COS')} - - - - - - ): void => { - setSearchDomainString(e.target.value); - }} - CustomIcon={(): ReactElement => ( - - )} - /> - - - - - - {isDomainRequestInProgress && ( - - - - )} - {domainList.length === 0 && !isDomainRequestInProgress && ( - - - logo - - - - {t('label.this_list_is_empty', 'This list is empty.')} - - - - )} - {domainList.length !== 0 && ( - - - - - - - - - )} - - - 0 ? '3rem' : '0rem' }} - > - - - {t('cos.accounts_that_use_this_cos', 'Accounts that use this COS')} - - - - - - ): void => { - setSearchAccountString(e.target.value); - }} - CustomIcon={(): ReactElement => ( - - )} - /> - - - - -
- {isAccountRequestInProgress && ( - - - - )} - {accountList.length === 0 && !isAccountRequestInProgress && ( - - - logo - - - - {t('label.this_list_is_empty', 'This list is empty.')} - - - - )} - {accountList.length !== 0 && ( - - - - - - - - - )} - - - - -
- {isRequestInProgress && ( + {isError && ( - + + {t( + 'label.error_loading_cos_list', + 'Failed to load COS list. Please try again.', + )} + )} - {cosList.length === 0 && !isRequestInProgress && ( + {showEmptyState && !isError && ( logo @@ -353,7 +306,7 @@ const CosList: FC = () => { crossAlignment="center" style={{ textAlign: 'center' }} > - + {t('label.this_list_is_empty', 'This list is empty.')} @@ -364,7 +317,7 @@ const CosList: FC = () => { padding={{ top: 'small' }} width="53%" > - + { ); }; - -export default CosList; diff --git a/apps/admin-ui-cos/src/views/cos/cos-server-pools.tsx b/apps/admin-ui-cos/src/views/cos/cos-server-pools.tsx deleted file mode 100644 index 90a1db48e..000000000 --- a/apps/admin-ui-cos/src/views/cos/cos-server-pools.tsx +++ /dev/null @@ -1,597 +0,0 @@ -/* - * SPDX-FileCopyrightText: 2022 Zextras - * - * SPDX-License-Identifier: AGPL-3.0-only - */ - -import { - Button, - Container, - CustomHeaderFactory, - HoverableRowFactory, - Input, - ListRow, - Modal, - Padding, - Row, - Switch, - Table, - type THeader, - type TRow, - useSnackbar, -} from '@zextras/ui-components'; -import { useCurrentUserRights, useMailstoreServers } from '@zextras/ui-shared'; -import { debounce, find } from 'lodash-es'; -import { ChangeEvent, FC, ReactElement, useCallback, useEffect, useMemo, useState } from 'react'; -import { useTranslation } from 'react-i18next'; -import { useParams } from 'react-router'; - -import { Attribute } from '../../../types/attribute'; -import { COS, DISABLED, ENABLED, ZIMBRA_ADMIN_URN } from '../../constants'; -import { flushCache } from '../../services/flush-cache-service'; -import { modifyCos, ModifyCosBody } from '../../services/modify-cos-service'; -import { useCosStore } from '../../store/cos/store'; -import { PageLayout } from '../page-layout'; - -type ServerItem = { - id?: string; - name?: string; - a?: Array; -}; - -const CosServerPools: FC = () => { - const [t] = useTranslation(); - const { cosId } = useParams(); - const cosInformation = useCosStore((state) => state.cos?.a); - const [zimbraMailHostPool, setZimbraMailHostPool] = useState(true); - const [serverList, setServerList] = useState>([]); - const [zimbraMailHostPoolList, setZimbraMailHostPoolList] = useState>([]); - const [serverTableRows, setServerTableRows] = useState>([]); - const [selectedTableRows, setSelectedTableRows] = useState>([]); - const [selectedTableRowsId, setSelectedTableRowsId] = useState>([]); - const [openConfirmDialog, setOpenConfirmDialog] = useState(false); - const createSnackbar = useSnackbar(); - const setCos = useCosStore((state) => state.setCos); - const [searchServer, setSearchServer] = useState(''); - const { data: allMailStoreList = [] } = useMailstoreServers(); - const { data: rights = [] } = useCurrentUserRights(); - - const readonlyCOS = useMemo(() => { - const rightsConfig = find(rights, { type: COS }) || { all: [], type: COS }; - return !rightsConfig?.all?.[0]?.setAttrs?.[0]?.all; - }, [rights]); - - useEffect(() => { - if (allMailStoreList && allMailStoreList.length > 0) { - setServerList(allMailStoreList); - } - }, [allMailStoreList]); - - useMemo(() => { - if (serverList && serverList.length > 0) { - const allRows = serverList.map((item) => ({ - id: item?.id ?? '', - columns: [ - { - e.stopPropagation(); - setSelectedTableRows([item]); - setSelectedTableRowsId([item?.id ?? '']); - }} - > - - {item?.name} - - , - { - e.stopPropagation(); - setSelectedTableRows([item]); - setSelectedTableRowsId([item?.id ?? '']); - }} - > - - {zimbraMailHostPoolList.find((sp) => item?.id === sp?._content)?.c ? ( - - {t('cos.enabled', 'Enabled')} - - ) : ( - - {t('cos.disabled', 'Disabled')} - - )} - - , - ], - })); - setServerTableRows(allRows); - } - }, [serverList, zimbraMailHostPoolList, t]); - - const enable = useMemo( - () => - selectedTableRows.length > 0 && - !zimbraMailHostPoolList.find((sp) => selectedTableRows[0]?.id === sp?._content)?.c, - [selectedTableRows, zimbraMailHostPoolList], - ); - - const disable = useMemo( - () => - (selectedTableRows.length > 0 && - zimbraMailHostPoolList.find((sp) => selectedTableRows[0]?.id === sp?._content)?.c) || - false, - [selectedTableRows, zimbraMailHostPoolList], - ); - - useEffect(() => { - if (!!cosInformation && cosInformation.length > 0) { - const list = cosInformation.filter((item) => item?.n === 'zimbraMailHostPool'); - if (list) { - setZimbraMailHostPoolList(list); - } - } - }, [cosInformation]); - - const onFilterApply = useCallback( - (e: string) => { - if (e === null) { - return; - } - if (e === ENABLED) { - const allRows = serverList - .filter( - (item) => - zimbraMailHostPoolList.find((sp) => item?.id === sp?._content)?.c === true, - ) - .map((item) => ({ - id: item?.id ?? '', - columns: [ - { - ev.stopPropagation(); - setSelectedTableRows([item]); - setSelectedTableRowsId([item?.id ?? '']); - }} - > - - {item?.name} - - , - { - ev.stopPropagation(); - setSelectedTableRows([item]); - setSelectedTableRowsId([item?.id ?? '']); - }} - > - - {zimbraMailHostPoolList.find((sp) => item?.id === sp?._content)?.c ? ( - - {t('cos.enabled', 'Enabled')} - - ) : ( - - {t('cos.disabled', 'Disabled')} - - )} - - , - ], - })); - setServerTableRows(allRows); - } - if (e === DISABLED) { - const allRows = serverList - .filter( - (item) => !zimbraMailHostPoolList.find((sp) => item?.id === sp?._content)?.c, - ) - .map((item) => ({ - id: item?.id ?? '', - columns: [ - { - ev.stopPropagation(); - setSelectedTableRows([item]); - setSelectedTableRowsId([item?.id ?? '']); - }} - > - - {item?.name} - - , - { - ev.stopPropagation(); - setSelectedTableRows([item]); - setSelectedTableRowsId([item?.id ?? '']); - }} - > - - {zimbraMailHostPoolList.find((sp) => item?.id === sp?._content)?.c ? ( - - {t('cos.enabled', 'Enabled')} - - ) : ( - - {t('cos.disabled', 'Disabled')} - - )} - - , - ], - })); - setServerTableRows(allRows); - } - }, - [t, serverList, zimbraMailHostPoolList], - ); - - const tableHeader = useMemo>( - () => [ - { - id: 'name_server', - label: t('cos.name_server', 'Name Server'), - width: '80%', - bold: true, - }, - { - id: 'status', - label: t('cos.status', 'Status'), - width: '20%', - align: 'left' as const, - bold: true, - items: [ - { label: t('cos.enabled', 'Enabled'), value: 'enabled' }, - { label: t('cos.disabled', 'Disabled'), value: 'disabled' }, - ], - onChange: (selected): void => { - const value = selected[0]?.value; - if (value) { - onFilterApply(value); - } - }, - }, - ], - [t, onFilterApply], - ); - - const onDisable = useCallback(() => { - setOpenConfirmDialog(true); - }, []); - - const onModifyCOS = useCallback( - (body: ModifyCosBody) => { - modifyCos(body) - .then((data) => { - const cos = data?.cos[0]; - if (cos) { - flushCache('cos', 'id', body.id._content); - createSnackbar({ - key: 'success', - severity: 'success', - label: t( - 'label.the_last_changes_has_been_saved_successfully', - 'Changes have been saved successfully', - ), - autoHideTimeout: 3000, - hideButton: true, - replace: true, - }); - setCos(cos); - } - setOpenConfirmDialog(false); - setSelectedTableRows([]); - setSelectedTableRowsId([]); - }) - .catch((error) => { - createSnackbar({ - key: 'error', - severity: 'error', - label: error?.message - ? error?.message - : t('label.something_wrong_error_msg', 'Something went wrong. Please try again.'), - autoHideTimeout: 3000, - hideButton: true, - replace: true, - }); - }); - }, - [createSnackbar, t, setCos], - ); - - const onEnable = useCallback(() => { - const body: ModifyCosBody = { - _jsns: ZIMBRA_ADMIN_URN, - a: [ - ...zimbraMailHostPoolList.map((item) => ({ - n: 'zimbraMailHostPool', - _content: item?._content, - })), - { - n: 'zimbraMailHostPool', - _content: selectedTableRows[0]?.id ?? '', - }, - ], - id: { - _content: cosId as string, - }, - }; - onModifyCOS(body); - }, [selectedTableRows, onModifyCOS, zimbraMailHostPoolList, cosId]); - - const onDisableServer = useCallback(() => { - const allServers = zimbraMailHostPoolList.filter( - (item) => item?._content !== selectedTableRows[0]?.id, - ); - const attributes: Attribute[] = - allServers.length === 0 - ? [{ n: 'zimbraMailHostPool', _content: '' }] - : allServers.map((item) => ({ - n: 'zimbraMailHostPool', - _content: item?._content, - })); - const body: ModifyCosBody = { - _jsns: ZIMBRA_ADMIN_URN, - id: { - _content: cosId ?? '', - }, - a: attributes, - } as ModifyCosBody; - - onModifyCOS(body); - }, [selectedTableRows, zimbraMailHostPoolList, onModifyCOS, cosId]); - - const hideConfirmDialog = useCallback(() => { - setOpenConfirmDialog(false); - }, []); - - // eslint-disable-next-line react-hooks/exhaustive-deps - const searchServerLists = useCallback( - debounce((searchText: string, serverListItems: Array) => { - if (searchText !== '') { - const allRows = serverListItems - .filter((item) => item?.name?.includes(searchText)) - .map((item) => ({ - id: item?.id ?? '', - columns: [ - { - ev.stopPropagation(); - setSelectedTableRows([item]); - setSelectedTableRowsId([item?.id ?? '']); - }} - > - - {item?.name} - - , - { - ev.stopPropagation(); - setSelectedTableRows([item]); - setSelectedTableRowsId([item?.id ?? '']); - }} - > - - {zimbraMailHostPoolList.find((sp) => item?.id === sp?._content)?.c ? ( - - {t('cos.enabled', 'Enabled')} - - ) : ( - - {t('cos.disabled', 'Disabled')} - - )} - - , - ], - })); - setServerTableRows(allRows); - } - }, 700), - [debounce], - ); - - useEffect(() => { - searchServerLists(searchServer, serverList); - }, [searchServer, searchServerLists, serverList]); - - useEffect(() => { - if (zimbraMailHostPoolList && serverList.length > 0) { - if ( - zimbraMailHostPoolList.length === - zimbraMailHostPoolList.filter((item) => !item?.c).length - ) { - setZimbraMailHostPool(false); - } - } - }, [zimbraMailHostPoolList, serverList]); - - return ( - - - - - - {t('cos.general_options', 'General Options')} - - - - - { - setZimbraMailHostPool(!zimbraMailHostPool); - }} - iconColor="primary" - /> - - - {zimbraMailHostPool && ( - <> - - - - - ( - - )} - onChange={(e: ChangeEvent): void => { - setSearchServer(e.target.value); - }} - /> - - - -
- - - )} - - - - { - setOpenConfirmDialog(false); - }} - customFooter={ - - -
+ + + ); +}; diff --git a/apps/admin-ui-cos/src/views/cos/create-new-cos.tsx b/apps/admin-ui-cos/src/views/cos/create-new-cos.tsx index 659459542..5dca104b5 100644 --- a/apps/admin-ui-cos/src/views/cos/create-new-cos.tsx +++ b/apps/admin-ui-cos/src/views/cos/create-new-cos.tsx @@ -15,25 +15,21 @@ import { useSnackbar, } from '@zextras/ui-components'; import { replaceHistory } from '@zextras/ui-shared'; -import { ChangeEvent, FC, useState } from 'react'; +import { ChangeEvent, useState } from 'react'; import { useTranslation } from 'react-i18next'; -import { useNavigate } from 'react-router'; import { Attribute } from '../../../types/attribute'; import { CosResponse } from '../../../types/cos'; -import { COS_ROUTE_ID, MANAGE } from '../../constants'; +import { GENERAL_INFORMATION } from '../../constants'; import { createCos } from '../../services/create-cos'; -import { useCosStore } from '../../store/cos/store'; -const CreateCos: FC = () => { +export const CreateCos = () => { const [t] = useTranslation(); const createSnackbar = useSnackbar(); - const navigate = useNavigate(); const [zimbraNotes, setZimbraNotes] = useState(''); const [description, setDescription] = useState(''); const [cosName, setCosName] = useState(''); const [isLoading, setIsLoading] = useState(false); - const { setCosView, setCos } = useCosStore(); const showSuccessSnackBar = (): void => { createSnackbar({ @@ -52,13 +48,7 @@ const CreateCos: FC = () => { const routeToCos = (resp: CosResponse): void => { const cos = resp?.cos[0]; if (cos) { - setCos({ - a: cos?.a, - id: cos?.id, - name: cos?.name, - }); - setCosView('general_information'); - replaceHistory(`/${cos.id}/general_information`); + replaceHistory(`/${cos.id}/${GENERAL_INFORMATION}`); } else { replaceHistory(`/`); } @@ -67,18 +57,20 @@ const CreateCos: FC = () => { const onCreate = (): void => { const attributes: Array = []; setIsLoading(true); - attributes.push({ - n: 'zimbraNotes', - _content: zimbraNotes, - }); - attributes.push({ - n: 'description', - _content: description, - }); - attributes.push({ - n: 'cn', - _content: cosName, - }); + attributes.push( + { + n: 'zimbraNotes', + _content: zimbraNotes, + }, + { + n: 'description', + _content: description, + }, + { + n: 'cn', + _content: cosName, + }, + ); createCos(cosName, attributes) .then((data) => { const cos = data?.cos[0]; @@ -104,7 +96,7 @@ const CreateCos: FC = () => { }; const onCancel = (): void => { - navigate(`/${MANAGE}/${COS_ROUTE_ID}`); + replaceHistory('/'); }; return ( @@ -223,4 +215,3 @@ const CreateCos: FC = () => { ); }; -export default CreateCos; diff --git a/apps/admin-ui-cos/src/views/cos/features.browser.test.tsx b/apps/admin-ui-cos/src/views/cos/features.browser.test.tsx deleted file mode 100644 index a0edcfdb0..000000000 --- a/apps/admin-ui-cos/src/views/cos/features.browser.test.tsx +++ /dev/null @@ -1,129 +0,0 @@ -/* - * SPDX-FileCopyrightText: 2026 Zextras - * - * SPDX-License-Identifier: AGPL-3.0-only - */ - -import { getQueryClient, setupBrowserTest } from 'admin-ui-test-utils'; -import { describe, expect, it, vi } from 'vitest'; -import { page, userEvent } from 'vitest/browser'; - -import { Features } from './features'; - -const mockProps = { - featuresDetail: { - carbonioFeatureOTPMgmtEnabled: 'FALSE', - }, - setFeaturesDetail: () => {}, - cosDetail: { - carbonioFeatureOTPMgmtEnabled: 'FALSE', - }, - accSpecificDetail: { - carbonioFeatureOTPMgmtEnabled: 'FALSE', - }, - setEmptyValue: () => {}, - readonlyFeatures: false, - cosLevelFeatures: false, -}; - -const enabledProps = { - ...mockProps, - cosLevelFeatures: true, - featuresDetail: { - carbonioFeatureOTPMgmtEnabled: 'TRUE', - carbonioOtpWizardFromUntrusted: 'TRUE', - carbonioOtpGracePeriodEnabled: 'TRUE', - }, - cosDetail: { - carbonioFeatureOTPMgmtEnabled: 'TRUE', - carbonioOtpWizardFromUntrusted: 'TRUE', - carbonioOtpGracePeriodEnabled: 'TRUE', - }, - accSpecificDetail: { - carbonioFeatureOTPMgmtEnabled: 'TRUE', - carbonioOtpWizardFromUntrusted: 'TRUE', - carbonioOtpGracePeriodEnabled: 'TRUE', - }, -}; - -const disabledProps = { - ...mockProps, - cosLevelFeatures: true, - featuresDetail: { - carbonioFeatureOTPMgmtEnabled: 'TRUE', - carbonioOtpWizardFromUntrusted: 'TRUE', - carbonioOtpGracePeriodEnabled: 'FALSE', - }, - cosDetail: { - carbonioFeatureOTPMgmtEnabled: 'TRUE', - carbonioOtpWizardFromUntrusted: 'TRUE', - carbonioOtpGracePeriodEnabled: 'FALSE', - }, - accSpecificDetail: { - carbonioFeatureOTPMgmtEnabled: 'TRUE', - carbonioOtpWizardFromUntrusted: 'TRUE', - carbonioOtpGracePeriodEnabled: 'FALSE', - }, -}; - -function setupAdvancedTest(ui: React.ReactElement) { - const queryClient = getQueryClient(); - queryClient.setQueryData(['advanced-supported'], { supported: true }); - return setupBrowserTest(ui, { queryClient }); -} - -function getCalendarButton() { - return page.getByRole('button', { name: 'Calendar' }).first(); -} - -describe('Features (browser)', () => { - it('should render 2FA section when cosLevelFeatures is true', async () => { - setupBrowserTest(); - await expect.element(page.getByText('Two-Factor authenticator')).toBeVisible(); - await expect.element(page.getByText('Allow users to configure 2FA')).toBeVisible(); - await expect.element(page.getByText('Users will be able to set up and manage their One-Time Password (OTP) from their profile settings.')).toBeVisible(); - }); - - it('should toggle 2FA switch', async () => { - const setFeaturesDetail = vi.fn(); - setupBrowserTest(); - const switchLabel = page.getByText('Allow users to configure 2FA'); - await userEvent.click(switchLabel); - expect(setFeaturesDetail).toHaveBeenCalled(); - }); - - describe('DatePicker', () => { - it('should render grace period expiration date picker when grace period is enabled', async () => { - setupAdvancedTest(); - - await expect - .element(page.getByPlaceholder('Set grace period expiration date')) - .toBeVisible(); - }); - - it('should disable the date picker when grace period is disabled', async () => { - setupAdvancedTest(); - - await expect - .element(page.getByPlaceholder('Set grace period expiration date')) - .toBeDisabled(); - }); - - it('should enable the date picker when grace period is enabled', async () => { - setupAdvancedTest(); - - await expect - .element(page.getByPlaceholder('Set grace period expiration date')) - .toBeEnabled(); - }); - - it('should open the calendar popover when the calendar icon is clicked', async () => { - setupAdvancedTest(); - - await getCalendarButton().click(); - - await expect.element(page.getByRole('grid')).toBeVisible(); - }); - - }); -}); diff --git a/apps/admin-ui-cos/src/views/cos/features.tsx b/apps/admin-ui-cos/src/views/cos/features.tsx deleted file mode 100644 index 3c18bf1fa..000000000 --- a/apps/admin-ui-cos/src/views/cos/features.tsx +++ /dev/null @@ -1,493 +0,0 @@ -/* - * SPDX-FileCopyrightText: 2022 Zextras - * - * SPDX-License-Identifier: AGPL-3.0-only - */ -import { - Container, - DatePicker, - InheritedSwitch, - ListRow, - Padding, - Row, -} from '@zextras/ui-components'; -import { useIsAdvanced } from '@zextras/ui-shared'; -import { FC, useCallback, useEffect, useMemo, useState } from 'react'; -import { useTranslation } from 'react-i18next'; - -export const Features: FC<{ - featuresDetail: Partial>; - setFeaturesDetail: CallableFunction; - cosDetail?: Partial>; - accSpecificDetail?: Partial>; - setEmptyValue?: CallableFunction; - readonlyFeatures?: boolean; - cosLevelFeatures?: boolean; -}> = ({ - featuresDetail, - setFeaturesDetail, - cosDetail, - accSpecificDetail, - setEmptyValue, - readonlyFeatures = false, - cosLevelFeatures = false, -}) => { - const [t] = useTranslation(); - const isAdvanced = useIsAdvanced(); - - const changeSwitchOption = useCallback( - (key: string): void => { - setFeaturesDetail((prev: Partial>) => ({ - ...prev, - [key]: featuresDetail[key] === 'TRUE' ? 'FALSE' : 'TRUE', - })); - }, - [featuresDetail, setFeaturesDetail], - ); - - const gracePeriodDefaultDate = useMemo(() => { - const gentimeValue = - accSpecificDetail?.carbonioOtpGracePeriodEndingTime ?? - featuresDetail?.carbonioOtpGracePeriodEndingTime; - if (gentimeValue) { - const match = /^(\d{4})(\d{2})(\d{2})(\d{2})(\d{2})(\d{2})Z$/.exec(gentimeValue); - if (match) { - return new Date( - Date.UTC( - Number(match[1]), - Number(match[2]) - 1, - Number(match[3]), - Number(match[4]), - Number(match[5]), - Number(match[6]), - ), - ); - } - } - if (featuresDetail?.carbonioOtpGracePeriodEnabled) { - const date = new Date(); - date.setMonth(date.getMonth() + 1); - return date; - } - return null; - }, [ - accSpecificDetail?.carbonioOtpGracePeriodEndingTime, - featuresDetail?.carbonioOtpGracePeriodEndingTime, - featuresDetail?.carbonioOtpGracePeriodEnabled, - ]); - const [fromDate, setFromDate] = useState(gracePeriodDefaultDate); - - useEffect(() => { - setFromDate(gracePeriodDefaultDate); - }, [gracePeriodDefaultDate]); - - const isGracePeriodEnabled = - featuresDetail?.carbonioOtpGracePeriodEnabled === 'TRUE' && - featuresDetail?.carbonioOtpWizardFromUntrusted === 'TRUE' && - featuresDetail?.carbonioFeatureOTPMgmtEnabled === 'TRUE'; - - const handleFromDateChange = useCallback( - (d: Date | null) => { - setFromDate(d); - if (!d) { - setFeaturesDetail((prev: Partial>) => ({ - ...prev, - carbonioOtpGracePeriodEndingTime: '', - })); - return; - } - const gentime = `${d.getUTCFullYear()}${String(d.getUTCMonth() + 1).padStart(2, '0')}${String( - d.getUTCDate(), - ).padStart(2, '0')}${String(d.getUTCHours()).padStart(2, '0')}${String( - d.getUTCMinutes(), - ).padStart(2, '0')}${String(d.getUTCSeconds()).padStart(2, '0')}Z`; - setFeaturesDetail((prev: Partial>) => ({ - ...prev, - carbonioOtpGracePeriodEndingTime: gentime, - })); - }, - [setFeaturesDetail], - ); - - return ( - - - - - {t('label.general_lbl', 'General')} - - - setEmptyValue?.('zimbraFeatureOptionsEnabled')} - disabled={readonlyFeatures} - /> - - - - - {cosLevelFeatures && ( - - - - {t('cos.features.twoFactorAuthenticator', 'Two-Factor authenticator')} - - - setEmptyValue?.('carbonioFeatureOTPMgmtEnabled')} - disabled={readonlyFeatures} - /> - - - - - {t( - 'cos.features.allowUsersToConfigure2FAInfo', - 'Users will be able to set up and manage their One-Time Password (OTP) from their profile settings.', - )} - - - - {isAdvanced && ( - - - {t( - 'cos.features.twoFactorAuthSetupEnforcement', - 'Two-Factor authenticator setup enforcement', - )} - - - - - - setEmptyValue?.('carbonioOtpWizardFromUntrusted') - } - disabled={ - readonlyFeatures || - featuresDetail?.carbonioFeatureOTPMgmtEnabled === 'FALSE' - } - /> - - - - {t( - 'cos.features.allowSetupFromUntrustedNetworksInfo', - 'Lets users without an OTP complete the 2FA setup wizard at sign-in from untrusted networks. Disable this option to block access from untrusted networks until 2FA is already configured.', - )} - - - - - - - - setEmptyValue?.('carbonioOtpGracePeriodEnabled')} - disabled={ - readonlyFeatures || - featuresDetail?.carbonioFeatureOTPMgmtEnabled === 'FALSE' || - featuresDetail?.carbonioOtpWizardFromUntrusted === 'FALSE' - } - /> - - - - {t( - 'cos.features.allowSetupDeferralDuringGracePeriodInfo', - 'Users can skip the wizard for a limited time. The prompt will reappear at every login until setup is completed or the grace period expires.', - )} - - - - - - - - - - - - - - {/* */} - - )} - - - - )} - - - - {t('label.mail', 'Mail')} - - - setEmptyValue?.('carbonioFeatureMailsAppEnabled')} - disabled={readonlyFeatures} - /> - - - setEmptyValue?.('zimbraFeatureSignaturesEnabled')} - disabled={readonlyFeatures} - /> - - - setEmptyValue?.('zimbraFeatureOutOfOfficeReplyEnabled')} - disabled={readonlyFeatures} - /> - - - - - - - - {t('label.contacts', 'Contacts')} - - - setEmptyValue?.('zimbraFeatureContactsEnabled')} - disabled={readonlyFeatures} - /> - - - - - {t('label.calendar', 'Calendar')} - - - setEmptyValue?.('zimbraFeatureCalendarEnabled')} - disabled={readonlyFeatures} - /> - - - - - - - - {t('label.files', 'Files')} - - - setEmptyValue?.('carbonioFeatureFilesEnabled')} - disabled={readonlyFeatures} - /> - - - setEmptyValue?.('carbonioFeatureFilesAppEnabled')} - disabled={featuresDetail.carbonioFeatureFilesEnabled !== 'TRUE' || readonlyFeatures} - /> - - - - - {t('label.tasks', 'Tasks')} - - - setEmptyValue?.('carbonioFeatureTasksEnabled')} - disabled={readonlyFeatures} - /> - - - - - ); -}; diff --git a/apps/admin-ui-cos/src/views/cos/features/sections/contacts-calendar-section.tsx b/apps/admin-ui-cos/src/views/cos/features/sections/contacts-calendar-section.tsx new file mode 100644 index 000000000..50e8bc626 --- /dev/null +++ b/apps/admin-ui-cos/src/views/cos/features/sections/contacts-calendar-section.tsx @@ -0,0 +1,67 @@ +/* + * SPDX-FileCopyrightText: 2026 Zextras + * + * SPDX-License-Identifier: AGPL-3.0-only + */ +import { Container, Row } from '@zextras/ui-components'; +import { useTranslation } from 'react-i18next'; + +import { FeatureSwitchField } from '../../fields/feature-switch-field'; +import type { CosFeaturesFormApi } from '../../types'; + +type ContactsCalendarSectionProps = { + form: CosFeaturesFormApi; + readonlyCOS: boolean; +}; + +export const ContactsCalendarSection = ({ form, readonlyCOS }: ContactsCalendarSectionProps) => { + const [t] = useTranslation(); + + return ( + + + + {t('label.contacts', 'Contacts')} + + + + + + + + {t('label.calendar', 'Calendar')} + + + + + + + ); +}; diff --git a/apps/admin-ui-cos/src/views/cos/features/sections/files-tasks-section.tsx b/apps/admin-ui-cos/src/views/cos/features/sections/files-tasks-section.tsx new file mode 100644 index 000000000..2ce437fe6 --- /dev/null +++ b/apps/admin-ui-cos/src/views/cos/features/sections/files-tasks-section.tsx @@ -0,0 +1,86 @@ +/* + * SPDX-FileCopyrightText: 2026 Zextras + * + * SPDX-License-Identifier: AGPL-3.0-only + */ +import { Container, Row, Switch } from '@zextras/ui-components'; +import { useTranslation } from 'react-i18next'; + +import { FeatureSwitchField } from '../../fields/feature-switch-field'; +import type { CosFeaturesFormApi } from '../../types'; + +type FilesTasksSectionProps = { + form: CosFeaturesFormApi; + readonlyCOS: boolean; +}; + +export const FilesTasksSection = ({ form, readonlyCOS }: FilesTasksSectionProps) => { + const [t] = useTranslation(); + + return ( + + + + {t('label.files', 'Files')} + + + + + + + {(field) => ( + + {(filesEnabledField) => ( + + field.handleChange(field.state.value === 'TRUE' ? 'FALSE' : 'TRUE') + } + label={t('label.mobile_app', 'Mobile App')} + iconColor="primary" + disabled={filesEnabledField.state.value !== 'TRUE' || readonlyCOS} + /> + )} + + )} + + + + + + {t('label.tasks', 'Tasks')} + + + + + + + ); +}; diff --git a/apps/admin-ui-cos/src/views/cos/features/sections/general-section.tsx b/apps/admin-ui-cos/src/views/cos/features/sections/general-section.tsx new file mode 100644 index 000000000..2fcd35c05 --- /dev/null +++ b/apps/admin-ui-cos/src/views/cos/features/sections/general-section.tsx @@ -0,0 +1,48 @@ +/* + * SPDX-FileCopyrightText: 2026 Zextras + * + * SPDX-License-Identifier: AGPL-3.0-only + */ +import { Container, Row } from '@zextras/ui-components'; +import { useTranslation } from 'react-i18next'; + +import { FeatureSwitchField } from '../../fields/feature-switch-field'; +import type { CosFeaturesFormApi } from '../../types'; + +type GeneralSectionProps = { + form: CosFeaturesFormApi; + readonlyCOS: boolean; +}; + +export const GeneralSection = ({ form, readonlyCOS }: GeneralSectionProps) => { + const [t] = useTranslation(); + + return ( + + + + {t('label.general_lbl', 'General')} + + + + + + + ); +}; diff --git a/apps/admin-ui-cos/src/views/cos/features/sections/grace-period-end-date-picker.tsx b/apps/admin-ui-cos/src/views/cos/features/sections/grace-period-end-date-picker.tsx new file mode 100644 index 000000000..031f771b7 --- /dev/null +++ b/apps/admin-ui-cos/src/views/cos/features/sections/grace-period-end-date-picker.tsx @@ -0,0 +1,70 @@ +/* + * SPDX-FileCopyrightText: 2026 Zextras + * + * SPDX-License-Identifier: AGPL-3.0-only + */ +import { useField } from '@tanstack/react-form'; +import { DatePicker, Padding, Row } from '@zextras/ui-components'; +import { useTranslation } from 'react-i18next'; + +import type { CosFeaturesFormApi } from '../../types'; + +type GracePeriodEndDatePickerProps = { + form: CosFeaturesFormApi; +}; + +export const GracePeriodEndDatePicker = ({ form }: GracePeriodEndDatePickerProps) => { + const [t] = useTranslation(); + const field = useField({ form, name: 'carbonioOtpGracePeriodEndingTime' }); + const gracePeriodField = useField({ form, name: 'carbonioOtpGracePeriodEnabled' }); + + const gentimeValue = field.state.value; + let defaultDate = null; + if (gentimeValue) { + const match = /^(\d{4})(\d{2})(\d{2})(\d{2})(\d{2})(\d{2})Z$/.exec(gentimeValue); + if (match) { + defaultDate = new Date( + Date.UTC( + Number(match[1]), + Number(match[2]) - 1, + Number(match[3]), + Number(match[4]), + Number(match[5]), + Number(match[6]), + ), + ); + } + } + + return ( + + + { + if (!d) { + field.handleChange(''); + return; + } + const gentime = `${d.getUTCFullYear()}${String(d.getUTCMonth() + 1).padStart( + 2, + '0', + )}${String(d.getUTCDate()).padStart(2, '0')}${String(d.getUTCHours()).padStart( + 2, + '0', + )}${String(d.getUTCMinutes()).padStart(2, '0')}${String(d.getUTCSeconds()).padStart( + 2, + '0', + )}Z`; + field.handleChange(gentime); + }} + dateFormat="dd/MM/yyyy" + minDate={new Date()} + selected={defaultDate} + /> + + + ); +}; diff --git a/apps/admin-ui-cos/src/views/cos/features/sections/grace-period-section.tsx b/apps/admin-ui-cos/src/views/cos/features/sections/grace-period-section.tsx new file mode 100644 index 000000000..6f1605039 --- /dev/null +++ b/apps/admin-ui-cos/src/views/cos/features/sections/grace-period-section.tsx @@ -0,0 +1,61 @@ +/* + * SPDX-FileCopyrightText: 2026 Zextras + * + * SPDX-License-Identifier: AGPL-3.0-only + */ +import { useField } from '@tanstack/react-form'; +import { Container, Padding, Row, Switch } from '@zextras/ui-components'; +import { useTranslation } from 'react-i18next'; + +import type { CosFeaturesFormApi } from '../../types'; + +type GracePeriodSectionProps = { + form: CosFeaturesFormApi; + readonlyCOS: boolean; +}; + +export const GracePeriodSection = ({ form, readonlyCOS }: GracePeriodSectionProps) => { + const [t] = useTranslation(); + const field = useField({ form, name: 'carbonioOtpGracePeriodEnabled' }); + const otpMgmtField = useField({ form, name: 'carbonioFeatureOTPMgmtEnabled' }); + const otpWizardField = useField({ form, name: 'carbonioOtpWizardFromUntrusted' }); + + return ( + + field.handleChange(field.state.value === 'TRUE' ? 'FALSE' : 'TRUE')} + label={t( + 'cos.features.allowSetupDeferralDuringGracePeriod', + 'Allow setup deferral during grace period', + )} + iconColor="primary" + disabled={ + readonlyCOS || + otpMgmtField.state.value === 'FALSE' || + otpWizardField.state.value === 'FALSE' + } + /> + + + + {t( + 'cos.features.allowSetupDeferralDuringGracePeriodInfo', + 'Users can skip the wizard for a limited time. The prompt will reappear at every login until setup is completed or the grace period expires.', + )}{' '} + + + + + ); +}; diff --git a/apps/admin-ui-cos/src/views/cos/features/sections/mail-section.tsx b/apps/admin-ui-cos/src/views/cos/features/sections/mail-section.tsx new file mode 100644 index 000000000..6ee609c88 --- /dev/null +++ b/apps/admin-ui-cos/src/views/cos/features/sections/mail-section.tsx @@ -0,0 +1,64 @@ +/* + * SPDX-FileCopyrightText: 2026 Zextras + * + * SPDX-License-Identifier: AGPL-3.0-only + */ +import { Container, Row } from '@zextras/ui-components'; +import { useTranslation } from 'react-i18next'; + +import { FeatureSwitchField } from '../../fields/feature-switch-field'; +import type { CosFeaturesFormApi } from '../../types'; + +type MailSectionProps = { + form: CosFeaturesFormApi; + readonlyCOS: boolean; +}; + +export const MailSection = ({ form, readonlyCOS }: MailSectionProps) => { + const [t] = useTranslation(); + + return ( + + + + {t('label.mail', 'Mail')} + + + + + + + + + + + + + ); +}; diff --git a/apps/admin-ui-cos/src/views/cos/features/sections/two-factor-section.tsx b/apps/admin-ui-cos/src/views/cos/features/sections/two-factor-section.tsx new file mode 100644 index 000000000..d31e0be32 --- /dev/null +++ b/apps/admin-ui-cos/src/views/cos/features/sections/two-factor-section.tsx @@ -0,0 +1,87 @@ +/* + * SPDX-FileCopyrightText: 2026 Zextras + * + * SPDX-License-Identifier: AGPL-3.0-only + */ +import { Container, ListRow, Padding, Row } from '@zextras/ui-components'; +import { useTranslation } from 'react-i18next'; + +import { FeatureSwitchField } from '../../fields/feature-switch-field'; +import type { CosFeaturesFormApi } from '../../types'; +import { GracePeriodEndDatePicker } from './grace-period-end-date-picker'; +import { GracePeriodSection } from './grace-period-section'; +import { UntrustedNetworkSection } from './untrusted-network-section'; + +type TwoFactorSectionProps = { + form: CosFeaturesFormApi; + readonlyCOS: boolean; + isAdvanced: boolean; +}; + +export const TwoFactorSection = ({ form, readonlyCOS, isAdvanced }: TwoFactorSectionProps) => { + const [t] = useTranslation(); + + return ( + + + + {t('cos.features.twoFactorAuthenticator', 'Two-Factor authenticator')} + + + + + + + + {t( + 'cos.features.allowUsersToConfigure2FAInfo', + 'Users will be able to set up and manage their One-Time Password (OTP) from their profile settings.', + )} + + + + {isAdvanced && ( + + + {t( + 'cos.features.twoFactorAuthSetupEnforcement', + 'Two-Factor authenticator setup enforcement', + )} + + + + + + + + + + + + + + )} + + + ); +}; diff --git a/apps/admin-ui-cos/src/views/cos/features/sections/untrusted-network-section.tsx b/apps/admin-ui-cos/src/views/cos/features/sections/untrusted-network-section.tsx new file mode 100644 index 000000000..dbeff65b9 --- /dev/null +++ b/apps/admin-ui-cos/src/views/cos/features/sections/untrusted-network-section.tsx @@ -0,0 +1,52 @@ +/* + * SPDX-FileCopyrightText: 2026 Zextras + * + * SPDX-License-Identifier: AGPL-3.0-only + */ +import { useField } from '@tanstack/react-form'; +import { Container, Padding, Row, Switch } from '@zextras/ui-components'; +import { useTranslation } from 'react-i18next'; + +import type { CosFeaturesFormApi } from '../../types'; + +type UntrustedNetworkSectionProps = { + form: CosFeaturesFormApi; + readonlyCOS: boolean; +}; + +export const UntrustedNetworkSection = ({ form, readonlyCOS }: UntrustedNetworkSectionProps) => { + const [t] = useTranslation(); + const field = useField({ form, name: 'carbonioOtpWizardFromUntrusted' }); + const otpMgmtField = useField({ form, name: 'carbonioFeatureOTPMgmtEnabled' }); + + return ( + + field.handleChange(field.state.value === 'TRUE' ? 'FALSE' : 'TRUE')} + label={t( + 'cos.features.allowSetupFromUntrustedNetworks', + 'Allow 2FA setup from untrusted networks', + )} + iconColor="primary" + disabled={readonlyCOS || otpMgmtField.state.value === 'FALSE'} + /> + + + + {t( + 'cos.features.allowSetupFromUntrustedNetworksInfo', + 'Lets users without an OTP complete the 2FA setup wizard at sign-in from untrusted networks. Disable this option to block access from untrusted networks until 2FA is already configured.', + )}{' '} + + + + + ); +}; diff --git a/apps/admin-ui-cos/src/views/cos/fields/feature-switch-field.tsx b/apps/admin-ui-cos/src/views/cos/fields/feature-switch-field.tsx new file mode 100644 index 000000000..59e7131f5 --- /dev/null +++ b/apps/admin-ui-cos/src/views/cos/fields/feature-switch-field.tsx @@ -0,0 +1,35 @@ +/* + * SPDX-FileCopyrightText: 2026 Zextras + * + * SPDX-License-Identifier: AGPL-3.0-only + */ +import { Switch } from '@zextras/ui-components'; +import type { FC } from 'react'; + +import type { CosFeaturesFormApi, CosFeaturesFormValues } from '../types'; + +type FeatureSwitchFieldProps = { + form: CosFeaturesFormApi; + name: keyof CosFeaturesFormValues; + label: string; + disabled?: boolean; +}; + +export const FeatureSwitchField: FC = ({ + form, + name, + label, + disabled, +}) => ( + + {(field) => ( + field.handleChange(field.state.value === 'TRUE' ? 'FALSE' : 'TRUE')} + label={label} + iconColor="primary" + disabled={disabled} + /> + )} + +); diff --git a/apps/admin-ui-cos/src/views/cos/general-information/cos-general-information.tsx b/apps/admin-ui-cos/src/views/cos/general-information/cos-general-information.tsx new file mode 100644 index 000000000..92bc8d9c6 --- /dev/null +++ b/apps/admin-ui-cos/src/views/cos/general-information/cos-general-information.tsx @@ -0,0 +1,35 @@ +/* + * SPDX-FileCopyrightText: 2022 Zextras + * + * SPDX-License-Identifier: AGPL-3.0-only + */ +import { Container } from '@zextras/ui-components'; +import { useCurrentUserRights } from '@zextras/ui-shared'; +import { find } from 'lodash-es'; +import { useParams } from 'react-router'; + +import { COS } from '../../../constants'; +import { useCosDetail } from '../../../services/use-cos-detail'; +import { GeneralInformationForm } from './general-information-form'; + +export const CosGeneralInformation = () => { + const { cosId } = useParams(); + const { data: cosDetailData, isPending } = useCosDetail(cosId); + const cosInformation = cosDetailData?.cos?.[0]?.a; + const { data: rights = [] } = useCurrentUserRights(); + + const readonlyCOS = (() => { + const rightsConfig = find(rights, { type: COS }) || { all: [], type: COS }; + return !rightsConfig?.all?.[0]?.setAttrs?.[0]?.all; + })(); + + if (isPending) { + return ( + + + + ); + } + + return ; +}; diff --git a/apps/admin-ui-cos/src/views/cos/general-information/cos-info-fields.tsx b/apps/admin-ui-cos/src/views/cos/general-information/cos-info-fields.tsx new file mode 100644 index 000000000..5ccbb0888 --- /dev/null +++ b/apps/admin-ui-cos/src/views/cos/general-information/cos-info-fields.tsx @@ -0,0 +1,157 @@ +/* + * SPDX-FileCopyrightText: 2026 Zextras + * + * SPDX-License-Identifier: AGPL-3.0-only + */ +import type { ReactFormExtendedApi } from '@tanstack/react-form'; +import { + Container, + CustomTextArea, + Input, + LabeledValue, + ListRow, + Row, +} from '@zextras/ui-components'; +import type { ChangeEvent, FC } from 'react'; +import { useTranslation } from 'react-i18next'; + +export type GeneralInfoFormValues = { + cn: string; + description: string; + zimbraNotes: string; +}; + +type CosInfoFormApi = ReactFormExtendedApi< + GeneralInfoFormValues, + any, + any, + any, + any, + any, + any, + any, + any, + any, + any, + any +>; + +type CosInfoFieldsProps = { + form: CosInfoFormApi; + cosId: string | undefined; + cosCreationDate: string; + totalAccount: number; + totalDomain: number; + canDeleteCOS: boolean; + readonlyCOS: boolean; +}; + +export const CosInfoFields: FC = ({ + form, + cosId, + cosCreationDate, + totalAccount, + totalDomain, + canDeleteCOS, + readonlyCOS, +}) => { + const [t] = useTranslation(); + + return ( + + + + + + {(field) => ( + ): void => { + field.handleChange(e.target.value); + }} + disabled={canDeleteCOS || readonlyCOS} + /> + )} + + + + + + {}} + /> + + + {}} + /> + + + + + + + + + + + + + + {(field) => ( + ): void => { + field.handleChange(e.target.value); + }} + disabled={readonlyCOS} + /> + )} + + + + + + + {(field) => ( + ): void => { + field.handleChange(e.target.value); + }} + disabled={readonlyCOS} + /> + )} + + + + + + ); +}; diff --git a/apps/admin-ui-cos/src/views/cos/general-information/delete-cos-modal.tsx b/apps/admin-ui-cos/src/views/cos/general-information/delete-cos-modal.tsx new file mode 100644 index 000000000..9383b80ce --- /dev/null +++ b/apps/admin-ui-cos/src/views/cos/general-information/delete-cos-modal.tsx @@ -0,0 +1,135 @@ +/* + * SPDX-FileCopyrightText: 2026 Zextras + * + * SPDX-License-Identifier: AGPL-3.0-only + */ +import { + Button, + Container, + Modal, + Padding, + useSnackbar, +} from '@zextras/ui-components'; +import { replaceHistory } from '@zextras/ui-shared'; +import { useState } from 'react'; +import { Trans, useTranslation } from 'react-i18next'; + +import { deleteCOS } from '../../../services/delete-cos-service'; + +type DeleteCosModalProps = { + open: boolean; + onClose: () => void; + cosName: string; + cosId: string; +}; + +export const DeleteCosModal = ({ + open, + onClose, + cosName, + cosId, +}: DeleteCosModalProps) => { + const [t] = useTranslation(); + const createSnackbar = useSnackbar(); + const [isRequestInProgress, setIsRequestInProgress] = useState(false); + + const onDeleteCOS = (): void => { + setIsRequestInProgress(true); + deleteCOS(cosId) + .then((data: unknown) => { + setIsRequestInProgress(false); + if (data) { + createSnackbar({ + key: 'info', + severity: 'info', + label: t('label.delete_cos_succeess', { + cosname: cosName, + defaultValue: 'The {{cosname}} has been deleted successfully', + }), + autoHideTimeout: 3000, + hideButton: true, + replace: true, + }); + onClose(); + replaceHistory(`/cos_list`); + } + }) + .catch((error: unknown) => { + setIsRequestInProgress(false); + createSnackbar({ + key: 'error', + severity: 'error', + label: + (error as Error)?.message || + t('label.something_wrong_error_msg', 'Something went wrong. Please try again.'), + autoHideTimeout: 3000, + hideButton: true, + replace: true, + }); + }); + }; + + return ( + }} + values={{ cosname: cosName }} + /> + } + open={open} + showCloseIcon + onClose={onClose} + size="medium" + customFooter={ + + + +
+ {isFetching && !isPlaceholderData && ( + + + + )} + {showEmptyState && ( + + + logo + + + + {'This list is empty.'} + + + + )} + {showPagination && ( + + + + + + + + + )} + + + + ); +}; diff --git a/apps/admin-ui-cos/src/views/cos/general-list-panel.tsx b/apps/admin-ui-cos/src/views/cos/general-list-panel.tsx index ac9caaf19..7cd2db831 100644 --- a/apps/admin-ui-cos/src/views/cos/general-list-panel.tsx +++ b/apps/admin-ui-cos/src/views/cos/general-list-panel.tsx @@ -5,30 +5,28 @@ */ import { ListItems, ListItemType, ListPanelItem } from '@zextras/ui-components'; import { replaceHistory } from '@zextras/ui-shared'; -import { FC, useCallback, useEffect, useState } from 'react'; +import { useEffect, useState } from 'react'; import { useTranslation } from 'react-i18next'; import { COS_LIST, IS_GENERAL_LIST_EXPANDED } from '../../constants'; -import { useCosStore } from '../../store/cos/store'; type GeneralListPanelProps = { generalOptionItems: Array; + selectedOperationItem?: string | null; }; -const GeneralListPanel: FC = ({ generalOptionItems }) => { +export const GeneralListPanel = ({ + generalOptionItems, + selectedOperationItem, +}: GeneralListPanelProps) => { const [t] = useTranslation(); const [isGeneralListExpanded, setIsGeneralListExpanded] = useState(true); - const { cosView, setCosView } = useCosStore(); - const navigateToGeneralView = useCallback( - (view: string) => { - setCosView(view); - if (view === COS_LIST) { - replaceHistory(`/${COS_LIST}`); - } - }, - [setCosView], - ); + const navigateToGeneralView = (view: string) => { + if (view === COS_LIST) { + replaceHistory(`/${COS_LIST}`); + } + }; const toggleGeneralView = (): void => { if (isGeneralListExpanded) { @@ -59,12 +57,10 @@ const GeneralListPanel: FC = ({ generalOptionItems }) => {isGeneralListExpanded && ( )} ); }; - -export default GeneralListPanel; diff --git a/apps/admin-ui-cos/src/views/cos/preferences/COSPreferences.tsx b/apps/admin-ui-cos/src/views/cos/preferences/COSPreferences.tsx deleted file mode 100644 index 834c500ee..000000000 --- a/apps/admin-ui-cos/src/views/cos/preferences/COSPreferences.tsx +++ /dev/null @@ -1,189 +0,0 @@ -/* - * SPDX-FileCopyrightText: 2024 Zextras - * - * SPDX-License-Identifier: AGPL-3.0-only - */ -import { Container, useSnackbar } from '@zextras/ui-components'; -import { useCurrentUserRights } from '@zextras/ui-shared'; -import { find } from 'lodash-es'; -import React, { useCallback, useEffect, useMemo, useState } from 'react'; -import { useTranslation } from 'react-i18next'; - -import { CosAttributes, CosPrefAttributes } from '../../../../types/cos'; -import { COS, ZIMBRA_ADMIN_URN } from '../../../constants'; -import { flushCache } from '../../../services/flush-cache-service'; -import { modifyCos, ModifyCosBody } from '../../../services/modify-cos-service'; -import { useCosStore } from '../../../store/cos/store'; -import { PageLayout } from '../../page-layout'; -import { localeList } from '../../utility/utils'; -import { DEFAULT_COS_PREF_ATTRIBUTES } from '../constants'; -import { AttributeValue } from '../constants/types'; -import { CalendarOptions } from './CalendarOptions'; -import { ContactOptions } from './ContactOptions'; -import { ForwardingOptions } from './ForwardingOptions'; -import { GeneralOptions } from './GeneralOptions'; -import { useHasUnsavedChanges } from './hooks/useHasUnsavedChanges'; -import { MailOptions } from './MailOptions'; -import { ReceivingMails } from './ReceivingMails'; -import { SendingMails } from './SendingMails'; - -export const COSPreferences = (): React.JSX.Element => { - const [t] = useTranslation(); - const createSnackbar = useSnackbar(); - const cosInformation = useCosStore((state) => state.cos?.a); - const { data: rights = [] } = useCurrentUserRights(); - const setCos = useCosStore((state) => state.setCos); - - const locales = useMemo(() => localeList(t), [t]); - const isReadOnlyCos = useMemo(() => { - const rightsConfig = find(rights, { type: COS }) || { all: [], type: COS }; - return !rightsConfig?.all?.[0]?.setAttrs?.[0]?.all; - }, [rights]); - - const [currentCosAttributes, setCurrentCosAttributes] = useState>(); - const [draftCosPrefAttributes, setDraftCosPrefAttributes] = useState( - DEFAULT_COS_PREF_ATTRIBUTES, - ); - - const hasUnsavedChanges = useHasUnsavedChanges(currentCosAttributes, draftCosPrefAttributes); - - const handleCosPrefAttributeChange = useCallback( - (key: keyof CosPrefAttributes, value: AttributeValue) => { - if (value === null) return; - const newValue = typeof value === 'object' && 'value' in value ? value.value : value; - setDraftCosPrefAttributes((prev) => ({ - ...prev, - [key]: newValue, - })); - }, - [], - ); - - const handleSwitchOptionChange = useCallback((key: keyof CosPrefAttributes): void => { - setDraftCosPrefAttributes((prev: CosPrefAttributes) => ({ - ...prev, - [key]: prev[key] === 'TRUE' ? 'FALSE' : 'TRUE', - })); - }, []); - - const setInitialValues = useCallback((initialCosPrefAttributes: Partial) => { - setDraftCosPrefAttributes((prev) => ({ - ...DEFAULT_COS_PREF_ATTRIBUTES, - ...prev, - ...initialCosPrefAttributes, - })); - }, []); - - const handleSave = (): void => { - const zimbraID = currentCosAttributes?.zimbraId; - if (!zimbraID) return; - - const body: ModifyCosBody = { - _jsns: ZIMBRA_ADMIN_URN, - id: { _content: zimbraID }, - a: Object.keys(DEFAULT_COS_PREF_ATTRIBUTES).map((key) => ({ - n: key, - _content: draftCosPrefAttributes[key as keyof CosPrefAttributes], - })), - }; - - modifyCos(body) - .then((data) => { - flushCache('cos', 'id', body.id._content); - createSnackbar({ - key: 'success', - severity: 'success', - label: t('label.change_save_success_msg', 'The change has been saved successfully'), - autoHideTimeout: 3000, - hideButton: true, - replace: true, - }); - setCos(data?.cos[0]); - }) - .catch((error) => { - createSnackbar({ - key: 'error', - severity: 'error', - label: - error?.message || - t('label.something_wrong_error_msg', 'Something went wrong. Please try again.'), - autoHideTimeout: 3000, - hideButton: true, - replace: true, - }); - }); - }; - - const handleCancel = (): void => { - currentCosAttributes && setInitialValues(currentCosAttributes); - }; - - useEffect(() => { - if (cosInformation?.length) { - const initialCosPrefAttributes = cosInformation.reduce((accumulator, item) => { - const key = item?.n as keyof CosAttributes; - accumulator[key] = item._content; - return accumulator; - }, {} as Partial); - setCurrentCosAttributes(initialCosPrefAttributes); - setInitialValues(initialCosPrefAttributes); - } - }, [cosInformation, setInitialValues]); - - return ( - - - - - - - - - - - - - - - - - - ); -}; diff --git a/apps/admin-ui-cos/src/views/cos/preferences/CalendarOptions.tsx b/apps/admin-ui-cos/src/views/cos/preferences/CalendarOptions.tsx deleted file mode 100644 index f690dfb37..000000000 --- a/apps/admin-ui-cos/src/views/cos/preferences/CalendarOptions.tsx +++ /dev/null @@ -1,360 +0,0 @@ -/* - * SPDX-FileCopyrightText: 2024 Zextras - * - * SPDX-License-Identifier: AGPL-3.0-only - */ -import { Container, ListRow, Row, Select, SelectItem, Switch } from '@zextras/ui-components'; -import React, { useMemo } from 'react'; -import { useTranslation } from 'react-i18next'; - -import { CosPrefAttributes } from '../../../../types/cos'; -import { appointmentReminder, timeZoneList } from '../../utility/utils'; -import { AttributeValue } from '../constants/types'; -import { findSelectItemWithFallback } from '../utils'; - -interface CalendarOptionsProps { - cosPrefAttributes: CosPrefAttributes; - isReadOnlyCosEntry: boolean; - onCosAttributeChanged: (attribute: keyof CosPrefAttributes, value: AttributeValue) => void; - onSwitchOptionChanged: (value: keyof CosPrefAttributes) => void; -} - -export const CalendarOptions = ({ - cosPrefAttributes, - isReadOnlyCosEntry, - onSwitchOptionChanged, - onCosAttributeChanged -}: CalendarOptionsProps): React.JSX.Element => { - const [t] = useTranslation(); - const APPOINTMENT_REMINDER: SelectItem[] = useMemo(() => appointmentReminder(t), [t]); - const TIMEZONES: SelectItem[] = useMemo(() => timeZoneList(t), [t]); - const MINUTES_LABEL = t('label.minutes', 'minutes'); - const DEFAULT_APPOINTMENT_DURATION: SelectItem[] = useMemo( - () => [ - { label: `30 ${MINUTES_LABEL}`, value: '30m' }, - { label: `60 ${MINUTES_LABEL}`, value: '60m' }, - { label: `90 ${MINUTES_LABEL}`, value: '90m' }, - { label: `120 ${MINUTES_LABEL}`, value: '120m' } - ], - [MINUTES_LABEL] - ); - const DEFAULT_VIEW_OPTIONS: SelectItem[] = useMemo( - () => [ - { label: t('cos.default_view.month', 'Month View'), value: 'month' }, - { label: t('cos.default_view.week', 'Week View'), value: 'week' }, - { label: t('cos.default_view.day', 'Day View'), value: 'day' }, - { label: t('cos.default_view.work_week', 'Work Week View'), value: 'workWeek' }, - { label: t('cos.default_view.list', 'List View'), value: 'list' } - ], - [t] - ); - const FIRST_DAY_OF_WEEK: SelectItem[] = useMemo( - () => [ - { label: t('label.week_day.sunday', 'Sunday'), value: '0' }, - { label: t('label.week_day.monday', 'Monday'), value: '1' }, - { label: t('label.week_day.tuesday', 'Tuesday'), value: '2' }, - { label: t('label.week_day.wednesday', 'Wednesday'), value: '3' }, - { label: t('label.week_day.thursday', 'Thursday'), value: '4' }, - { label: t('label.week_day.friday', 'Friday'), value: '5' }, - { label: t('label.week_day.saturday', 'Saturday'), value: '6' } - ], - [t] - ); - const APPOINTMENT_VISIBILITY: SelectItem[] = useMemo( - () => [ - { label: t('label.public', 'Public'), value: 'public' }, - { label: t('label.private', 'Private'), value: 'private' } - ], - [t] - ); - return ( - - - {t('label.calendar_options', 'Calendar Options')} - - - - - - - onCosAttributeChanged('zimbraPrefCalendarDefaultApptDuration', value) - } - disabled={isReadOnlyCosEntry} - /> - - - - - - - - - - onCosAttributeChanged('zimbraPrefCalendarInitialView', value) - } - disabled={isReadOnlyCosEntry} - /> - - - - - - - - - - onCosAttributeChanged('zimbraPrefCalendarApptVisibility', value) - } - disabled={isReadOnlyCosEntry} - /> - - - - - - - - - - onSwitchOptionChanged('zimbraPrefCalendarShowPastDueReminders') - } - label={t( - 'cos.enable_past_due_reminders', - `Enable reminders of appointments in the past` - )} - iconColor="primary" - disabled={isReadOnlyCosEntry} - /> - - - - onSwitchOptionChanged('zimbraPrefCalendarAllowCancelEmailToSelf') - } - label={t('cos.allow_sending_cancellation_mail', `Allow sending cancellation mail`)} - iconColor="primary" - disabled={isReadOnlyCosEntry} - /> - - - - - - - - - - onSwitchOptionChanged('zimbraPrefCalendarAllowForwardedInvite') - } - label={t( - 'cos.add_forwarded_invites_to_calendar', - `Automatically add forwarded appointments to the calendar` - )} - iconColor="primary" - disabled={isReadOnlyCosEntry} - /> - - - - onSwitchOptionChanged('zimbraPrefCalendarAllowPublishMethodInvite') - } - label={t('cos.add_invites_with_publish_method', 'Add invites with PUBLISH method')} - iconColor="primary" - disabled={isReadOnlyCosEntry} - /> - - - - - - - - - onSwitchOptionChanged('zimbraPrefCalendarAutoAddInvites')} - label={t( - 'label.add_appointments_when_invited', - `Automatically add appointments when the user is invited` - )} - iconColor="primary" - disabled={isReadOnlyCosEntry} - /> - - - - onSwitchOptionChanged('zimbraPrefCalendarSendInviteDeniedAutoReply') - } - label={t( - 'cos.auto_decline_if_inviter_is_blacklisted', - 'Auto-decline if the sender is blacklisted' - )} - iconColor="primary" - disabled={isReadOnlyCosEntry} - /> - - - - - - - - - - onSwitchOptionChanged('zimbraPrefCalendarNotifyDelegatedChanges') - } - label={t( - 'cos.notify_changes_by_delegated_access', - `Notify changes made by delegated accounts` - )} - iconColor="primary" - disabled={isReadOnlyCosEntry} - /> - - - onSwitchOptionChanged('zimbraPrefAppleIcalDelegationEnabled')} - label={t( - 'cos.use_ical_delegation_model_for_shared_calendars', - `Use iCal delegation model for shared calendars` - )} - iconColor="primary" - disabled={isReadOnlyCosEntry} - /> - - - - - - ); -}; diff --git a/apps/admin-ui-cos/src/views/cos/preferences/ContactOptions.tsx b/apps/admin-ui-cos/src/views/cos/preferences/ContactOptions.tsx deleted file mode 100644 index 32461a770..000000000 --- a/apps/admin-ui-cos/src/views/cos/preferences/ContactOptions.tsx +++ /dev/null @@ -1,65 +0,0 @@ -/* - * SPDX-FileCopyrightText: 2024 Zextras - * - * SPDX-License-Identifier: AGPL-3.0-only - */ -import { Container, ListRow, Row, Switch } from '@zextras/ui-components'; -import React from 'react'; -import { useTranslation } from 'react-i18next'; - -import { CosPrefAttributes } from '../../../../types/cos'; - -interface ContactOptionsProps { - cosPrefAttributes: CosPrefAttributes; - isReadOnlyCosEntry: boolean; - changeSwitchOption: (value: keyof CosPrefAttributes) => void; -} - -export const ContactOptions = ({ - cosPrefAttributes, - isReadOnlyCosEntry, - changeSwitchOption -}: ContactOptionsProps): React.JSX.Element => { - const { t } = useTranslation(); - return ( - - - {t('label.contact_options', 'Contact Options')} - - - - - - changeSwitchOption('zimbraPrefAutoAddAddressEnabled')} - label={t('cos.enable_auto_add_contacts', `Enable auto-add contacts`)} - iconColor="primary" - disabled={isReadOnlyCosEntry} - /> - - - changeSwitchOption('zimbraPrefGalAutoCompleteEnabled')} - label={t('cos.use_gal_to_auto_fill', 'Use GAL to auto-fill')} - iconColor="primary" - disabled={isReadOnlyCosEntry} - /> - - - - - - ); -}; diff --git a/apps/admin-ui-cos/src/views/cos/preferences/ForwardingOptions.tsx b/apps/admin-ui-cos/src/views/cos/preferences/ForwardingOptions.tsx deleted file mode 100644 index f237aeaea..000000000 --- a/apps/admin-ui-cos/src/views/cos/preferences/ForwardingOptions.tsx +++ /dev/null @@ -1,74 +0,0 @@ -/* - * SPDX-FileCopyrightText: 2024 Zextras - * - * SPDX-License-Identifier: AGPL-3.0-only - */ -import { Container, ListRow, Row, Switch } from '@zextras/ui-components'; -import React from 'react'; -import { useTranslation } from 'react-i18next'; - -import { CosPrefAttributes } from '../../../../types/cos'; - -interface ForwardingOptionsProps { - cosPrefAttributes: CosPrefAttributes; - isReadOnlyCosEntry: boolean; - changeSwitchOption: (key: keyof CosPrefAttributes) => void; -} - -export const ForwardingOptions = ({ - cosPrefAttributes, - isReadOnlyCosEntry, - changeSwitchOption -}: ForwardingOptionsProps): React.JSX.Element => { - const { t } = useTranslation(); - - return ( - - - {t('label.forwarding', 'Forwarding')} - - - - - - changeSwitchOption('zimbraFeatureMailForwardingEnabled')} - label={t( - 'cos.user_can_specify_forwarding_address', - `User can specify forwarding address` - )} - iconColor="primary" - disabled={isReadOnlyCosEntry} - /> - - - - changeSwitchOption('zimbraFeatureMailForwardingInFiltersEnabled') - } - label={t( - 'cos.user_can_specify_mail_forwarding_filter', - 'User can specify mail forwarding filter' - )} - iconColor="primary" - disabled={isReadOnlyCosEntry} - /> - - - - - - ); -}; diff --git a/apps/admin-ui-cos/src/views/cos/preferences/GeneralOptions.tsx b/apps/admin-ui-cos/src/views/cos/preferences/GeneralOptions.tsx deleted file mode 100644 index 5d243928c..000000000 --- a/apps/admin-ui-cos/src/views/cos/preferences/GeneralOptions.tsx +++ /dev/null @@ -1,68 +0,0 @@ -/* - * SPDX-FileCopyrightText: 2024 Zextras - * - * SPDX-License-Identifier: AGPL-3.0-only - */ -import { Container, ListRow, Row, Select, SelectItem } from '@zextras/ui-components'; -import React from 'react'; -import { useTranslation } from 'react-i18next'; - -import { CosPrefAttributes } from '../../../../types/cos'; -import { AttributeValue } from '../constants/types'; - -interface GeneralOptionsProps { - cosPrefAttributes: CosPrefAttributes; - locales: SelectItem[]; - isReadOnlyCosEntry: boolean; - onCosAttributeChanged: (attribute: keyof CosPrefAttributes, value: AttributeValue) => void; -} - -export const GeneralOptions = ({ - cosPrefAttributes, - locales, - isReadOnlyCosEntry, - onCosAttributeChanged -}: GeneralOptionsProps): React.JSX.Element => { - const { t } = useTranslation(); - - return ( - - - {t('label.general_options', 'General Options')} - - - - - - - item.value === cosPrefAttributes?.zimbraPrefGroupMailBy - ) || GROUP_BY[0] - } - onChange={(value: AttributeValue): void => - onCosAttributeChanged('zimbraPrefGroupMailBy', value) - } - disabled={isReadOnlyCosEntry} - /> - - - ): void => { - if ( - ![ - 'Backspace', - 'Delete', - 'ArrowLeft', - 'ArrowRight', - 'ArrowUp', - 'ArrowDown', - '0', - '1', - '2', - '3', - '4', - '5', - '6', - '7', - '8', - '9' - ].includes(e.key) - ) { - e.preventDefault(); - } - }} - onChange={(e: React.ChangeEvent): void => { - const value = Number(e.target.value); - updateHumanFriendlyFileUploadMaxSizePerFileLabel(value); - }} - /> - - - - - {humanFriendlyFileUploadMaxSizePerFileLabel} - - - - - - - - ); -}; diff --git a/apps/admin-ui-cos/src/views/cos/preferences/ReceivingMails.tsx b/apps/admin-ui-cos/src/views/cos/preferences/ReceivingMails.tsx deleted file mode 100644 index 9ab4ee041..000000000 --- a/apps/admin-ui-cos/src/views/cos/preferences/ReceivingMails.tsx +++ /dev/null @@ -1,215 +0,0 @@ -/* - * SPDX-FileCopyrightText: 2024 Zextras - * - * SPDX-License-Identifier: AGPL-3.0-only - */ -import { Container, Input, ListRow, Row, Select, SelectItem } from '@zextras/ui-components'; -import React, { useCallback, useEffect, useMemo, useState } from 'react'; -import { useTranslation } from 'react-i18next'; - -import { CosPrefAttributes } from '../../../../types/cos'; -import { AttributeValue } from '../constants/types'; -import { findSelectItemWithFallback } from '../utils'; - -interface ReceivingMailsProps { - cosPrefAttributes: CosPrefAttributes; - isReadOnlyCosEntry: boolean; - onCosAttributeChanged: (attribute: keyof CosPrefAttributes, value: AttributeValue) => void; -} - -export const ReceivingMails = ({ - cosPrefAttributes, - isReadOnlyCosEntry, - onCosAttributeChanged -}: ReceivingMailsProps): React.JSX.Element => { - const { t } = useTranslation(); - - const TIME_TYPES: SelectItem[] = useMemo( - () => [ - { label: `${t('label.days', 'Days')}`, value: 'd' }, - { label: `${t('label.hours', 'Hours')}`, value: 'h' }, - - { label: `${t('label.minutes', 'Minutes')}`, value: 'm' }, - { label: `${t('label.seconds', 'Seconds')}`, value: 's' } - ], - [t] - ); - - const POLLING_INTERVAL: SelectItem[] = useMemo( - () => [ - { - label: t('cos.as_new_mail_arrives', 'As New Mail Arrives'), - value: '500' - }, - { label: `2 ${t('label.minutes', 'minutes')}`, value: '2m' }, - { label: `3 ${t('label.minutes', 'minutes')}`, value: '3m' }, - { label: `4 ${t('label.minutes', 'minutes')}`, value: '4m' }, - { label: `5 ${t('label.minutes', 'minutes')}`, value: '5m' }, - { label: `6 ${t('label.minutes', 'minutes')}`, value: '6m' }, - { label: `7 ${t('label.minutes', 'minutes')}`, value: '7m' }, - { label: `8 ${t('label.minutes', 'minutes')}`, value: '8m' }, - { label: `9 ${t('label.minutes', 'minutes')}`, value: '9m' }, - { label: `10 ${t('label.minutes', 'minutes')}`, value: '10m' }, - { label: `15 ${t('label.minutes', 'minutes')}`, value: '15m' }, - { - label: t('cos.manuallly', 'Manually'), - value: '31536000s' - } - ], - [t] - ); - - const [zimbraPrefMailPollingIntervalNum, setZimbraPrefMailPollingIntervalNum] = useState( - cosPrefAttributes?.zimbraMailMinPollingInterval?.slice(0, -1) || '' - ); - const [prefMailPollingIntervalType, setPrefMailPollingIntervalType] = useState( - cosPrefAttributes?.zimbraMailMinPollingInterval?.slice(-1) || '' - ); - - const onPrefMailPollingIntervalNumChange = useCallback( - (e: React.ChangeEvent) => { - onCosAttributeChanged( - 'zimbraMailMinPollingInterval', - e.target.value ? `${e.target.value}${prefMailPollingIntervalType}` : '' - ); - setZimbraPrefMailPollingIntervalNum(e.target.value); - }, - [onCosAttributeChanged, prefMailPollingIntervalType] - ); - - const onPrefMailPollingIntervalTypeChange = useCallback( - (v: SelectItem[] | string | null) => { - onCosAttributeChanged( - 'zimbraMailMinPollingInterval', - zimbraPrefMailPollingIntervalNum ? `${zimbraPrefMailPollingIntervalNum}${v}` : '' - ); - }, - [onCosAttributeChanged, zimbraPrefMailPollingIntervalNum] - ); - - useEffect(() => { - setZimbraPrefMailPollingIntervalNum( - cosPrefAttributes?.zimbraMailMinPollingInterval?.slice(0, -1) - ); - setPrefMailPollingIntervalType(cosPrefAttributes?.zimbraMailMinPollingInterval?.slice(-1)); - }, [cosPrefAttributes?.zimbraMailMinPollingInterval]); - - const SEND_READ_RECEIPTS: SelectItem[] = useMemo( - () => [ - { label: t('label.never_send_read_receipt', 'Never send a read receipt'), value: 'never' }, - { label: t('label.always_send_read_receipt', 'Always send a read receipt'), value: 'always' }, - { label: t('label.ask_me', 'Ask me'), value: 'prompt' } - ], - [t] - ); - - return ( - - - {t('label.receiving_mails', 'Receiving Mails')} - - - - - - ): void => { - onPrefMailPollingIntervalNumChange(e); - }} - disabled={isReadOnlyCosEntry} - /> - - - item.value === cosPrefAttributes?.zimbraPrefMailPollingInterval - ) || POLLING_INTERVAL[0] - } - onChange={(value: AttributeValue): void => - onCosAttributeChanged('zimbraPrefMailPollingInterval', value) - } - disabled={isReadOnlyCosEntry} - /> - - - - - - - - - { + const v = + typeof value === 'object' && value !== null && 'value' in value + ? (value as SelectItem).value + : (value as string); + field.handleChange(v); + }} + disabled={readonlyCOS} + /> + )} + + + + + {(field) => ( + { + const v = + typeof value === 'object' && value !== null && 'value' in value + ? (value as SelectItem).value + : (value as string); + field.handleChange(v); + }} + disabled={readonlyCOS} + /> + )} + + + + + {(field) => ( + { + const v = + typeof value === 'object' && value !== null && 'value' in value + ? (value as SelectItem).value + : (value as string); + field.handleChange(v); + }} + disabled={readonlyCOS} + /> + )} + + + + + {(field) => ( + item.value === field.state.value) || locales[0] + } + onChange={(value): void => { + const newValue = + typeof value === 'object' && value !== null && 'value' in value + ? (value as SelectItem).value + : (value as string); + field.handleChange(newValue); + }} + disabled={readonlyCOS} + /> + )} + + + + + + + ); +}; diff --git a/apps/admin-ui-cos/src/views/cos/preferences/sections/mail-options.tsx b/apps/admin-ui-cos/src/views/cos/preferences/sections/mail-options.tsx new file mode 100644 index 000000000..1640b844c --- /dev/null +++ b/apps/admin-ui-cos/src/views/cos/preferences/sections/mail-options.tsx @@ -0,0 +1,244 @@ +/* + * SPDX-FileCopyrightText: 2026 Zextras + * + * SPDX-License-Identifier: AGPL-3.0-only + */ +import { useSelector } from '@tanstack/react-store'; +import { + Container, + Input, + ListRow, + Padding, + Row, + Select, + SelectItem, + Switch, +} from '@zextras/ui-components'; +import { useTranslation } from 'react-i18next'; + +import { bytesToHumanReadable, charactorSet, conversationGroupBy } from '../../../utility/utils'; +import { CosPreferencesFormApi } from '../types'; + +type MailOptionsProps = { + form: CosPreferencesFormApi; + readonlyCOS: boolean; +}; + +const bytesToHumanFriendlyFileUploadMaxSizePerFile = ( + bytes: string | number, + t: (key: string, defaultValue: string) => string, +): string => { + const parsedBytes = Number(bytes); + return parsedBytes === 0 + ? t('cos.unlimited', 'Unlimited') + : `~${bytesToHumanReadable(parsedBytes)}`; +}; + +export const MailOptions = ({ form, readonlyCOS }: MailOptionsProps) => { + const [t] = useTranslation(); + const GROUP_BY: SelectItem[] = conversationGroupBy(t); + const CHARACTOR_SET: SelectItem[] = charactorSet(); + + const fileUploadMaxSizePerFile = useSelector( + form.store, + (s) => s.values.zimbraFileUploadMaxSizePerFile, + ); + const humanFriendlyLabel = bytesToHumanFriendlyFileUploadMaxSizePerFile( + fileUploadMaxSizePerFile, + t, + ); + + return ( + + + {t('label.mailing_options', 'Mail Options')} + + + + + {(field) => ( + field.handleChange(field.state.value === 'TRUE' ? 'FALSE' : 'TRUE')} + label={t('cos.view_mail_as_html', 'View mail as HTML (when possible)')} + iconColor="primary" + disabled={readonlyCOS} + /> + )} + + + + + + + + + {(field) => ( + item.value === field.state.value) || + CHARACTOR_SET[0] + } + onChange={(value): void => { + const newValue = + typeof value === 'object' && value !== null && 'value' in value + ? (value as SelectItem).value + : (value as string); + field.handleChange(newValue); + }} + disabled={readonlyCOS} + /> + )} + + + + + + + + + + + {(field) => ( + + field.handleChange(field.state.value === 'TRUE' ? 'FALSE' : 'TRUE') + } + label={t( + 'cos.auto_delete_duplicate_messages', + 'Auto-Delete duplicate messages', + )} + iconColor="primary" + disabled={readonlyCOS} + /> + )} + + + + + {(field) => ( + + field.handleChange(field.state.value === 'TRUE' ? 'FALSE' : 'TRUE') + } + label={t( + 'cos.enable_new_mail_toast_notification', + 'Enable New Mail Toast Notification', + )} + iconColor="primary" + disabled={readonlyCOS} + /> + )} + + + + + + + + + + + {(field) => ( + ): void => { + if ( + ![ + 'Backspace', + 'Delete', + 'ArrowLeft', + 'ArrowRight', + 'ArrowUp', + 'ArrowDown', + '0', + '1', + '2', + '3', + '4', + '5', + '6', + '7', + '8', + '9', + ].includes(e.key) + ) { + e.preventDefault(); + } + }} + onChange={(e: React.ChangeEvent): void => { + field.handleChange(e.target.value); + }} + /> + )} + + + + + + {humanFriendlyLabel} + + + + + + + + ); +}; diff --git a/apps/admin-ui-cos/src/views/cos/preferences/sections/receiving-mails.tsx b/apps/admin-ui-cos/src/views/cos/preferences/sections/receiving-mails.tsx new file mode 100644 index 000000000..dc24e379f --- /dev/null +++ b/apps/admin-ui-cos/src/views/cos/preferences/sections/receiving-mails.tsx @@ -0,0 +1,216 @@ +/* + * SPDX-FileCopyrightText: 2026 Zextras + * + * SPDX-License-Identifier: AGPL-3.0-only + */ +import { useSelector } from '@tanstack/react-store'; +import { Container, Input, ListRow, Row, Select, SelectItem } from '@zextras/ui-components'; +import { useEffect, useState } from 'react'; +import { useTranslation } from 'react-i18next'; + +import { findSelectItemWithFallback } from '../../utils'; +import { CosPreferencesFormApi } from '../types'; + +type ReceivingMailsProps = { + form: CosPreferencesFormApi; + readonlyCOS: boolean; +}; + +export const ReceivingMails = ({ form, readonlyCOS }: ReceivingMailsProps) => { + const [t] = useTranslation(); + + const TIME_TYPES: Array = [ + { label: `${t('label.days', 'Days')}`, value: 'd' }, + { label: `${t('label.hours', 'Hours')}`, value: 'h' }, + { label: `${t('label.minutes', 'Minutes')}`, value: 'm' }, + { label: `${t('label.seconds', 'Seconds')}`, value: 's' }, + ]; + + const POLLING_INTERVAL: Array = [ + { + label: t('cos.as_new_mail_arrives', 'As New Mail Arrives'), + value: '500', + }, + { label: `2 ${t('label.minutes', 'minutes')}`, value: '2m' }, + { label: `3 ${t('label.minutes', 'minutes')}`, value: '3m' }, + { label: `4 ${t('label.minutes', 'minutes')}`, value: '4m' }, + { label: `5 ${t('label.minutes', 'minutes')}`, value: '5m' }, + { label: `6 ${t('label.minutes', 'minutes')}`, value: '6m' }, + { label: `7 ${t('label.minutes', 'minutes')}`, value: '7m' }, + { label: `8 ${t('label.minutes', 'minutes')}`, value: '8m' }, + { label: `9 ${t('label.minutes', 'minutes')}`, value: '9m' }, + { label: `10 ${t('label.minutes', 'minutes')}`, value: '10m' }, + { label: `15 ${t('label.minutes', 'minutes')}`, value: '15m' }, + { + label: t('cos.manuallly', 'Manually'), + value: '31536000s', + }, + ]; + + const SEND_READ_RECEIPTS: Array = [ + { + label: t('label.never_send_read_receipt', 'Never send a read receipt'), + value: 'never', + }, + { + label: t('label.always_send_read_receipt', 'Always send a read receipt'), + value: 'always', + }, + { label: t('label.ask_me', 'Ask me'), value: 'prompt' }, + ]; + + const [pollingIntervalNum, setPollingIntervalNum] = useState(''); + const [pollingIntervalType, setPollingIntervalType] = useState(''); + + const minPollingInterval = useSelector(form.store, (s) => s.values.zimbraMailMinPollingInterval); + + useEffect(() => { + const val = minPollingInterval ?? ''; + const num = val.slice(0, -1) || ''; + const unit = val.slice(-1) || ''; + setPollingIntervalNum(num); + setPollingIntervalType(unit); + }, [minPollingInterval]); + + return ( + + + {t('label.receiving_mails', 'Receiving Mails')} + + + + + + {(field) => { + return ( + <> + + ): void => { + const num = e.target.value; + setPollingIntervalNum(num); + field.handleChange(num ? `${num}${pollingIntervalType}` : ''); + }} + disabled={readonlyCOS} + /> + + + item.value === field.state.value) ?? + POLLING_INTERVAL[0] + } + onChange={(value): void => { + const v = + typeof value === 'object' && value !== null && 'value' in value + ? (value as SelectItem).value + : (value as string); + field.handleChange(v); + }} + disabled={readonlyCOS} + /> + )} + + + + + + + + + + + {(field) => ( +