From 74b6a2a7b5f796c51b1c8af43b5c2b93a7cf5c22 Mon Sep 17 00:00:00 2001 From: Louis BENSI Date: Thu, 20 Aug 2026 16:20:47 +0200 Subject: [PATCH 1/2] feat(web-ongoing-operations): add designated agent foa validation ref: #D2I-6259 Signed-off-by: Louis BENSI Co-Authored-By: Claude Opus 5 (1M context) --- .../dashboard/Messages_fr_FR.json | 12 +- .../Dashboard/DashboardPage.spec.tsx | 6 + .../OngoingOperationDatagridActions.spec.tsx | 222 ++++++++++++ .../OngoingOperationDatagridActions.tsx | 105 ++++++ .../Content/Update.Foa.component.spec.tsx | 327 ++++++++++++++++++ .../Update/Content/Update.Foa.component.tsx | 293 ++++++++++++++++ .../web-ongoing-operations/src/constants.ts | 16 + .../src/data/api/foa.spec.ts | 103 ++++++ .../src/data/api/foa.ts | 85 +++++ .../src/enum/foa.enum.ts | 4 + .../src/hooks/data/query.tsx | 93 +++++ .../useOngoingOperationDatagridColumns.tsx | 54 +-- .../pages/dashboard/allDom/AllDom.spec.tsx | 6 + .../src/pages/dashboard/dns/Dns.spec.tsx | 6 + .../pages/dashboard/domain/Domain.spec.tsx | 6 + .../src/routes/routes.constant.ts | 1 + .../src/routes/routes.tsx | 14 + .../web-ongoing-operations/src/setupTests.tsx | 20 +- .../web-ongoing-operations/src/types/index.ts | 43 +++ .../src/utils/foa.utils.spec.ts | 56 +++ .../src/utils/foa.utils.ts | 23 ++ 21 files changed, 1441 insertions(+), 54 deletions(-) create mode 100644 packages/manager/apps/web-ongoing-operations/src/components/OngoingOperationDatagrid/OngoingOperationDatagridActions.spec.tsx create mode 100644 packages/manager/apps/web-ongoing-operations/src/components/OngoingOperationDatagrid/OngoingOperationDatagridActions.tsx create mode 100644 packages/manager/apps/web-ongoing-operations/src/components/Update/Content/Update.Foa.component.spec.tsx create mode 100644 packages/manager/apps/web-ongoing-operations/src/components/Update/Content/Update.Foa.component.tsx create mode 100644 packages/manager/apps/web-ongoing-operations/src/data/api/foa.spec.ts create mode 100644 packages/manager/apps/web-ongoing-operations/src/data/api/foa.ts create mode 100644 packages/manager/apps/web-ongoing-operations/src/enum/foa.enum.ts create mode 100644 packages/manager/apps/web-ongoing-operations/src/utils/foa.utils.spec.ts create mode 100644 packages/manager/apps/web-ongoing-operations/src/utils/foa.utils.ts diff --git a/packages/manager/apps/web-ongoing-operations/public/translations/dashboard/Messages_fr_FR.json b/packages/manager/apps/web-ongoing-operations/public/translations/dashboard/Messages_fr_FR.json index cfbaad12cecd..b75246eaed30 100644 --- a/packages/manager/apps/web-ongoing-operations/public/translations/dashboard/Messages_fr_FR.json +++ b/packages/manager/apps/web-ongoing-operations/public/translations/dashboard/Messages_fr_FR.json @@ -88,6 +88,7 @@ "domain_operations_update_gender": "Genre", "domain_operations_update_language": "Langue", "domain_operations_update_firstname": "Prénom", + "domain_operations_update_lastname": "Nom", "domain_operations_update_name": "Nom", "domain_operations_update_uklegalform": "Forme légale", "domain_operations_update_nationalidentificationnumber": "Code fiscal", @@ -153,5 +154,14 @@ "domain_operations_update_nicbilling_click": "Changez les informations du contact facturation en cliquant ici", "domain_operations_accelerate_success": "L'opération a été accélérée avec succès.", "domain_operations_cancel_success": "L'opération a été annulée avec succès.", - "domain_operations_relaunch_success": "L'opération a été relancée avec succès." + "domain_operations_relaunch_success": "L'opération a été relancée avec succès.", + "domain_operations_foa_cta": "Valider en tant qu'agent désigné", + "domain_operations_foa_title": "Validation en tant qu'agent désigné pour le nom de domaine {{t0}}", + "domain_operations_foa_description": "Ce changement de titulaire est en attente de la validation des formulaires d'autorisation (FOA) envoyés aux titulaires. En tant qu'agent désigné, vous pouvez accepter ou rejeter ce changement en leur nom. Seuls les formulaires encore sans réponse seront validés.", + "domain_operations_foa_certification": "Je certifie que je suis dûment autorisé(e) à agir en tant qu'agent désigné et à répondre au nom du titulaire actuel et du nouveau titulaire.", + "domain_operations_foa_accept": "J'accepte ce changement de titulaire pour {{t0}}", + "domain_operations_foa_reject": "Je refuse ce changement de titulaire pour {{t0}}", + "domain_operations_foa_accept_success": "Le changement de titulaire a été accepté en tant qu'agent désigné.", + "domain_operations_foa_reject_success": "Le changement de titulaire a été rejeté en tant qu'agent désigné.", + "domain_operations_foa_error": "Échec de la validation en tant qu'agent désigné" } diff --git a/packages/manager/apps/web-ongoing-operations/src/components/Dashboard/DashboardPage.spec.tsx b/packages/manager/apps/web-ongoing-operations/src/components/Dashboard/DashboardPage.spec.tsx index ea0aee083961..f9ae98aa0e1b 100644 --- a/packages/manager/apps/web-ongoing-operations/src/components/Dashboard/DashboardPage.spec.tsx +++ b/packages/manager/apps/web-ongoing-operations/src/components/Dashboard/DashboardPage.spec.tsx @@ -19,6 +19,12 @@ vi.mock('react-router-dom', () => ({ vi.mock('@/hooks/data/query', () => ({ useGetDomainInformation: vi.fn(), + usePendingFoas: vi.fn(() => ({ + taskId: null, + foas: [], + pendingFoas: [], + isLoading: false, + })), })); describe('Datagrid template', () => { diff --git a/packages/manager/apps/web-ongoing-operations/src/components/OngoingOperationDatagrid/OngoingOperationDatagridActions.spec.tsx b/packages/manager/apps/web-ongoing-operations/src/components/OngoingOperationDatagrid/OngoingOperationDatagridActions.spec.tsx new file mode 100644 index 000000000000..a952d67e99bd --- /dev/null +++ b/packages/manager/apps/web-ongoing-operations/src/components/OngoingOperationDatagrid/OngoingOperationDatagridActions.spec.tsx @@ -0,0 +1,222 @@ +import { navigateMock } from '@/setupTests'; +import React from 'react'; +import { Mock, describe, it, expect, vi, beforeEach } from 'vitest'; +import { fireEvent, render } from '@testing-library/react'; +import { useLocation } from 'react-router-dom'; +import OngoingOperationDatagridActions from '@/components/OngoingOperationDatagrid/OngoingOperationDatagridActions'; +import { usePendingFoas, useGetDomainInformation } from '@/hooks/data/query'; +import { AlldomOperationsEnum, DNSOperationsEnum } from '@/constants'; +import { FoaChoiceEnum } from '@/enum/foa.enum'; +import { StatusEnum } from '@/enum/status.enum'; +import { wrapper } from '@/utils/test.provider'; +import { TFoa, TOngoingOperations } from '@/types'; + +const tradeTaskId = 'f0a1c2d3-0000-4a1b-9b7e-000000000001'; + +const tradeOperation: TOngoingOperations = { + id: 42, + domain: 'change-of-registrant.ovh', + status: 'todo', + function: 'DomainTrade', + todoDate: '2026-08-10T09:12:00+02:00', + creationDate: '2026-08-10T09:12:00+02:00', + lastUpdate: '2026-08-12T14:40:00+02:00', + canCancel: false, + canRelaunch: false, + canAccelerate: false, +}; + +/** The two FOAs of a trade, the second one already answered by its holder */ +const foas: TFoa[] = [ + { id: 'foa-current-holder', currentState: {} }, + { id: 'foa-new-holder', currentState: { choice: FoaChoiceEnum.Accept } }, +]; + +vi.mock('@/hooks/data/query', () => ({ + usePendingFoas: vi.fn(), + useGetDomainInformation: vi.fn(), +})); + +// setupTests mocks useNichandle to 'ca0000-ovh' : same handle = admin contact +const adminHandle = 'ca0000-ovh'; +const mockServiceInfo = (contactAdminId: string | null = adminHandle) => { + (useGetDomainInformation as Mock).mockReturnValue({ + data: contactAdminId ? { contactAdmin: { id: contactAdminId } } : undefined, + }); +}; + +const mockPendingFoas = ({ + taskId = tradeTaskId, + pendingFoas = foas.slice(0, 1), + isDesignatedAgentAllowed = true, +}: { + taskId?: string | null; + pendingFoas?: TFoa[]; + isDesignatedAgentAllowed?: boolean; +} = {}) => { + (usePendingFoas as Mock).mockReturnValue({ + taskId, + foas, + pendingFoas, + isDesignatedAgentAllowed, + isLoading: false, + }); +}; + +const renderActions = (operation: TOngoingOperations = tradeOperation) => { + const { container } = render( + , + { wrapper }, + ); + // the menu items are ods-button custom elements, only labelled by attribute + return { + trigger: container.querySelector( + '[data-testid="navigation-action-trigger-action"]', + ), + foaItem: container.querySelector( + 'ods-button[label="domain_operations_foa_cta"]', + ), + }; +}; + +describe('OngoingOperationDatagridActions', () => { + beforeEach(() => { + vi.clearAllMocks(); + mockServiceInfo(); + }); + + it('offers the designated agent validation on a trade with a pending foa', () => { + mockPendingFoas(); + const { trigger, foaItem } = renderActions(); + + expect(foaItem?.className).not.toContain('hidden'); + expect(trigger).toHaveAttribute('is-disabled', 'false'); + }); + + it('navigates to the certification page of the operation', () => { + // The url is appended to the current pathname, the listing of a product + // section, matching the ':product/foa/:id' route pattern + (useLocation as Mock).mockReturnValue({ pathname: '/domain', search: '' }); + mockPendingFoas(); + const { foaItem } = renderActions(); + + fireEvent.click(foaItem as Element); + + expect(navigateMock).toHaveBeenCalledWith( + `/domain/foa/${tradeOperation.id}`, + ); + }); + + it('hides the entry point when every foa has already been answered', () => { + mockPendingFoas({ pendingFoas: [] }); + const { trigger, foaItem } = renderActions(); + + expect(foaItem?.className).toContain('hidden'); + expect(trigger).toHaveAttribute('is-disabled', 'true'); + }); + + it('hides the entry point when the task carries no foa', () => { + mockPendingFoas({ taskId: null, pendingFoas: [] }); + const { foaItem } = renderActions(); + + expect(foaItem?.className).toContain('hidden'); + }); + + // A trade may be listed long after it ended : nothing can be validated on + // it anymore, whatever the APIv2 task list still returns for the domain. + it('hides the entry point on a trade that is over', () => { + mockPendingFoas(); + [StatusEnum.DONE, StatusEnum.CANCELLED].forEach((status) => { + const { foaItem } = renderActions({ ...tradeOperation, status }); + + expect(foaItem?.className).toContain('hidden'); + expect(usePendingFoas).toHaveBeenCalledWith(tradeOperation.domain, false); + }); + }); + + it('still offers the validation on a trade in error or in problem', () => { + mockPendingFoas(); + [StatusEnum.DOING, StatusEnum.ERROR, StatusEnum.PROBLEM].forEach( + (status) => { + const { foaItem } = renderActions({ ...tradeOperation, status }); + + expect(foaItem?.className).not.toContain('hidden'); + }, + ); + }); + + it('keeps the menu usable on a trade whose other actions are available', () => { + mockPendingFoas({ pendingFoas: [] }); + const { trigger } = renderActions({ + ...tradeOperation, + canCancel: true, + }); + + expect(trigger).toHaveAttribute('is-disabled', 'false'); + }); + + it('does not look for foas on an operation that is not a trade', () => { + mockPendingFoas({ taskId: null, pendingFoas: [] }); + const { foaItem } = renderActions({ + ...tradeOperation, + function: 'DomainDnsUpdate', + }); + + expect(usePendingFoas).toHaveBeenCalledWith(tradeOperation.domain, false); + expect(foaItem?.className).toContain('hidden'); + }); + + // The designated agent only exists for domain operations. The alldom and + // dns sections are fed by disjoint datasets (/me/task/domain?type=alldom + // and /me/task/dns) whose functions can never be DomainTrade, so the + // DomainTrade gate above is the structural guarantee — these two cases lock + // it against a function ever leaking from those sections. + it('never offers the validation on an alldom operation', () => { + mockPendingFoas({ taskId: null, pendingFoas: [] }); + const { foaItem } = renderActions({ + ...tradeOperation, + domain: 'alldom-pack', + function: AlldomOperationsEnum.AlldomDelete, + }); + + expect(usePendingFoas).toHaveBeenCalledWith('alldom-pack', false); + expect(foaItem?.className).toContain('hidden'); + }); + + it('never offers the validation on a dns zone operation', () => { + mockPendingFoas({ taskId: null, pendingFoas: [] }); + const { foaItem } = renderActions({ + ...tradeOperation, + domain: undefined, + zone: 'zone-of-registrant.ovh', + function: DNSOperationsEnum.ZoneCreate, + }); + + // a dns row carries a zone, not a domain : the lookup is doubly disabled + expect(usePendingFoas).toHaveBeenCalledWith('', false); + expect(foaItem?.className).toContain('hidden'); + }); + + it('hides the entry point when the registry forbids the designated agent', () => { + mockPendingFoas({ isDesignatedAgentAllowed: false }); + const { foaItem } = renderActions(); + + expect(foaItem?.className).toContain('hidden'); + }); + + it('does not offer the validation to a contact who is not the domain admin', () => { + mockPendingFoas(); + mockServiceInfo('other-contact-ovh'); + const { foaItem } = renderActions(); + + expect(foaItem?.className).toContain('hidden'); + }); + + it('falls open when the service info is unknown, the api stays the real gate', () => { + mockPendingFoas(); + mockServiceInfo(null); + const { foaItem } = renderActions(); + + expect(foaItem?.className).not.toContain('hidden'); + }); +}); diff --git a/packages/manager/apps/web-ongoing-operations/src/components/OngoingOperationDatagrid/OngoingOperationDatagridActions.tsx b/packages/manager/apps/web-ongoing-operations/src/components/OngoingOperationDatagrid/OngoingOperationDatagridActions.tsx new file mode 100644 index 000000000000..963cda1714a9 --- /dev/null +++ b/packages/manager/apps/web-ongoing-operations/src/components/OngoingOperationDatagrid/OngoingOperationDatagridActions.tsx @@ -0,0 +1,105 @@ +import React from 'react'; +import clsx from 'clsx'; +import { useTranslation } from 'react-i18next'; +import { ActionMenu, useNotifications } from '@ovh-ux/manager-react-components'; +import { useLocation, useNavigate } from 'react-router-dom'; +import { ODS_BUTTON_VARIANT } from '@ovhcloud/ods-components'; +import { OngoingOperationDatagridActionsProps } from '@/types'; +import { DomainOperationsEnum } from '@/constants'; +import { usePendingFoas, useGetDomainInformation } from '@/hooks/data/query'; +import { isFoaEligibleOperation } from '@/utils/foa.utils'; +import { useNichandle } from '@/hooks/nichandle/useNichandle'; +import { useTrackNavigation } from '@/hooks/tracking/useTrackDatagridNavivationLink'; + +export default function OngoingOperationDatagridActions({ + props, +}: Readonly) { + const { t } = useTranslation('dashboard'); + const { trackPageNavivationTile } = useTrackNavigation(); + const { clearNotifications } = useNotifications(); + const navigate = useNavigate(); + const location = useLocation(); + + // A trade still running, and nothing else : a done or cancelled operation + // is over, and its row must not offer the validation anymore. Also what + // keeps the designated agent out of the alldom and dns sections : their + // datasets (/me/task/domain?type=alldom, /me/task/dns) can never carry a + // DomainTrade function, only domain operations can. + const isOngoingTrade = isFoaEligibleOperation(props); + // Per row lookup, on purpose : the entry point must be hidden when every FOA + // is already answered, and that verdict only exists once the FOAs are known. + // Bounded by the DomainTrade gate below and by the 10 rows the datagrid + // loads, then cached by the app wide staleTime, so a re-render or a round + // trip through the pages costs nothing. Deferring to the menu opening is not + // possible : the shared ActionMenu exposes no open callback, and the item + // visibility depends on the very data we would be waiting for. + const { taskId, pendingFoas, isDesignatedAgentAllowed } = usePendingFoas( + props.domain ?? '', + isOngoingTrade, + ); + + // Answering a FOA engages both holders, so only the admin contact of the + // domain is offered the action. Already fetched by the domain column, so this + // is a cache hit. Fails open while the identity or the service info is + // unknown : the real authorization is API side, this only avoids proposing + // an action the user is not entitled to. + const { nichandle } = useNichandle(); + const { data: serviceInfo } = useGetDomainInformation(props.domain ?? ''); + const isAdminContact = + !nichandle || !serviceInfo || nichandle === serviceInfo.contactAdmin.id; + + const canUpdate = props.canAccelerate || props.canRelaunch || props.canCancel; + const canValidateFoa = + isOngoingTrade && + !!taskId && + pendingFoas.length > 0 && + isAdminContact && + isDesignatedAgentAllowed; + + return ( + { + const url = `${location.pathname}/update/${props.id}`; + trackPageNavivationTile(url); + navigate(url); + clearNotifications(); + }, + }, + { + id: 2, + label: t('domain_operations_tab_popover_progress'), + className: clsx( + props.function !== DomainOperationsEnum.DomainIncomingTransfer && + 'hidden', + 'menu-item-button', + ), + onClick: () => { + const url = `/tracking/${props.id}`; + trackPageNavivationTile(url); + navigate(url); + }, + }, + { + id: 3, + label: t('domain_operations_foa_cta'), + className: clsx(!canValidateFoa && 'hidden', 'menu-item-button'), + onClick: () => { + const url = `${location.pathname}/foa/${props.id}`; + trackPageNavivationTile(url); + navigate(url); + clearNotifications(); + }, + }, + ]} + /> + ); +} diff --git a/packages/manager/apps/web-ongoing-operations/src/components/Update/Content/Update.Foa.component.spec.tsx b/packages/manager/apps/web-ongoing-operations/src/components/Update/Content/Update.Foa.component.spec.tsx new file mode 100644 index 000000000000..4acbf8d1a104 --- /dev/null +++ b/packages/manager/apps/web-ongoing-operations/src/components/Update/Content/Update.Foa.component.spec.tsx @@ -0,0 +1,327 @@ +import { navigateMock } from '@/setupTests'; +import React from 'react'; +import { Mock, describe, it, expect, vi, beforeEach } from 'vitest'; +import { fireEvent, render, screen, waitFor } from '@testing-library/react'; +import { NAMESPACES } from '@ovh-ux/manager-common-translations'; +import UpdateFoaComponent from '@/components/Update/Content/Update.Foa.component'; +import { + useDomain, + useGetDomainInformation, + usePendingFoas, +} from '@/hooks/data/query'; +import { validateFoa } from '@/data/api/foa'; +import { AlldomOperationsEnum } from '@/constants'; +import { StatusEnum } from '@/enum/status.enum'; +import { FoaChoiceEnum } from '@/enum/foa.enum'; +import { isPendingFoa } from '@/utils/foa.utils'; +import { wrapper } from '@/utils/test.provider'; +import { domain } from '@/__mocks__/domain'; +import { TFoa, TOngoingOperations } from '@/types'; + +const domainName = 'change-of-registrant.ovh'; +const taskId = 'f0a1c2d3-0000-4a1b-9b7e-000000000001'; + +const tradeOperation: TOngoingOperations = { + id: 42, + domain: domainName, + status: 'todo', + function: 'DomainTrade', + todoDate: '2026-08-10T09:12:00+02:00', + creationDate: '2026-08-10T09:12:00+02:00', + lastUpdate: '2026-08-12T14:40:00+02:00', + canCancel: false, + canRelaunch: false, + canAccelerate: false, +}; + +/** The two FOAs of a trade, the second one already answered by its holder */ +const answeredFoas: TFoa[] = [ + { id: 'foa-current-holder', currentState: {} }, + { id: 'foa-new-holder', currentState: { choice: FoaChoiceEnum.Accept } }, +]; + +const pendingFoas: TFoa[] = [ + { id: 'foa-current-holder', currentState: {} }, + { id: 'foa-new-holder' }, +]; + +vi.mock('@/hooks/data/query', () => ({ + useDomain: vi.fn(), + usePendingFoas: vi.fn(), + useGetDomainInformation: vi.fn(), +})); + +// setupTests mocks useNichandle to 'ca0000-ovh' : same handle = admin contact +const adminHandle = 'ca0000-ovh'; +const mockServiceInfo = (contactAdminId: string | null = adminHandle) => { + (useGetDomainInformation as Mock).mockReturnValue({ + data: contactAdminId ? { contactAdmin: { id: contactAdminId } } : undefined, + }); +}; + +vi.mock('@/data/api/foa', () => ({ + validateFoa: vi.fn(), +})); + +const mockQueries = ({ + operation = tradeOperation, + foas = answeredFoas, + isDesignatedAgentAllowed = true, +}: { + operation?: unknown; + foas?: TFoa[]; + isDesignatedAgentAllowed?: boolean; +} = {}) => { + (useDomain as Mock).mockReturnValue({ data: operation, isLoading: false }); + (usePendingFoas as Mock).mockReturnValue({ + taskId: taskId, + foas, + pendingFoas: foas.filter(isPendingFoa), + isDesignatedAgentAllowed, + isLoading: false, + }); +}; + +const getCheckbox = () => screen.getByRole('checkbox'); +const getRadio = (name: string) => screen.getByRole('radio', { name }); +const getButton = (name: string) => + screen.getByRole('button', { name }) as HTMLButtonElement; +const confirmLabel = `${NAMESPACES.ACTIONS}:confirm`; +const acceptLabel = 'domain_operations_foa_accept'; +const rejectLabel = 'domain_operations_foa_reject'; + +/** Both gates of the page : an explicit outcome and the certification. */ +const certifyAndPick = async (outcome: string) => { + fireEvent.click(getRadio(outcome)); + fireEvent.click(getCheckbox()); + await waitFor(() => { + expect(getButton(confirmLabel).disabled).toBe(false); + }); +}; + +describe('UpdateFoaComponent', () => { + beforeEach(() => { + vi.clearAllMocks(); + mockServiceInfo(); + (validateFoa as Mock).mockResolvedValue(undefined); + }); + + it('preselects no outcome, an irreversible answer is never a default', async () => { + mockQueries(); + const { container } = render(, { wrapper }); + + expect(screen.getByText('domain_operations_foa_title')).toBeInTheDocument(); + expect( + screen.getByText('domain_operations_foa_certification'), + ).toBeInTheDocument(); + // an irreversible answer is never a default + expect(getRadio(acceptLabel)).not.toBeChecked(); + expect(getRadio(rejectLabel)).not.toBeChecked(); + + await expect(container).toBeAccessible({ + rules: { + 'heading-order': { enabled: false }, + }, + }); + }); + + it('confirms only once an outcome is picked and the certification ticked', async () => { + mockQueries(); + render(, { wrapper }); + + expect(getButton(confirmLabel).disabled).toBe(true); + expect(getButton(`${NAMESPACES.ACTIONS}:cancel`).disabled).toBe(false); + + // the certification alone is not enough + fireEvent.click(getCheckbox()); + await waitFor(() => { + expect(getCheckbox()).toBeChecked(); + }); + expect(getButton(confirmLabel).disabled).toBe(true); + + fireEvent.click(getRadio(acceptLabel)); + + await waitFor(() => { + expect(getButton(confirmLabel).disabled).toBe(false); + }); + }); + + it('leaves the confirmation locked when only an outcome is picked', async () => { + mockQueries(); + render(, { wrapper }); + + fireEvent.click(getRadio(rejectLabel)); + + await waitFor(() => { + expect(getRadio(rejectLabel)).toBeChecked(); + }); + expect(getButton(confirmLabel).disabled).toBe(true); + }); + + it('lays the actions out with the primary one last', () => { + mockQueries(); + render(, { wrapper }); + + const order = screen + .getAllByRole('button') + .map((button) => button.getAttribute('name')) + .filter((name): name is string => name !== null); + + expect(order).toEqual(['cancel', 'confirm']); + }); + + it('validates every still pending foa of the task on accept', async () => { + mockQueries({ foas: pendingFoas }); + render(, { wrapper }); + + await certifyAndPick(acceptLabel); + fireEvent.click(getButton(confirmLabel)); + + await waitFor(() => { + expect(validateFoa).toHaveBeenCalledTimes(2); + }); + expect(validateFoa).toHaveBeenNthCalledWith( + 1, + domainName, + taskId, + 'foa-current-holder', + FoaChoiceEnum.Accept, + ); + expect(validateFoa).toHaveBeenNthCalledWith( + 2, + domainName, + taskId, + 'foa-new-holder', + FoaChoiceEnum.Accept, + ); + await waitFor(() => { + expect(navigateMock).toHaveBeenCalledWith('/domain'); + }); + }); + + it('skips the foas already answered and rejects with the reject choice', async () => { + mockQueries(); + render(, { wrapper }); + + await certifyAndPick(rejectLabel); + fireEvent.click(getButton(confirmLabel)); + + await waitFor(() => { + expect(validateFoa).toHaveBeenCalledTimes(1); + }); + expect(validateFoa).toHaveBeenCalledWith( + domainName, + taskId, + 'foa-current-holder', + FoaChoiceEnum.Reject, + ); + }); + + it('treats an already finalized foa as an idempotent success', async () => { + mockQueries(); + (validateFoa as Mock).mockRejectedValue({ response: { status: 409 } }); + render(, { wrapper }); + + await certifyAndPick(acceptLabel); + fireEvent.click(getButton(confirmLabel)); + + await waitFor(() => { + expect(navigateMock).toHaveBeenCalledWith('/domain'); + }); + }); + + it('keeps the user on the page when the validation fails', async () => { + mockQueries(); + (validateFoa as Mock).mockRejectedValue({ response: { status: 400 } }); + render(, { wrapper }); + + await certifyAndPick(acceptLabel); + fireEvent.click(getButton(confirmLabel)); + + await waitFor(() => { + expect( + screen.getByText('domain_operations_foa_error'), + ).toBeInTheDocument(); + }); + expect(navigateMock).not.toHaveBeenCalled(); + }); + + it('is not reachable when the operation is not a change of registrant', () => { + mockQueries({ operation: domain[0] }); + render(, { wrapper }); + + expect(screen.getByText('404 - route not found')).toBeInTheDocument(); + }); + + // The designated agent only exists for domain operations : a crafted + // /alldom/foa/:id or /dns/foa/:id url must dead-end. An alldom task resolves + // on /me/task/domain/{id} but is not a trade ; a dns task id 404s there, so + // the operation stays undefined. + it('is not reachable for an alldom operation', () => { + mockQueries({ + operation: { ...tradeOperation, function: AlldomOperationsEnum.AlldomDelete }, + foas: pendingFoas, + }); + render(, { wrapper }); + + expect(screen.getByText('404 - route not found')).toBeInTheDocument(); + }); + + it('is not reachable for a dns task id, unknown to /me/task/domain', () => { + // null, not undefined : undefined would fall back to the default operation + mockQueries({ operation: null, foas: pendingFoas }); + render(, { wrapper }); + + expect(screen.getByText('404 - route not found')).toBeInTheDocument(); + }); + + // The certification page is a plain route : a bookmark, a back navigation + // or a hand written url must not reopen the validation of a trade that is + // already over. + it('is not reachable once the operation is over', () => { + [StatusEnum.DONE, StatusEnum.CANCELLED].forEach((status) => { + mockQueries({ + operation: { ...tradeOperation, status }, + foas: pendingFoas, + }); + const { unmount } = render(, { wrapper }); + + expect(screen.getByText('404 - route not found')).toBeInTheDocument(); + expect(usePendingFoas).toHaveBeenCalledWith(domainName, false); + unmount(); + }); + }); + + it('is not reachable when the registry forbids the designated agent', () => { + mockQueries({ isDesignatedAgentAllowed: false }); + render(, { wrapper }); + + expect(screen.getByText('404 - route not found')).toBeInTheDocument(); + }); + + it('is not reachable when every foa has already been answered', () => { + mockQueries({ + foas: [{ id: 'foa-1', currentState: { choice: FoaChoiceEnum.Accept } }], + }); + render(, { wrapper }); + + expect(screen.getByText('404 - route not found')).toBeInTheDocument(); + }); + + it('locks the certification and the outcomes for a non admin contact', () => { + mockQueries(); + mockServiceInfo('other-contact-ovh'); + render(, { wrapper }); + + expect(screen.getByTestId('foa-not-admin')).toBeInTheDocument(); + expect(screen.queryByRole('checkbox')).not.toBeInTheDocument(); + expect(screen.queryAllByRole('radio')).toHaveLength(0); + expect( + screen.queryByRole('button', { name: confirmLabel }), + ).not.toBeInTheDocument(); + // leaving the screen must stay possible + expect( + screen.getByRole('button', { name: `${NAMESPACES.ACTIONS}:cancel` }), + ).toBeInTheDocument(); + }); +}); diff --git a/packages/manager/apps/web-ongoing-operations/src/components/Update/Content/Update.Foa.component.tsx b/packages/manager/apps/web-ongoing-operations/src/components/Update/Content/Update.Foa.component.tsx new file mode 100644 index 000000000000..a7eb17e6ebe0 --- /dev/null +++ b/packages/manager/apps/web-ongoing-operations/src/components/Update/Content/Update.Foa.component.tsx @@ -0,0 +1,293 @@ +import { + BaseLayout, + Notifications, + useNotifications, +} from '@ovh-ux/manager-react-components'; +import React, { useState } from 'react'; +import { useTranslation } from 'react-i18next'; +import { useNavigate, useParams } from 'react-router-dom'; +import { useMutation, useQueryClient } from '@tanstack/react-query'; +import pLimit from 'p-limit'; +import { toUnicode } from 'punycode'; +import { + Button, + BUTTON_VARIANT, + Checkbox, + CheckboxCheckedChangeDetail, + CheckboxControl, + CheckboxLabel, + Radio, + RadioControl, + RadioGroup, + RadioLabel, + RadioValueChangeDetail, + Text, + TEXT_PRESET, +} from '@ovhcloud/ods-react'; +import { NAMESPACES } from '@ovh-ux/manager-common-translations'; +import { ApiError } from '@ovh-ux/manager-core-api'; +import SubHeader from '@/components/SubHeader/SubHeader'; +import Loading from '@/components/Loading/Loading'; +import { validateFoa } from '@/data/api/foa'; +import { FoaChoiceEnum } from '@/enum/foa.enum'; +import { + useDomain, + useGetDomainInformation, + usePendingFoas, +} from '@/hooks/data/query'; +import { useNichandle } from '@/hooks/nichandle/useNichandle'; +import { useTrackNavigation } from '@/hooks/tracking/useTrackDatagridNavivationLink'; +import { isFoaEligibleOperation, isPendingFoa } from '@/utils/foa.utils'; +import { urls } from '@/routes/routes.constant'; +import NotFound from '@/pages/404'; + +/** + * Designated agent validation of a change of registrant. Answering engages + * both holders and cannot be undone, so the outcome is an explicit choice — + * accept or reject, nothing preselected — and the certification checkbox + * remains the sole authorization gate : confirming stays disabled until both + * are set, then every FOA of the task still awaiting an answer is validated + * with the chosen choice. + */ +export default function UpdateFoaComponent() { + const { t } = useTranslation(['dashboard', NAMESPACES.ACTIONS]); + const { id, product } = useParams<{ id: string; product: string }>(); + const paramId = Number(id); + const navigate = useNavigate(); + const queryClient = useQueryClient(); + const { trackPageNavivationButton } = useTrackNavigation(); + const { + notifications, + addError, + addSuccess, + clearNotifications, + } = useNotifications(); + // Nothing preselected : an irreversible answer is never a default + const [choice, setChoice] = useState(null); + const [isCertified, setIsCertified] = useState(false); + + const { data: operation, isLoading: operationLoading } = useDomain(paramId); + const domainName = operation?.domain ?? ''; + // A crafted url must dead-end on anything but a trade still running : a + // finished or cancelled operation can no longer be answered + const isOngoingTrade = isFoaEligibleOperation(operation); + const { + taskId, + foas, + pendingFoas, + isDesignatedAgentAllowed, + isLoading: foasLoading, + } = usePendingFoas(domainName, isOngoingTrade); + + // Answering a FOA engages both holders, so only the admin contact of the + // domain may do it. Fails open while the identity or the service info is + // unknown : the real authorization is API side, this guard only avoids + // offering an action the user is not entitled to. + const { nichandle } = useNichandle(); + const { data: serviceInfo } = useGetDomainInformation(domainName); + const isAdminContact = + !nichandle || !serviceInfo || nichandle === serviceInfo.contactAdmin.id; + + const backToListing = () => { + const url = `${urls.root}${product ?? ''}`; + trackPageNavivationButton(url); + navigate(url); + }; + + const { mutate: validateFoas, isPending } = useMutation({ + mutationFn: async (choice: FoaChoiceEnum) => { + clearNotifications(); + const validateLimit = pLimit(1); + // Answered FOAs are re-checked here so a holder answer landed in the + // meantime is never overwritten by the designated agent + const results = await Promise.allSettled( + foas + .filter(isPendingFoa) + .map((foa) => + validateLimit(() => + validateFoa(domainName, taskId ?? '', foa.id, choice), + ), + ), + ); + // A 409 means the FOA has just been finalized : idempotent no-op + const [failure] = results.filter( + (result): result is PromiseRejectedResult => + result.status === 'rejected' && + (result.reason as ApiError)?.response?.status !== 409, + ); + if (failure) { + throw failure.reason; + } + }, + onSuccess: async (_data, choice) => { + await queryClient.invalidateQueries({ + queryKey: ['me', 'task'], + }); + clearNotifications(); + addSuccess( + + {t( + choice === FoaChoiceEnum.Accept + ? 'domain_operations_foa_accept_success' + : 'domain_operations_foa_reject_success', + )} + , + ); + backToListing(); + // Not awaited : the answered FOAs are refreshed for the listing once + // this page is left, so the entry point disappears from the row + queryClient.invalidateQueries({ + queryKey: ['foa'], + }); + }, + onError: async () => { + // A 400 or a 404 both mean the local FOA data cannot be trusted anymore + await queryClient.invalidateQueries({ + queryKey: ['foa'], + }); + addError({t('domain_operations_foa_error')}); + }, + }); + + if (operationLoading || foasLoading) { + return ; + } + + // Operation over or not a trade, no scheduled trade task, no FOA (404), + // every FOA already answered or the registry forbids the designated agent + // procedure on the domain : there is nothing a designated agent can + // validate here + if ( + !operation || + !isOngoingTrade || + !taskId || + pendingFoas.length === 0 || + !isDesignatedAgentAllowed + ) { + return ; + } + + return ( + : undefined} + > + +
+ {/* Bounded measure : left to the full width of a large screen, the + paragraph runs edge to edge and becomes hard to read */} + + {t('domain_operations_foa_description')} + + + {!isAdminContact && ( + + {t('domain_operations_update_contact_administrator')} + + )} + + {isAdminContact && ( + <> + { + if (detail?.value) { + setChoice(detail.value as FoaChoiceEnum); + } + }} + > + + + + {/* ODS sets no colour on its labels, so they would fall + back to the browser black while every Text of the page + follows the theme. The span preset carries the same + style as paragraph, minus the

tag a label cannot + contain. */} + + {t('domain_operations_foa_accept', { + t0: toUnicode(domainName), + })} + + + + + + + + {t('domain_operations_foa_reject', { + t0: toUnicode(domainName), + })} + + + + + + {/* One line whenever the viewport allows it : the certification + is a single sentence, and it reads better in one go than the + paragraph above, which keeps a bounded measure. `max-w-full` + is still needed — ODS gives the wrapper a + `width: max-content`, which would overflow a narrow screen + instead of wrapping. `items-start` undoes the + `align-items: center` of ODS, which floats the box between the + lines once the sentence does wrap (de_DE is the longest, 158 + characters). */} + + setIsCertified(detail.checked === true) + } + > + + + + {t('domain_operations_foa_certification')} + + + + + )} + +

+ + {isAdminContact && ( + + )} +
+
+
+ ); +} diff --git a/packages/manager/apps/web-ongoing-operations/src/constants.ts b/packages/manager/apps/web-ongoing-operations/src/constants.ts index 3faf9668bf88..2e0484a1fc86 100644 --- a/packages/manager/apps/web-ongoing-operations/src/constants.ts +++ b/packages/manager/apps/web-ongoing-operations/src/constants.ts @@ -1,4 +1,5 @@ import { z } from 'zod'; +import { StatusEnum } from '@/enum/status.enum'; export const taskMeDomain = ['me', 'task', 'domain']; export const taskMeDns = ['me', 'task', 'dns']; @@ -123,6 +124,21 @@ export const editableArgument: Record = { default: z.string(), }; +/** APIv2 status of a trade task that may still carry answerable FOAs. */ +export const foaScheduledTaskStatus = 'SCHEDULED'; + +/** + * APIv6 statuses of an operation whose FOAs may still be answered. A done + * or cancelled trade is over : whatever the APIv2 task list still returns, + * nothing can be validated on it anymore. + */ +export const foaEligibleOperationStatuses: readonly string[] = [ + StatusEnum.TODO, + StatusEnum.DOING, + StatusEnum.ERROR, + StatusEnum.PROBLEM, +]; + export const iamGetAllDomAction = 'domain:apiovh:alldom/get'; export const allDomFeatureAvailibility = 'web-domains:alldoms'; export const domainFeatureAvailibility = 'web-domains:domain'; diff --git a/packages/manager/apps/web-ongoing-operations/src/data/api/foa.spec.ts b/packages/manager/apps/web-ongoing-operations/src/data/api/foa.spec.ts new file mode 100644 index 000000000000..046f8479c45e --- /dev/null +++ b/packages/manager/apps/web-ongoing-operations/src/data/api/foa.spec.ts @@ -0,0 +1,103 @@ +import '@/setupTests'; +import { Mock, describe, it, expect, vi, beforeEach } from 'vitest'; +import { v2 } from '@ovh-ux/manager-core-api'; +import { + getDomainResource, + getScheduledTradeTasks, + getTaskFoas, + validateFoa, +} from '@/data/api/foa'; +import { FoaChoiceEnum } from '@/enum/foa.enum'; +import { TDomainTaskV2, TFoa } from '@/types'; + +const domainName = 'change-of-registrant.ovh'; +/** Every FOA read asks the api for a fresh object, never a cached list. */ +const noCache = { headers: { Pragma: 'no-cache' } }; +const taskId = 'f0a1c2d3-0000-4a1b-9b7e-000000000001'; + +const scheduledTasks: TDomainTaskV2[] = [ + { + id: taskId, + type: 'DomainTrade', + status: 'SCHEDULED', + createdAt: '2026-08-10T09:12:00+02:00', + }, +]; + +const foas: TFoa[] = [ + { id: 'foa-current-holder', currentState: {} }, + { id: 'foa-new-holder', currentState: { choice: FoaChoiceEnum.Accept } }, +]; + +describe('foa api', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('reads the domain resource carrying the designated agent verdict', async () => { + (v2.get as Mock).mockResolvedValue({ + data: { currentState: { designatedAgentAllowed: false } }, + }); + + await expect(getDomainResource(domainName)).resolves.toEqual({ + currentState: { designatedAgentAllowed: false }, + }); + expect(v2.get).toHaveBeenCalledWith( + `domain/name/${domainName}`, + noCache, + ); + }); + + it('filters the task listing on the scheduled trades', async () => { + (v2.get as Mock).mockResolvedValue({ data: scheduledTasks }); + + await expect(getScheduledTradeTasks(domainName)).resolves.toEqual( + scheduledTasks, + ); + expect(v2.get).toHaveBeenCalledWith( + `domain/name/${domainName}/task?type=DomainTrade&status=SCHEDULED`, + noCache, + ); + }); + + it('returns the foa list of a task', async () => { + (v2.get as Mock).mockResolvedValue({ data: foas }); + + await expect(getTaskFoas(domainName, taskId)).resolves.toEqual( + foas, + ); + expect(v2.get).toHaveBeenCalledWith( + `domain/name/${domainName}/task/${taskId}/foa`, + noCache, + ); + }); + + it('maps a 404 on the foa listing to an empty list', async () => { + (v2.get as Mock).mockRejectedValue({ response: { status: 404 } }); + + await expect(getTaskFoas(domainName, taskId)).resolves.toEqual([]); + }); + + it('rethrows any other error of the foa listing', async () => { + (v2.get as Mock).mockRejectedValue({ response: { status: 500 } }); + + await expect(getTaskFoas(domainName, taskId)).rejects.toMatchObject({ + response: { status: 500 }, + }); + }); + + it('posts the choice on the validate call', async () => { + (v2.post as Mock).mockResolvedValue({ data: undefined }); + + await validateFoa( + domainName, + taskId, + 'foa-current-holder', + FoaChoiceEnum.Reject, + ); + expect(v2.post).toHaveBeenCalledWith( + `domain/name/${domainName}/task/${taskId}/foa/foa-current-holder/validate`, + { choice: FoaChoiceEnum.Reject }, + ); + }); +}); diff --git a/packages/manager/apps/web-ongoing-operations/src/data/api/foa.ts b/packages/manager/apps/web-ongoing-operations/src/data/api/foa.ts new file mode 100644 index 000000000000..cafa623e4d74 --- /dev/null +++ b/packages/manager/apps/web-ongoing-operations/src/data/api/foa.ts @@ -0,0 +1,85 @@ +import { ApiError, v2 } from '@ovh-ux/manager-core-api'; +import { DomainOperationsEnum, foaScheduledTaskStatus } from '@/constants'; +import { FoaChoiceEnum } from '@/enum/foa.enum'; +import { TDomainResource, TDomainTaskV2, TFoa } from '@/types'; + +const getTaskPath = (domainName: string) => + `domain/name/${encodeURIComponent(domainName)}/task`; + +/** + * The FOA reads all carry a live verdict — the registry policy on the + * domain, the state of the trade task, the answers of the holders. None of + * them may be served from a cached object list, so every GET asks for a + * fresh one, as the datagrids do through their disableCache option. + */ +const noCacheHeaders = { headers: { Pragma: 'no-cache' } }; + +/** + * Get the APIv2 domain resource : its currentState carries the + * designatedAgentAllowed verdict of the registry + */ +export const getDomainResource = async ( + domainName: string, +): Promise => { + const { data } = await v2.get( + `domain/name/${encodeURIComponent(domainName)}`, + noCacheHeaders, + ); + return data ?? null; +}; + +/** + * Get the scheduled change of registrant tasks of a domain : the FOAs a + * designated agent may answer are carried by those tasks only + */ +export const getScheduledTradeTasks = async ( + domainName: string, +): Promise => { + const { data } = await v2.get( + `${getTaskPath(domainName)}?type=${ + DomainOperationsEnum.DomainTrade + }&status=${foaScheduledTaskStatus}`, + noCacheHeaders, + ); + // the v2 client hands back an untyped payload : normalise at the boundary + return Array.isArray(data) ? data : []; +}; + +/** + * Get the FOAs of a task : a 404 means the task carries no FOA, an expected + * case surfaced as an empty list rather than as an error + */ +export const getTaskFoas = async ( + domainName: string, + taskId: string, +): Promise => { + try { + const { data } = await v2.get( + `${getTaskPath(domainName)}/${encodeURIComponent(taskId)}/foa`, + noCacheHeaders, + ); + return Array.isArray(data) ? data : []; + } catch (error) { + if ((error as ApiError)?.response?.status === 404) { + return []; + } + throw error; + } +}; + +/** + * Record the designated agent answer on a single FOA + */ +export const validateFoa = async ( + domainName: string, + taskId: string, + foaId: string, + choice: FoaChoiceEnum, +): Promise => { + await v2.post( + `${getTaskPath(domainName)}/${encodeURIComponent( + taskId, + )}/foa/${encodeURIComponent(foaId)}/validate`, + { choice }, + ); +}; diff --git a/packages/manager/apps/web-ongoing-operations/src/enum/foa.enum.ts b/packages/manager/apps/web-ongoing-operations/src/enum/foa.enum.ts new file mode 100644 index 000000000000..d2e4055316e5 --- /dev/null +++ b/packages/manager/apps/web-ongoing-operations/src/enum/foa.enum.ts @@ -0,0 +1,4 @@ +export enum FoaChoiceEnum { + Accept = 'ACCEPT', + Reject = 'REJECT', +} diff --git a/packages/manager/apps/web-ongoing-operations/src/hooks/data/query.tsx b/packages/manager/apps/web-ongoing-operations/src/hooks/data/query.tsx index 373337e38078..9bacb17e6074 100644 --- a/packages/manager/apps/web-ongoing-operations/src/hooks/data/query.tsx +++ b/packages/manager/apps/web-ongoing-operations/src/hooks/data/query.tsx @@ -7,11 +7,20 @@ import { } from '@/data/api/web-ongoing-operations'; import { TArgument, + TDomainResource, + TDomainTaskV2, + TFoa, TOngoingOperations, TServiceInfo, TTracking, } from '@/types'; import { getOperationTrackingStatus } from '@/data/api/tracking'; +import { + getDomainResource, + getScheduledTradeTasks, + getTaskFoas, +} from '@/data/api/foa'; +import { isPendingFoa } from '@/utils/foa.utils'; export const useTracking = (id: number) => { return useQuery({ @@ -48,3 +57,87 @@ export const useGetDomainInformation = (serviceName: string) => { retry: 0, }); }; + +/** + * The registry verdict is an authorization, so it is never served stale : the + * app wide staleTime would otherwise keep a five minutes old answer — and fire + * no request at all when walking from the listing to the certification page, + * which makes the check look absent from the network panel. + */ +export const useDomainResource = (domainName: string, enabled = true) => { + return useQuery({ + queryKey: ['foa', 'domain', domainName], + queryFn: () => getDomainResource(domainName), + enabled: !!domainName && enabled, + staleTime: 0, + }); +}; + +// Live task, live answers : both are refetched on mount rather than served +// from the app wide staleTime, so an answer landed meanwhile is seen at once +export const useScheduledTradeTask = (domainName: string, enabled = true) => { + return useQuery({ + queryKey: ['foa', 'task', domainName], + // The listing is already narrowed to the scheduled trades of the domain, + // and a domain carries at most one : the first task is the one to answer + queryFn: async () => (await getScheduledTradeTasks(domainName))[0] ?? null, + enabled: !!domainName && enabled, + staleTime: 0, + }); +}; + +export const useTaskFoas = ( + domainName: string, + taskId: string | null, + enabled = true, +) => { + return useQuery({ + queryKey: ['foa', domainName, taskId], + // '' is a sentinel : the query stays disabled while there is no task id + queryFn: () => getTaskFoas(domainName, taskId ?? ''), + enabled: !!domainName && !!taskId && enabled, + staleTime: 0, + }); +}; + +/** + * Resolve the scheduled change of registrant task of a domain, then the FOAs + * of that task still waiting for an answer : the designated agent validation + * is offered only when at least one of them is pending, and only while the + * registry does not forbid the procedure on the domain + * (currentState.designatedAgentAllowed of the APIv2 domain resource). + */ +export const usePendingFoas = (domainName: string, enabled = true) => { + const { data: task, isLoading: taskLoading } = useScheduledTradeTask( + domainName, + enabled, + ); + const taskId = task?.id ?? null; + const { data: foas = [], isLoading: foasLoading } = useTaskFoas( + domainName, + taskId, + enabled, + ); + const pendingFoas = foas.filter(isPendingFoa); + const hasPendingFoas = pendingFoas.length > 0; + // Only asked once a FOA is actually answerable : the verdict is pointless + // otherwise, and the request would be spent on every trade row of the page + const { data: domainResource, isLoading: domainLoading } = useDomainResource( + domainName, + enabled && hasPendingFoas, + ); + + return { + taskId, + foas, + pendingFoas, + // Hidden only on an explicit refusal : an unknown verdict fails open, the + // API stays the real authorization gate + isDesignatedAgentAllowed: + domainResource?.currentState?.designatedAgentAllowed !== false, + isLoading: + taskLoading || + (!!taskId && foasLoading) || + (hasPendingFoas && domainLoading), + }; +}; diff --git a/packages/manager/apps/web-ongoing-operations/src/hooks/useOngoingOperationDatagridColumns.tsx b/packages/manager/apps/web-ongoing-operations/src/hooks/useOngoingOperationDatagridColumns.tsx index b11e8ca119e5..4476c04ac32a 100644 --- a/packages/manager/apps/web-ongoing-operations/src/hooks/useOngoingOperationDatagridColumns.tsx +++ b/packages/manager/apps/web-ongoing-operations/src/hooks/useOngoingOperationDatagridColumns.tsx @@ -1,43 +1,30 @@ import React from 'react'; import { useTranslation } from 'react-i18next'; import { - ActionMenu, DataGridTextCell, - useNotifications, useFormatDate, } from '@ovh-ux/manager-react-components'; -import { useLocation, useNavigate } from 'react-router-dom'; import { TOngoingOperations } from 'src/types'; import { FilterCategories } from '@ovh-ux/manager-core-api'; -import { ODS_BUTTON_VARIANT } from '@ovhcloud/ods-components'; import { NAMESPACES } from '@ovh-ux/manager-common-translations'; import { ParentEnum } from '@/enum/parent.enum'; import { removeQuotes } from '@/utils/utils'; import OngoingOperationDatagridDomain from '@/components/OngoingOperationDatagrid/OngoingOperationDatagridDomain'; import OngoingOperationDatagridBadge from '@/components/OngoingOperationDatagrid/OngoingOperationDatagridBadge'; +import OngoingOperationDatagridActions from '@/components/OngoingOperationDatagrid/OngoingOperationDatagridActions'; import { DNS_OPERATIONS_TABLE_HEADER_DOMAIN } from '@/pages/dashboard/Dashboard'; import { StatusEnum } from '@/enum/status.enum'; -import { - DomainOperations, - DNSOperations, - DomainOperationsEnum, - AlldomOperations, -} from '@/constants'; -import { useTrackNavigation } from './tracking/useTrackDatagridNavivationLink'; +import { DomainOperations, DNSOperations, AlldomOperations } from '@/constants'; export const useOngoingOperationDatagridColumns = ( searchableColumnID: string, parent: ParentEnum, ) => { - const { trackPageNavivationTile } = useTrackNavigation(); const { t } = useTranslation([ 'dashboard', NAMESPACES.FORM, NAMESPACES.DASHBOARD, ]); - const { clearNotifications } = useNotifications(); - const navigate = useNavigate(); - const location = useLocation(); const formatDate = useFormatDate(); const getOperationsFilter = (type: ParentEnum) => { @@ -131,42 +118,7 @@ export const useOngoingOperationDatagridColumns = ( }, { cell: (props: TOngoingOperations) => ( - { - const url = `${location.pathname}/update/${props.id}`; - trackPageNavivationTile(url); - navigate(url); - clearNotifications(); - }, - }, - { - id: 2, - label: t('domain_operations_tab_popover_progress'), - className: `${props.function !== - DomainOperationsEnum.DomainIncomingTransfer && - 'hidden'} menu-item-button`, - onClick: () => { - const url = `/tracking/${props.id}`; - trackPageNavivationTile(url); - navigate(url); - }, - }, - ]} - /> + ), id: 'actions', label: '', diff --git a/packages/manager/apps/web-ongoing-operations/src/pages/dashboard/allDom/AllDom.spec.tsx b/packages/manager/apps/web-ongoing-operations/src/pages/dashboard/allDom/AllDom.spec.tsx index 914cae6fe82b..20f278ae1352 100644 --- a/packages/manager/apps/web-ongoing-operations/src/pages/dashboard/allDom/AllDom.spec.tsx +++ b/packages/manager/apps/web-ongoing-operations/src/pages/dashboard/allDom/AllDom.spec.tsx @@ -27,6 +27,12 @@ vi.mock('@/data/api/web-ongoing-operations', () => ({ vi.mock('@/hooks/data/query', () => ({ useGetDomainInformation: vi.fn(), + usePendingFoas: vi.fn(() => ({ + taskId: null, + foas: [], + pendingFoas: [], + isLoading: false, + })), })); vi.mock('@/hooks/iam/iam', () => ({ diff --git a/packages/manager/apps/web-ongoing-operations/src/pages/dashboard/dns/Dns.spec.tsx b/packages/manager/apps/web-ongoing-operations/src/pages/dashboard/dns/Dns.spec.tsx index 4c888cea7747..1b53685bc944 100644 --- a/packages/manager/apps/web-ongoing-operations/src/pages/dashboard/dns/Dns.spec.tsx +++ b/packages/manager/apps/web-ongoing-operations/src/pages/dashboard/dns/Dns.spec.tsx @@ -31,6 +31,12 @@ vi.mock('@/data/api/web-ongoing-operations', () => ({ vi.mock('@/hooks/data/query', () => ({ useGetDomainInformation: vi.fn(), + usePendingFoas: vi.fn(() => ({ + taskId: null, + foas: [], + pendingFoas: [], + isLoading: false, + })), })); describe('Dns datagrid', () => { diff --git a/packages/manager/apps/web-ongoing-operations/src/pages/dashboard/domain/Domain.spec.tsx b/packages/manager/apps/web-ongoing-operations/src/pages/dashboard/domain/Domain.spec.tsx index bd6d8807ac3c..a3792c605bff 100644 --- a/packages/manager/apps/web-ongoing-operations/src/pages/dashboard/domain/Domain.spec.tsx +++ b/packages/manager/apps/web-ongoing-operations/src/pages/dashboard/domain/Domain.spec.tsx @@ -73,6 +73,12 @@ describe('Domain datagrid', () => { data: null, }; }), + usePendingFoas: vi.fn(() => ({ + taskId: null, + foas: [], + pendingFoas: [], + isLoading: false, + })), })); const { container } = render(, { wrapper }); diff --git a/packages/manager/apps/web-ongoing-operations/src/routes/routes.constant.ts b/packages/manager/apps/web-ongoing-operations/src/routes/routes.constant.ts index 7e1da70e5e00..cdc1609503a8 100644 --- a/packages/manager/apps/web-ongoing-operations/src/routes/routes.constant.ts +++ b/packages/manager/apps/web-ongoing-operations/src/routes/routes.constant.ts @@ -5,5 +5,6 @@ export const urls = { allDom: 'alldom', track: 'tracking/:id', update: ':product/update/:id', + foa: ':product/foa/:id', error404: '404', }; diff --git a/packages/manager/apps/web-ongoing-operations/src/routes/routes.tsx b/packages/manager/apps/web-ongoing-operations/src/routes/routes.tsx index 35ef3327b5b7..5f0379832b5a 100644 --- a/packages/manager/apps/web-ongoing-operations/src/routes/routes.tsx +++ b/packages/manager/apps/web-ongoing-operations/src/routes/routes.tsx @@ -14,6 +14,9 @@ const DashboardDomainPage = React.lazy(() => const DashboardDnsPage = React.lazy(() => import('@/pages/dashboard/dns/Dns')); const TrackPage = React.lazy(() => import('@/pages/tracking/Tracking')); const ActionPage = React.lazy(() => import('@/pages/update/Update')); +const FoaPage = React.lazy(() => + import('@/components/Update/Content/Update.Foa.component'), +); export default ( + } /> ); diff --git a/packages/manager/apps/web-ongoing-operations/src/setupTests.tsx b/packages/manager/apps/web-ongoing-operations/src/setupTests.tsx index 4c4725c0ac57..e8ae4afd9471 100644 --- a/packages/manager/apps/web-ongoing-operations/src/setupTests.tsx +++ b/packages/manager/apps/web-ongoing-operations/src/setupTests.tsx @@ -30,6 +30,12 @@ vi.mock('@ovh-ux/manager-core-api', async () => { put: vi.fn(), delete: vi.fn(), }, + v2: { + get: vi.fn(), + post: vi.fn(), + put: vi.fn(), + delete: vi.fn(), + }, }; }); @@ -66,9 +72,18 @@ const mocks = vi.hoisted(() => ({ }, }, }, + navigate: vi.fn(), })); -vi.mock('@ovh-ux/manager-react-shell-client', () => ({ +/** Stable useNavigate spy, so a redirection can be asserted from a spec */ +export const navigateMock = mocks.navigate; + +vi.mock('@ovh-ux/manager-react-shell-client', async (importOriginal) => ({ + // the enums (PageLocation, ButtonType, PageType) are kept as is : the app + // tracking helpers read them at call time + ...(await importOriginal< + typeof import('@ovh-ux/manager-react-shell-client') + >()), ShellContext: React.createContext({ shell: mocks.shell, }), @@ -110,7 +125,7 @@ vi.mock('@/hooks/nichandle/useNichandle', () => ({ })); vi.mock('react-router-dom', () => ({ - useNavigate: () => vi.fn(() => null), + useNavigate: () => mocks.navigate, useSearchParams: vi.fn(() => [new URLSearchParams(), vi.fn()]), Navigate: vi.fn(() => null), useLocation: vi.fn(() => ({ @@ -124,6 +139,7 @@ vi.mock('react-router-dom', () => ({ return { serviceName: 'foobar', id: '1', + product: 'domain', }; }, NavLink: ({ ...params }: NavLinkProps) => params.children, diff --git a/packages/manager/apps/web-ongoing-operations/src/types/index.ts b/packages/manager/apps/web-ongoing-operations/src/types/index.ts index feb92d4678e9..b5260411621f 100644 --- a/packages/manager/apps/web-ongoing-operations/src/types/index.ts +++ b/packages/manager/apps/web-ongoing-operations/src/types/index.ts @@ -1,3 +1,4 @@ +import { FoaChoiceEnum } from '@/enum/foa.enum'; import { ParentEnum } from '@/enum/parent.enum'; import { TrackingEnum } from '@/enum/tracking.enum'; @@ -70,6 +71,10 @@ export interface OngoingOperationDatagridDomainProps { props: TOngoingOperations; } +export interface OngoingOperationDatagridActionsProps { + props: TOngoingOperations; +} + export interface UploadedArgumentFiles { argument: TArgument; files: File[]; @@ -81,3 +86,41 @@ export interface UpdateMeDocumentComponentProps { React.SetStateAction >; } + +/** + * Domain task as returned by APIv2 /domain/name/{domainName}/task — its id is + * a UUID, unlike the numeric id of the APIv6 /me/task/domain operations. + */ +export interface TDomainTaskV2 { + id: string; + type: string; + status: string; + createdAt?: string; + updatedAt?: string; +} + +/** + * Domain resource as returned by APIv2 /domain/name/{domainName}, narrowed to + * what the designated agent feature reads : the registry may forbid the + * designated agent procedure on the domain. + */ +export interface TDomainResource { + currentState?: { designatedAgentAllowed?: boolean } & Record< + string, + unknown + >; +} + +export type TFoaCurrentState = { choice?: FoaChoiceEnum } & Record< + string, + unknown +>; + +/** + * FOA (Form of Authorization) attached to a DomainTrade task. + * A FOA is still pending while its currentState carries no choice. + */ +export interface TFoa { + id: string; + currentState?: TFoaCurrentState; +} diff --git a/packages/manager/apps/web-ongoing-operations/src/utils/foa.utils.spec.ts b/packages/manager/apps/web-ongoing-operations/src/utils/foa.utils.spec.ts new file mode 100644 index 000000000000..e7077d1300ba --- /dev/null +++ b/packages/manager/apps/web-ongoing-operations/src/utils/foa.utils.spec.ts @@ -0,0 +1,56 @@ +import { describe, it, expect } from 'vitest'; +import { isFoaEligibleOperation, isPendingFoa } from '@/utils/foa.utils'; +import { FoaChoiceEnum } from '@/enum/foa.enum'; +import { StatusEnum } from '@/enum/status.enum'; + +describe('isPendingFoa', () => { + it('is pending while the currentState carries no choice', () => { + expect(isPendingFoa({ id: 'foa-1' })).toBe(true); + expect(isPendingFoa({ id: 'foa-2', currentState: {} })).toBe(true); + // whatever else the currentState carries is none of our business + expect( + isPendingFoa({ id: 'foa-3', currentState: { anything: 'else' } }), + ).toBe(true); + }); + + it('is not pending anymore once a choice is recorded', () => { + expect( + isPendingFoa({ id: 'foa-1', currentState: { choice: FoaChoiceEnum.Accept } }), + ).toBe(false); + expect( + isPendingFoa({ id: 'foa-2', currentState: { choice: FoaChoiceEnum.Reject } }), + ).toBe(false); + }); +}); + +describe('isFoaEligibleOperation', () => { + const operation = { function: 'DomainTrade', status: StatusEnum.TODO }; + + it('is eligible while the trade is still running', () => { + [ + StatusEnum.TODO, + StatusEnum.DOING, + StatusEnum.ERROR, + StatusEnum.PROBLEM, + ].forEach((status) => { + expect(isFoaEligibleOperation({ ...operation, status })).toBe(true); + }); + }); + + it('is not eligible anymore on a finished trade', () => { + expect( + isFoaEligibleOperation({ ...operation, status: StatusEnum.DONE }), + ).toBe(false); + expect( + isFoaEligibleOperation({ ...operation, status: StatusEnum.CANCELLED }), + ).toBe(false); + }); + + it('is not eligible on another operation, nor on a missing one', () => { + expect( + isFoaEligibleOperation({ ...operation, function: 'DomainDnsUpdate' }), + ).toBe(false); + expect(isFoaEligibleOperation(undefined)).toBe(false); + expect(isFoaEligibleOperation(null)).toBe(false); + }); +}); diff --git a/packages/manager/apps/web-ongoing-operations/src/utils/foa.utils.ts b/packages/manager/apps/web-ongoing-operations/src/utils/foa.utils.ts new file mode 100644 index 000000000000..c25a36c2cae7 --- /dev/null +++ b/packages/manager/apps/web-ongoing-operations/src/utils/foa.utils.ts @@ -0,0 +1,23 @@ +import { + DomainOperationsEnum, + foaEligibleOperationStatuses, +} from '@/constants'; +import { TFoa, TOngoingOperations } from '@/types'; + +/** + * A FOA is still pending while the holder has not answered it, ie while its + * currentState carries no choice. + */ +export const isPendingFoa = (foa: TFoa): boolean => !foa.currentState?.choice; + +/** + * An operation may be validated by a designated agent only while it is a + * change of registrant still running. The APIv6 status of the operation is + * the authority here : a done, cancelled — or otherwise finished — trade can + * no longer be answered, whatever the APIv2 task list carries for the domain. + */ +export const isFoaEligibleOperation = ( + operation?: Pick | null, +): boolean => + operation?.function === DomainOperationsEnum.DomainTrade && + foaEligibleOperationStatuses.includes(operation.status); From 8df2054e306750303636a8b79f5b6d0ce6a9bc9b Mon Sep 17 00:00:00 2001 From: CDS Translator Agent Date: Tue, 8 Sep 2026 13:29:22 +0000 Subject: [PATCH 2/2] fix(i18n): add missing translations [CDS 1309] Signed-off-by: CDS Translator Agent --- .../translations/dashboard/Messages_de_DE.json | 11 ++++++++++- .../translations/dashboard/Messages_en_GB.json | 11 ++++++++++- .../translations/dashboard/Messages_es_ES.json | 11 ++++++++++- .../translations/dashboard/Messages_fr_CA.json | 12 +++++++++++- .../translations/dashboard/Messages_it_IT.json | 11 ++++++++++- .../translations/dashboard/Messages_pl_PL.json | 11 ++++++++++- .../translations/dashboard/Messages_pt_PT.json | 11 ++++++++++- 7 files changed, 71 insertions(+), 7 deletions(-) diff --git a/packages/manager/apps/web-ongoing-operations/public/translations/dashboard/Messages_de_DE.json b/packages/manager/apps/web-ongoing-operations/public/translations/dashboard/Messages_de_DE.json index 688c67330ed1..f15a94d026a8 100644 --- a/packages/manager/apps/web-ongoing-operations/public/translations/dashboard/Messages_de_DE.json +++ b/packages/manager/apps/web-ongoing-operations/public/translations/dashboard/Messages_de_DE.json @@ -182,5 +182,14 @@ "domain_operations_update_nationalidentificationnumber": "Steuernummer", "domain_operations_update_identificationnumber": "Identifikationsnummer", "domain_operations_update_nicowner_click_whoiscontactbilling": "Ändern Sie die Informationen des Rechnungskontakts, indem Sie hier klicken", - "domain_operations_update_nicowner_click_whoiscontacttech": "Ändern Sie die Informationen des technischen Kontakts, indem Sie hier klicken" + "domain_operations_update_nicowner_click_whoiscontacttech": "Ändern Sie die Informationen des technischen Kontakts, indem Sie hier klicken", + "domain_operations_foa_cta": "Als benannter Vertreter bestätigen", + "domain_operations_foa_title": "Bestätigung als benannter Vertreter für den Domainnamen {{t0}}", + "domain_operations_foa_description": "Dieser Inhaberwechsel wartet auf die Bestätigung der an die Inhaber gesendeten Autorisierungsformulare (FOA). Als benannter Vertreter können Sie diese Änderung in deren Namen akzeptieren oder ablehnen. Nur noch nicht beantwortete Formulare werden bestätigt.", + "domain_operations_foa_certification": "Ich bestätige, dass ich ordnungsgemäß bevollmächtigt bin, als benannter Vertreter zu handeln und im Namen des aktuellen sowie des neuen Inhabers zu antworten.", + "domain_operations_foa_accept": "Ich akzeptiere diesen Inhaberwechsel für {{t0}}", + "domain_operations_foa_reject": "Ich lehne diesen Inhaberwechsel für {{t0}} ab", + "domain_operations_foa_accept_success": "Der Inhaberwechsel wurde als benannter Vertreter akzeptiert.", + "domain_operations_foa_reject_success": "Der Inhaberwechsel wurde als benannter Vertreter abgelehnt.", + "domain_operations_foa_error": "Bestätigung als benannter Vertreter fehlgeschlagen" } diff --git a/packages/manager/apps/web-ongoing-operations/public/translations/dashboard/Messages_en_GB.json b/packages/manager/apps/web-ongoing-operations/public/translations/dashboard/Messages_en_GB.json index b8b46d3d6579..9be77c0812f7 100644 --- a/packages/manager/apps/web-ongoing-operations/public/translations/dashboard/Messages_en_GB.json +++ b/packages/manager/apps/web-ongoing-operations/public/translations/dashboard/Messages_en_GB.json @@ -182,5 +182,14 @@ "domain_operations_update_nationalidentificationnumber": "Tax code ", "domain_operations_update_identificationnumber": "Identification number", "domain_operations_update_nicowner_click_whoiscontactbilling": "Change the billing contact information by clicking here", - "domain_operations_update_nicowner_click_whoiscontacttech": "Change the technical contact information by clicking here" + "domain_operations_update_nicowner_click_whoiscontacttech": "Change the technical contact information by clicking here", + "domain_operations_foa_cta": "Validate as designated agent", + "domain_operations_foa_title": "Validation as designated agent for domain name {{t0}}", + "domain_operations_foa_description": "This change of registrant is awaiting validation of the Form of Authorisation (FOA) sent to the registrants. As a designated agent, you may accept or reject this change on their behalf. Only forms that have not yet been answered will be validated.", + "domain_operations_foa_certification": "I certify that I am duly authorised to act as a designated agent and to respond on behalf of the current registrant and the new registrant.", + "domain_operations_foa_accept": "I accept this change of registrant for {{t0}}", + "domain_operations_foa_reject": "I refuse this change of registrant for {{t0}}", + "domain_operations_foa_accept_success": "Change of registrant has been accepted as designated agent.", + "domain_operations_foa_reject_success": "Change of registrant rejected as designated agent.", + "domain_operations_foa_error": "Validation as designated agent failed" } diff --git a/packages/manager/apps/web-ongoing-operations/public/translations/dashboard/Messages_es_ES.json b/packages/manager/apps/web-ongoing-operations/public/translations/dashboard/Messages_es_ES.json index 3aaab61e5097..edae2633ac17 100644 --- a/packages/manager/apps/web-ongoing-operations/public/translations/dashboard/Messages_es_ES.json +++ b/packages/manager/apps/web-ongoing-operations/public/translations/dashboard/Messages_es_ES.json @@ -182,5 +182,14 @@ "domain_operations_update_nationalidentificationnumber": "NIF", "domain_operations_update_identificationnumber": "Número de identificación", "domain_operations_update_nicowner_click_whoiscontactbilling": "Cambiad la información del contacto de facturación haciendo clic aquí", - "domain_operations_update_nicowner_click_whoiscontacttech": "Cambiad la información del contacto técnico haciendo clic aquí" + "domain_operations_update_nicowner_click_whoiscontacttech": "Cambiad la información del contacto técnico haciendo clic aquí", + "domain_operations_foa_cta": "Validar como agente designado", + "domain_operations_foa_title": "Validación como agente designado para el nombre de dominio {{t0}}", + "domain_operations_foa_description": "Este cambio de titular está pendiente de la validación de los formularios de autorización (FOA) enviados a los titulares. Como agente designado, podéis aceptar o rechazar este cambio en su nombre. Solo se validarán los formularios que aún no tengan respuesta.", + "domain_operations_foa_certification": "Certifico que estoy debidamente autorizado para actuar como agente designado y para responder en nombre del titular actual y del nuevo titular.", + "domain_operations_foa_accept": "Acepto este cambio de titular para {{t0}}", + "domain_operations_foa_reject": "Rechazo este cambio de titular para {{t0}}", + "domain_operations_foa_accept_success": "El cambio de titular ha sido aceptado como agente designado.", + "domain_operations_foa_reject_success": "El cambio de titular ha sido rechazado como agente designado.", + "domain_operations_foa_error": "Error en la validación como agente designado" } diff --git a/packages/manager/apps/web-ongoing-operations/public/translations/dashboard/Messages_fr_CA.json b/packages/manager/apps/web-ongoing-operations/public/translations/dashboard/Messages_fr_CA.json index cfbaad12cecd..b75246eaed30 100644 --- a/packages/manager/apps/web-ongoing-operations/public/translations/dashboard/Messages_fr_CA.json +++ b/packages/manager/apps/web-ongoing-operations/public/translations/dashboard/Messages_fr_CA.json @@ -88,6 +88,7 @@ "domain_operations_update_gender": "Genre", "domain_operations_update_language": "Langue", "domain_operations_update_firstname": "Prénom", + "domain_operations_update_lastname": "Nom", "domain_operations_update_name": "Nom", "domain_operations_update_uklegalform": "Forme légale", "domain_operations_update_nationalidentificationnumber": "Code fiscal", @@ -153,5 +154,14 @@ "domain_operations_update_nicbilling_click": "Changez les informations du contact facturation en cliquant ici", "domain_operations_accelerate_success": "L'opération a été accélérée avec succès.", "domain_operations_cancel_success": "L'opération a été annulée avec succès.", - "domain_operations_relaunch_success": "L'opération a été relancée avec succès." + "domain_operations_relaunch_success": "L'opération a été relancée avec succès.", + "domain_operations_foa_cta": "Valider en tant qu'agent désigné", + "domain_operations_foa_title": "Validation en tant qu'agent désigné pour le nom de domaine {{t0}}", + "domain_operations_foa_description": "Ce changement de titulaire est en attente de la validation des formulaires d'autorisation (FOA) envoyés aux titulaires. En tant qu'agent désigné, vous pouvez accepter ou rejeter ce changement en leur nom. Seuls les formulaires encore sans réponse seront validés.", + "domain_operations_foa_certification": "Je certifie que je suis dûment autorisé(e) à agir en tant qu'agent désigné et à répondre au nom du titulaire actuel et du nouveau titulaire.", + "domain_operations_foa_accept": "J'accepte ce changement de titulaire pour {{t0}}", + "domain_operations_foa_reject": "Je refuse ce changement de titulaire pour {{t0}}", + "domain_operations_foa_accept_success": "Le changement de titulaire a été accepté en tant qu'agent désigné.", + "domain_operations_foa_reject_success": "Le changement de titulaire a été rejeté en tant qu'agent désigné.", + "domain_operations_foa_error": "Échec de la validation en tant qu'agent désigné" } diff --git a/packages/manager/apps/web-ongoing-operations/public/translations/dashboard/Messages_it_IT.json b/packages/manager/apps/web-ongoing-operations/public/translations/dashboard/Messages_it_IT.json index a01b668961c9..054e4a41865e 100644 --- a/packages/manager/apps/web-ongoing-operations/public/translations/dashboard/Messages_it_IT.json +++ b/packages/manager/apps/web-ongoing-operations/public/translations/dashboard/Messages_it_IT.json @@ -182,5 +182,14 @@ "domain_operations_update_nationalidentificationnumber": "Codice fiscale", "domain_operations_update_identificationnumber": "Numero di identificazione", "domain_operations_update_nicowner_click_whoiscontactbilling": "Modifica le informazioni del contatto di fatturazione cliccando qui", - "domain_operations_update_nicowner_click_whoiscontacttech": "Modifica le informazioni del contatto tecnico cliccando qui" + "domain_operations_update_nicowner_click_whoiscontacttech": "Modifica le informazioni del contatto tecnico cliccando qui", + "domain_operations_foa_cta": "Convalida come agente designato", + "domain_operations_foa_title": "Convalida come agente designato per il nome di dominio {{t0}}", + "domain_operations_foa_description": "Questo cambio di titolare è in attesa della convalida dei moduli di autorizzazione (FOA) inviati ai titolari. In qualità di agente designato, può accettare o rifiutare questo cambio per loro conto. Saranno convalidati solo i moduli ancora senza risposta.", + "domain_operations_foa_certification": "Certifico di essere debitamente autorizzato ad agire in qualità di agente designato e a rispondere per conto dell'attuale titolare e del nuovo titolare.", + "domain_operations_foa_accept": "Accetto questo cambio di titolare per {{t0}}", + "domain_operations_foa_reject": "Rifiuto questo cambio di titolare per {{t0}}", + "domain_operations_foa_accept_success": "Il cambio di titolare è stato accettato come agente designato.", + "domain_operations_foa_reject_success": "Il cambio di titolare è stato rifiutato come agente designato.", + "domain_operations_foa_error": "Convalida come agente designato non riuscita" } diff --git a/packages/manager/apps/web-ongoing-operations/public/translations/dashboard/Messages_pl_PL.json b/packages/manager/apps/web-ongoing-operations/public/translations/dashboard/Messages_pl_PL.json index f88425438710..59b9ee2aef21 100644 --- a/packages/manager/apps/web-ongoing-operations/public/translations/dashboard/Messages_pl_PL.json +++ b/packages/manager/apps/web-ongoing-operations/public/translations/dashboard/Messages_pl_PL.json @@ -182,5 +182,14 @@ "domain_operations_update_nationalidentificationnumber": "NIP", "domain_operations_update_identificationnumber": "Numer identyfikacyjny", "domain_operations_update_nicowner_click_whoiscontactbilling": "Zmień dane kontaktu do faktur, klikając tutaj", - "domain_operations_update_nicowner_click_whoiscontacttech": "Zmień dane kontaktu technicznego, klikając tutaj" + "domain_operations_update_nicowner_click_whoiscontacttech": "Zmień dane kontaktu technicznego, klikając tutaj", + "domain_operations_foa_cta": "Zatwierdź jako wyznaczony agent", + "domain_operations_foa_title": "Zatwierdzenie jako wyznaczony agent dla nazwy domeny {{t0}}", + "domain_operations_foa_description": "Ta zmiana abonenta oczekuje na zatwierdzenie formularzy autoryzacyjnych (FOA) wysłanych do abonentów. Jako wyznaczony agent możesz zaakceptować lub odrzucić tę zmianę w ich imieniu. Zatwierdzone zostaną tylko formularze, na które nie udzielono jeszcze odpowiedzi.", + "domain_operations_foa_certification": "Oświadczam, że jestem należycie upoważniony(-a) do działania jako wyznaczony agent oraz do odpowiadania w imieniu obecnego i nowego abonenta.", + "domain_operations_foa_accept": "Akceptuję tę zmianę abonenta dla {{t0}}", + "domain_operations_foa_reject": "Odrzucam tę zmianę abonenta dla {{t0}}", + "domain_operations_foa_accept_success": "Zmiana abonenta została zaakceptowana jako wyznaczony agent.", + "domain_operations_foa_reject_success": "Zmiana abonenta została odrzucona jako wyznaczony agent.", + "domain_operations_foa_error": "Nie udało się zatwierdzić jako wyznaczony agent" } diff --git a/packages/manager/apps/web-ongoing-operations/public/translations/dashboard/Messages_pt_PT.json b/packages/manager/apps/web-ongoing-operations/public/translations/dashboard/Messages_pt_PT.json index 4e1cb455cc9d..a0de990cc2eb 100644 --- a/packages/manager/apps/web-ongoing-operations/public/translations/dashboard/Messages_pt_PT.json +++ b/packages/manager/apps/web-ongoing-operations/public/translations/dashboard/Messages_pt_PT.json @@ -182,5 +182,14 @@ "domain_operations_update_nationalidentificationnumber": "Número de identificação fiscal", "domain_operations_update_identificationnumber": "Número de identificação", "domain_operations_update_nicowner_click_whoiscontactbilling": "Altere as informações do contacto de faturação clicando aqui", - "domain_operations_update_nicowner_click_whoiscontacttech": "Altere as informações do contacto técnico clicando aqui" + "domain_operations_update_nicowner_click_whoiscontacttech": "Altere as informações do contacto técnico clicando aqui", + "domain_operations_foa_cta": "Validar como agente designado", + "domain_operations_foa_title": "Validação como agente designado para o nome de domínio {{t0}}", + "domain_operations_foa_description": "Esta alteração de titular encontra-se pendente da validação dos formulários de autorização (FOA) enviados aos titulares. Como agente designado, pode aceitar ou rejeitar esta alteração em nome deles. Apenas os formulários ainda sem resposta serão validados.", + "domain_operations_foa_certification": "Certifico que estou devidamente autorizado(a) a agir como agente designado e a responder em nome do titular atual e do novo titular.", + "domain_operations_foa_accept": "Aceito esta alteração de titular para {{t0}}", + "domain_operations_foa_reject": "Recuso esta alteração de titular para {{t0}}", + "domain_operations_foa_accept_success": "A alteração de titular foi aceite como agente designado.", + "domain_operations_foa_reject_success": "A alteração de titular foi rejeitada como agente designado.", + "domain_operations_foa_error": "Falha na validação como agente designado" }