diff --git a/src/oneid/oneid-ecs-core/src/main/webui/package.json b/src/oneid/oneid-ecs-core/src/main/webui/package.json index f56268a4..73ab4934 100644 --- a/src/oneid/oneid-ecs-core/src/main/webui/package.json +++ b/src/oneid/oneid-ecs-core/src/main/webui/package.json @@ -10,6 +10,7 @@ "@mui/lab": "^5.0.0-alpha.170", "@mui/material": "^5.15.20", "@pagopa/mui-italia": "^1.4.2", + "@tanstack/react-query": "^5.62.2", "@types/node": "^20.14.5", "@types/react": "^18.3.3", "@types/react-dom": "^18.3.0", diff --git a/src/oneid/oneid-ecs-core/src/main/webui/src/App.test.tsx b/src/oneid/oneid-ecs-core/src/main/webui/src/App.test.tsx index 936edc42..af2ae1d5 100644 --- a/src/oneid/oneid-ecs-core/src/main/webui/src/App.test.tsx +++ b/src/oneid/oneid-ecs-core/src/main/webui/src/App.test.tsx @@ -19,7 +19,7 @@ vi.mock('./services/analyticsService', () => ({ })); // Mock the components -vi.mock('./pages/login/Login', () => ({ +vi.mock('./pages/login', () => ({ default: () =>
Mocked Login Component
, })); vi.mock('./pages/logout/Logout', () => ({ diff --git a/src/oneid/oneid-ecs-core/src/main/webui/src/App.tsx b/src/oneid/oneid-ecs-core/src/main/webui/src/App.tsx index b0655bc5..acbb2834 100644 --- a/src/oneid/oneid-ecs-core/src/main/webui/src/App.tsx +++ b/src/oneid/oneid-ecs-core/src/main/webui/src/App.tsx @@ -1,4 +1,3 @@ -import Login from './pages/login/Login'; import { ROUTE_LOGIN, ROUTE_LOGIN_ERROR, @@ -7,6 +6,7 @@ import { import { redirectToLogin } from './utils/utils'; import Logout from './pages/logout/Logout'; import { LoginError } from './pages/loginError/LoginError'; +import Login from './pages/login'; const onLogout = () => ; const onLoginError = () => ; diff --git a/src/oneid/oneid-ecs-core/src/main/webui/src/hooks/useLoginData.test.tsx b/src/oneid/oneid-ecs-core/src/main/webui/src/hooks/useLoginData.test.tsx new file mode 100644 index 00000000..c6f26b72 --- /dev/null +++ b/src/oneid/oneid-ecs-core/src/main/webui/src/hooks/useLoginData.test.tsx @@ -0,0 +1,110 @@ +import { describe, it, expect, vi, Mock } from 'vitest'; +import { renderHook, waitFor } from '@testing-library/react'; +import { useLoginData } from './useLoginData'; +import { ENV } from '../utils/env'; +import { fetchBannerContent, getIdpList, getClientData } from '../services/api'; +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; + +// Mock API functions +vi.mock('../services/api', () => ({ + fetchBannerContent: vi.fn(), + getIdpList: vi.fn(), + getClientData: vi.fn(), +})); + +// Mock ENV +vi.mock('../utils/env', () => ({ + ENV: { + JSON_URL: { + ALERT: 'mock-alert-url', + IDP_LIST: 'mock-idp-list-url', + CLIENT_BASE_URL: 'mock-client-base-url', + }, + }, +})); + +describe('useLoginData', () => { + const wrapper = ({ children }: { children: React.ReactNode }) => { + const queryClient = new QueryClient(); + return ( + {children} + ); + }; + + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('fetches banner content successfully', async () => { + const mockBannerContent = [ + { title: 'Test Banner', description: 'Test Description' }, + ]; + (fetchBannerContent as Mock).mockResolvedValue(mockBannerContent); + + const { result } = renderHook(useLoginData, { wrapper }); + + await waitFor(() => + expect(result.current.bannerQuery.isSuccess).toBe(true) + ); + expect(fetchBannerContent).toHaveBeenCalledWith(ENV.JSON_URL.ALERT); + expect(result.current.bannerQuery.data).toEqual(mockBannerContent); + }); + + it('fetches identity providers list successfully', async () => { + const mockIdpList = { + providers: [{ name: 'Test IDP', url: 'test-idp-url' }], + }; + (getIdpList as Mock).mockResolvedValue(mockIdpList); + + const { result } = renderHook(useLoginData, { wrapper }); + + await waitFor(() => expect(result.current.idpQuery.isSuccess).toBe(true)); + expect(getIdpList).toHaveBeenCalledWith(ENV.JSON_URL.IDP_LIST); + expect(result.current.idpQuery.data).toEqual(mockIdpList); + }); + + it('fetches client data successfully', async () => { + const mockClientData = { + clientId: 'test-client-id', + clientSecret: 'test-client-secret', + }; + (getClientData as Mock).mockResolvedValue(mockClientData); + + const { result } = renderHook(useLoginData, { wrapper }); + + await waitFor(() => + expect(result.current.clientQuery.isSuccess).toBe(true) + ); + expect(getClientData).toHaveBeenCalledWith(ENV.JSON_URL.CLIENT_BASE_URL); + expect(result.current.clientQuery.data).toEqual(mockClientData); + }); + + it('handles errors correctly', async () => { + (fetchBannerContent as Mock).mockRejectedValue( + new Error('Banner content error') + ); + (getIdpList as Mock).mockRejectedValue(new Error('IDP list error')); + (getClientData as Mock).mockRejectedValue(new Error('Client data error')); + + const { result } = renderHook(useLoginData, { wrapper }); + + await waitFor(() => expect(result.current.bannerQuery.isError).toBe(true), { + timeout: 10000, + }); + expect(result.current.bannerQuery.error).toEqual( + new Error('Banner content error') + ); + + await waitFor(() => expect(result.current.idpQuery.isError).toBe(true), { + timeout: 10000, + }); + expect(result.current.idpQuery.error).toEqual(new Error('IDP list error')); + + await waitFor(() => expect(result.current.clientQuery.isError).toBe(true), { + timeout: 10000, + }); + expect(result.current.clientQuery.error).toEqual( + new Error('Client data error') + ); + }); +}); diff --git a/src/oneid/oneid-ecs-core/src/main/webui/src/hooks/useLoginData.tsx b/src/oneid/oneid-ecs-core/src/main/webui/src/hooks/useLoginData.tsx new file mode 100644 index 00000000..b4541a52 --- /dev/null +++ b/src/oneid/oneid-ecs-core/src/main/webui/src/hooks/useLoginData.tsx @@ -0,0 +1,38 @@ +import { useQuery } from '@tanstack/react-query'; +import { ENV } from '../utils/env'; +import { IdentityProviders } from '../utils/IDPS'; +import { + type BannerContent, + type Client, + fetchBannerContent, + getIdpList, + getClientData, +} from '../services/api'; + +const staleTime = 5 * 60 * 1000; +const retry = 2; + +export const useLoginData = () => { + const bannerQuery = useQuery, Error>({ + queryKey: ['bannerContent'], + queryFn: () => fetchBannerContent(ENV.JSON_URL.ALERT), + staleTime, + retry, + }); + + const idpQuery = useQuery({ + queryKey: ['idpList'], + queryFn: () => getIdpList(ENV.JSON_URL.IDP_LIST), + staleTime, + retry, + }); + + const clientQuery = useQuery({ + queryKey: ['clientData'], + queryFn: () => getClientData(ENV.JSON_URL.CLIENT_BASE_URL), + staleTime, + retry, + }); + + return { bannerQuery, idpQuery, clientQuery }; +}; diff --git a/src/oneid/oneid-ecs-core/src/main/webui/src/index.tsx b/src/oneid/oneid-ecs-core/src/main/webui/src/index.tsx index 991d5e97..c1ff6ec8 100644 --- a/src/oneid/oneid-ecs-core/src/main/webui/src/index.tsx +++ b/src/oneid/oneid-ecs-core/src/main/webui/src/index.tsx @@ -5,16 +5,20 @@ import { theme } from '@pagopa/mui-italia'; import App from './App'; import './locale'; +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; const rootEl = document.getElementById('root'); if (rootEl) { const root = createRoot(rootEl); + const queryClient = new QueryClient(); root.render( - + + + ); diff --git a/src/oneid/oneid-ecs-core/src/main/webui/src/pages/login/Login.test.tsx b/src/oneid/oneid-ecs-core/src/main/webui/src/pages/login/Login.test.tsx index d78f5434..4c1b3f80 100644 --- a/src/oneid/oneid-ecs-core/src/main/webui/src/pages/login/Login.test.tsx +++ b/src/oneid/oneid-ecs-core/src/main/webui/src/pages/login/Login.test.tsx @@ -1,138 +1,139 @@ -/* eslint-disable functional/immutable-data */ import { render, screen, fireEvent, waitFor } from '@testing-library/react'; -import { afterAll, beforeAll, expect, Mock, test, vi } from 'vitest'; - +import { Mock, vi } from 'vitest'; +import Login, { LinkWrapper } from '../login'; +import { useLoginData } from '../../hooks/useLoginData'; import { ENV } from '../../utils/env'; -import { i18nTestSetup } from '../../__tests__/i18nTestSetup'; -import Login from './Login'; - -// Mock fetch -global.fetch = vi.fn(); - -i18nTestSetup({ - loginPage: { - privacyAndCondition: { - text: 'terms: {{termsLink}} privacy: {{privacyLink}}', - }, - }, -}); - -const oldWindowLocation = global.window.location; - -beforeAll(() => { - // Mock window location - Object.defineProperty(window, 'location', { value: { assign: vi.fn() } }); -}); - -afterAll(() => { - Object.defineProperty(window, 'location', { value: oldWindowLocation }); -}); - -// Clear mocks after each test -afterEach(() => { - vi.clearAllMocks(); +import { trackEvent } from '../../services/analyticsService'; +import { ThemeProvider } from '@emotion/react'; +import { createTheme } from '@mui/material'; + +vi.mock('../../hooks/useLoginData'); +vi.mock('../../services/analyticsService', () => ({ + trackEvent: vi.fn(), +})); +vi.mock('@mui/material', async () => { + const actual = await vi.importActual('@mui/material'); + return { + ...actual, + useTheme: () => ({ + spacing: (value: number) => value * 8, + breakpoints: { down: () => '@media (max-width: 960px)' }, + }), + }; }); -test('Renders Login component', () => { - render(); - expect(screen.getByText('loginPage.title')).toBeInTheDocument(); -}); +describe('LinkWrapper', () => { + const mockOnClick = vi.fn(); -const mockWarning = 'This is a warning!'; + const renderWithTheme = (component: React.ReactNode) => { + const theme = createTheme(); + return render({component}); + }; -test('Fetches and displays banner alerts', async () => { - // Mock the fetch response - const mockBannerResponse = [ - { enable: true, severity: 'warning', description: mockWarning }, - ]; - (fetch as Mock).mockResolvedValueOnce({ - json: vi.fn().mockResolvedValueOnce(mockBannerResponse), + beforeEach(() => { + vi.resetAllMocks(); }); - render(); + it('renders children correctly', () => { + renderWithTheme(Test Link); - await waitFor(() => { - expect(screen.getByText(mockWarning)).toBeInTheDocument(); + expect(screen.getByText('Test Link')).toBeInTheDocument(); }); -}); -test('Handles fetch error for alert message', async () => { - (fetch as Mock).mockRejectedValueOnce(new Error('Fetch failed')); + it('calls onClick handler when clicked', () => { + renderWithTheme(Click Me); + const link = screen.getByText('Click Me'); - render(); + fireEvent.click(link); - // Optionally check if an error message or warning is displayed - await waitFor(() => { - expect(screen.queryByText(mockWarning)).not.toBeInTheDocument(); - }); -}); - -test('Fetches IDP list on mount', async () => { - const mockIDPListResponse = { - identityProviders: [{ entityID: 'test-idp' }], - richiediSpid: 'https://example.com/spid', - }; - (fetch as Mock).mockResolvedValueOnce({ - json: vi.fn().mockResolvedValueOnce(mockIDPListResponse), + expect(mockOnClick).toHaveBeenCalledTimes(1); }); - render(); + it('applies correct styles', () => { + renderWithTheme( + Styled Link + ); + const link = screen.getByText('Styled Link'); - await waitFor(() => { - expect(fetch).toHaveBeenCalledWith(ENV.JSON_URL.IDP_LIST); + expect(link).toHaveStyle({ + cursor: 'pointer', + }); }); }); -test('Handles invalid client ID gracefully', async () => { - window.history.pushState({}, '', `?client_id=invalidId`); - - render(); +describe('', () => { + const mockBannerQuery = { + isSuccess: true, + data: [{ enable: true, severity: 'warning', description: 'Test banner' }], + }; + const mockClientQuery = { + isFetched: true, + data: { + friendlyName: 'Test Client', + logoUri: 'https://example.com/logo.png', + }, + }; + const mockIdpQuery = { + isLoading: false, + data: [ + { + entityID: 'idp1', + name: 'IDP 1', + imageUrl: 'https://example.com/idp1.png', + }, + ], + }; - await waitFor(() => { - expect(screen.queryByAltText('Test Client')).not.toBeInTheDocument(); + beforeEach(() => { + (useLoginData as Mock).mockReturnValue({ + bannerQuery: mockBannerQuery, + clientQuery: mockClientQuery, + idpQuery: mockIdpQuery, + }); }); -}); - -test('Clicking SPID button opens modal', () => { - render(); - const buttonSpid = document.getElementById('spidButton'); - fireEvent.click(buttonSpid as HTMLElement); - - expect(screen.getByRole('dialog')).toBeInTheDocument(); // Check if modal opens -}); -test('Clicking CIE button redirects correctly', () => { - render(); - const buttonCIE = screen.getByRole('button', { - name: 'loginPage.loginBox.cieLogin', + it('renders titles and descriptions', () => { + render(); + expect(screen.getByText('loginPage.title')).toBeInTheDocument(); + expect(screen.getByText('loginPage.description')).toBeInTheDocument(); }); - fireEvent.click(buttonCIE); - expect(global.window.location.assign).toHaveBeenCalledWith( - `${ENV.URL_API.AUTHORIZE}?idp=${ENV.SPID_CIE_ENTITY_ID}` - ); -}); - -test('Clicking terms and conditions link redirects correctly', () => { - render(); - - const termsConditionLink = screen.getByText( - 'loginPage.privacyAndCondition.terms' - ); - fireEvent.click(termsConditionLink); - - expect(global.window.location.assign).toHaveBeenCalledWith( - ENV.URL_FOOTER.TERMS_AND_CONDITIONS - ); -}); + it('displays the client logo', () => { + render(); + const logo = screen.getByAltText('Test Client'); + expect(logo).toBeInTheDocument(); + expect(logo).toHaveAttribute('src', 'https://example.com/logo.png'); + }); -test('Clicking privacy link redirects correctly', () => { - render(); + it('shows a banner when bannerQuery is successful', () => { + render(); + expect(screen.getByText('Test banner')).toBeInTheDocument(); + }); - const privacyLink = screen.getByText('loginPage.privacyAndCondition.privacy'); - fireEvent.click(privacyLink); + it('opens the SpidModal on SPID button click', () => { + render(); + const spidButton = screen.getByRole('button', { name: /SPID/i }); + fireEvent.click(spidButton); + expect( + screen.getByRole('dialog', { name: 'spidSelect.modalTitle' }) + ).toBeInTheDocument(); + }); - expect(global.window.location.assign).toHaveBeenCalledWith( - ENV.URL_FOOTER.PRIVACY_DISCLAIMER - ); + it('navigates to CIE login on CIE button click', async () => { + render(); + const cieButton = screen.getByRole('button', { name: /CIE/i }); + fireEvent.click(cieButton); + + await waitFor(() => { + expect(trackEvent).toHaveBeenCalledWith( + 'LOGIN_IDP_SELECTED', + { + SPID_IDP_NAME: 'CIE', + SPID_IDP_ID: ENV.SPID_CIE_ENTITY_ID, + FORWARD_PARAMETERS: expect.any(String), + }, + expect.any(Function) + ); + }); + }); }); diff --git a/src/oneid/oneid-ecs-core/src/main/webui/src/pages/login/SpidModal.tsx b/src/oneid/oneid-ecs-core/src/main/webui/src/pages/login/SpidModal.tsx deleted file mode 100644 index d2dbc6e7..00000000 --- a/src/oneid/oneid-ecs-core/src/main/webui/src/pages/login/SpidModal.tsx +++ /dev/null @@ -1,119 +0,0 @@ -import { Button, Dialog, Grid, Icon, Typography } from '@mui/material'; -import { useTranslation } from 'react-i18next'; - -import { IdentityProvider, IdentityProviders } from '../../utils/IDPS'; -import { trackEvent } from '../../services/analyticsService'; -import { forwardSearchParams } from '../../utils/utils'; -import { ENV } from '../../utils/env'; -import { ImageWithFallback } from '../../components/ImageFallback'; -import { IDP_PLACEHOLDER_IMG } from '../../utils/constants'; - -type Props = { - openSpidModal: boolean; - setOpenSpidModal: (openDialog: boolean) => void; - idpList: IdentityProviders; -}; - -export const getSPID = (IDP: IdentityProvider) => { - const params = forwardSearchParams(IDP.entityID); - const redirectUrl = `${ENV.URL_API.AUTHORIZE}?${params}`; - trackEvent( - 'LOGIN_IDP_SELECTED', - { - SPID_IDP_NAME: IDP.name, - SPID_IDP_ID: IDP.entityID, - FORWARD_PARAMETERS: params, - }, - () => window.location.assign(redirectUrl) - ); -}; - -const IdpListSelection = ({ - identityProviders, -}: { - identityProviders: Array; -}) => - identityProviders?.map((IDP, i) => ( - - - - )); - -const SpidModal = ({ openSpidModal, setOpenSpidModal, idpList }: Props) => { - const { t } = useTranslation(); - - return ( - setOpenSpidModal(false)}> - - {t('spidSelect.modalTitle')} - - {idpList?.identityProviders?.length ? ( - - - - - - ) : ( - - {t('spidSelect.placeholder')} - - )} - - - - - ); -}; - -export default SpidModal; diff --git a/src/oneid/oneid-ecs-core/src/main/webui/src/pages/login/SpidSelect.tsx b/src/oneid/oneid-ecs-core/src/main/webui/src/pages/login/SpidSelect.tsx deleted file mode 100644 index a8dcf157..00000000 --- a/src/oneid/oneid-ecs-core/src/main/webui/src/pages/login/SpidSelect.tsx +++ /dev/null @@ -1,136 +0,0 @@ -import { Fragment } from 'react'; -import Icon from '@mui/material/Icon'; -import Grid from '@mui/material/Grid'; -import Typography from '@mui/material/Typography'; -import Button from '@mui/material/Button'; -import { useTranslation } from 'react-i18next'; - -import { IdentityProvider, IdentityProviders } from '../../utils/IDPS'; -import SpidBig from '../../assets/spid_big.svg'; -import { ENV } from '../../utils/env'; -import { IDP_PLACEHOLDER_IMG } from '../../utils/constants'; -import { trackEvent } from '../../services/analyticsService'; -import { forwardSearchParams } from '../../utils/utils'; -import { ImageWithFallback } from '../../components/ImageFallback'; - -type Props = { - onBack: () => void; - idpList: IdentityProviders; -}; - -export const getSPID = (IDP: IdentityProvider) => { - const params = forwardSearchParams(IDP.entityID); - const redirectUrl = `${ENV.URL_API.AUTHORIZE}?${params}`; - trackEvent( - 'LOGIN_IDP_SELECTED', - { - SPID_IDP_NAME: IDP.name, - SPID_IDP_ID: IDP.entityID, - FORWARD_PARAMETERS: params, - }, - () => window.location.assign(redirectUrl) - ); -}; - -export const SpidList = ({ idpList }: { idpList: IdentityProviders }) => - idpList.identityProviders.map((IDP, i) => ( - - - - )); - -const SpidSelect = ({ onBack, idpList }: Props) => { - const { t } = useTranslation(); - - return ( - - - - - - - - - - - {t('spidSelect.title')} - - - - - {idpList?.identityProviders?.length ? ( - - ) : ( - - {t('spidSelect.placeholder')} - - )} - - - - - - - - - ); -}; - -export default SpidSelect; diff --git a/src/oneid/oneid-ecs-core/src/main/webui/src/pages/login/components/CieButton/CieButton.test.tsx b/src/oneid/oneid-ecs-core/src/main/webui/src/pages/login/components/CieButton/CieButton.test.tsx new file mode 100644 index 00000000..26208260 --- /dev/null +++ b/src/oneid/oneid-ecs-core/src/main/webui/src/pages/login/components/CieButton/CieButton.test.tsx @@ -0,0 +1,36 @@ +import { render, screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { describe, it, vi } from 'vitest'; +import { CieButtonProps, CieButton } from '../CieButton'; +import { i18nTestSetup } from '../../../../__tests__/i18nTestSetup'; + +describe('CieButton', () => { + const BUTTON_TEXT = 'CIE Login'; + const onClickMock = vi.fn(); + + const renderComponent = (props: Partial = {}) => { + i18nTestSetup({ 'loginPage.loginBox.cieLogin': BUTTON_TEXT }); + render(); + }; + + it('renders the button with the correct text', () => { + renderComponent(); + expect( + screen.getByRole('button', { name: BUTTON_TEXT }) + ).toBeInTheDocument(); + }); + + it('displays the CIE icon', () => { + renderComponent(); + const icon = screen.getByAltText('CIE Icon'); + expect(icon).toBeInTheDocument(); + expect(icon).toHaveAttribute('src', expect.stringContaining('CIEIcon')); + }); + + it('calls the onClick handler when clicked', async () => { + renderComponent(); + const button = screen.getByRole('button', { name: BUTTON_TEXT }); + await userEvent.click(button); + expect(onClickMock).toHaveBeenCalledTimes(1); + }); +}); diff --git a/src/oneid/oneid-ecs-core/src/main/webui/src/pages/login/components/CieButton/index.tsx b/src/oneid/oneid-ecs-core/src/main/webui/src/pages/login/components/CieButton/index.tsx new file mode 100644 index 00000000..fab51ecf --- /dev/null +++ b/src/oneid/oneid-ecs-core/src/main/webui/src/pages/login/components/CieButton/index.tsx @@ -0,0 +1,33 @@ +import Icon from '@mui/material/Icon'; +import { useTranslation } from 'react-i18next'; +import Button from '@mui/material/Button'; +import CIEIcon from '../../../../assets/CIEIcon.svg'; + +export type CieButtonProps = { + onClick: () => void; +}; + +export const CieIconWrapper = () => ( + + CIE Icon + +); + +export const CieButton = ({ onClick }: CieButtonProps) => { + const { t } = useTranslation(); + + return ( + + ); +}; diff --git a/src/oneid/oneid-ecs-core/src/main/webui/src/pages/login/components/SpidButton/SpidButton.test.tsx b/src/oneid/oneid-ecs-core/src/main/webui/src/pages/login/components/SpidButton/SpidButton.test.tsx new file mode 100644 index 00000000..43ca6bf7 --- /dev/null +++ b/src/oneid/oneid-ecs-core/src/main/webui/src/pages/login/components/SpidButton/SpidButton.test.tsx @@ -0,0 +1,48 @@ +import { render, screen, fireEvent } from '@testing-library/react'; +import { vi } from 'vitest'; +import { SpidButton, SpidButtonProps } from '../SpidButton'; +import { i18nTestSetup } from '../../../../__tests__/i18nTestSetup'; + +const TEST_ID = 'spidButton'; + +describe('SpidButton Component', () => { + const onClickMock = vi.fn(); + + beforeAll(() => { + i18nTestSetup({ + 'loginPage.loginBox.spidLogin': 'Login with SPID', + }); + }); + + beforeEach(() => { + vi.clearAllMocks(); + }); + + const renderComponent = (props: SpidButtonProps) => + render(); + + it('renders the button with the correct text', () => { + renderComponent({ onClick: onClickMock }); + + const button = screen.getByTestId(TEST_ID); + expect(button).toBeInTheDocument(); + expect(button).toHaveTextContent('Login with SPID'); + }); + + it('triggers the onClick function when clicked', () => { + renderComponent({ onClick: onClickMock }); + + const button = screen.getByTestId(TEST_ID); + fireEvent.click(button); + + expect(onClickMock).toHaveBeenCalledOnce(); + }); + + it('displays the SPID icon', () => { + renderComponent({ onClick: onClickMock }); + + const icon = screen.getByAltText('SPID Icon'); + expect(icon).toBeInTheDocument(); + expect(icon).toHaveAttribute('src', expect.stringContaining('SpidIcon')); + }); +}); diff --git a/src/oneid/oneid-ecs-core/src/main/webui/src/pages/login/components/SpidButton/index.tsx b/src/oneid/oneid-ecs-core/src/main/webui/src/pages/login/components/SpidButton/index.tsx new file mode 100644 index 00000000..bd527deb --- /dev/null +++ b/src/oneid/oneid-ecs-core/src/main/webui/src/pages/login/components/SpidButton/index.tsx @@ -0,0 +1,46 @@ +import LoadingButton from '@mui/lab/LoadingButton'; +import Icon from '@mui/material/Icon'; +import Typography from '@mui/material/Typography'; +import { theme } from '@pagopa/mui-italia/dist/theme'; +import { useTranslation } from 'react-i18next'; +import SpidIcon from '../../../../assets/SpidIcon.svg'; + +export const SpidIconWrapper = () => ( + + SPID Icon + +); + +export type SpidButtonProps = { + onClick: () => void; +}; + +export const SpidButton = ({ onClick }: SpidButtonProps) => { + const { t } = useTranslation(); + + return ( + } + sx={{ + borderRadius: '4px', + width: '100%', + marginBottom: '5px', + }} + variant="contained" + > + + {t('loginPage.loginBox.spidLogin')} + + + ); +}; diff --git a/src/oneid/oneid-ecs-core/src/main/webui/src/pages/login/SpidModal.test.tsx b/src/oneid/oneid-ecs-core/src/main/webui/src/pages/login/components/SpidModal/SpidModal.test.tsx similarity index 83% rename from src/oneid/oneid-ecs-core/src/main/webui/src/pages/login/SpidModal.test.tsx rename to src/oneid/oneid-ecs-core/src/main/webui/src/pages/login/components/SpidModal/SpidModal.test.tsx index 4e182917..4e9e500b 100644 --- a/src/oneid/oneid-ecs-core/src/main/webui/src/pages/login/SpidModal.test.tsx +++ b/src/oneid/oneid-ecs-core/src/main/webui/src/pages/login/components/SpidModal/SpidModal.test.tsx @@ -1,12 +1,12 @@ import { render, screen, fireEvent } from '@testing-library/react'; -import { IdentityProviders } from '../../utils/IDPS'; -import { trackEvent } from '../../services/analyticsService'; -import { forwardSearchParams } from '../../utils/utils'; -import SpidModal from './SpidModal'; +import { IdentityProviders } from '../../../../utils/IDPS'; +import { trackEvent } from '../../../../services/analyticsService'; +import { forwardSearchParams } from '../../../../utils/utils'; +import SpidModal from '.././SpidModal'; -vi.mock('../../services/analyticsService'); -vi.mock('../../utils/utils', () => ({ +vi.mock('../../../../services/analyticsService'); +vi.mock('../../../../utils/utils', () => ({ forwardSearchParams: vi.fn(() => 'testParams'), })); @@ -25,6 +25,7 @@ describe('SpidModal', () => { it('renders the modal with identity providers', () => { render( void; + idpList?: IdentityProviders; + loading?: boolean; +}; + +export const NoProviders = () => { + const { t } = useTranslation(); + + return ( + + {t('spidSelect.placeholder')} + + ); +}; + +export const ContentSelection = ({ + idpList, +}: { + idpList?: IdentityProviders; +}) => { + const noSpidProvidersFound = !idpList?.identityProviders?.length; + + return noSpidProvidersFound ? ( + + ) : ( + + ); +}; + +const SpidModal = ({ + openSpidModal, + setOpenSpidModal, + idpList, + loading, +}: Props) => { + const { t } = useTranslation(); + + return ( + setOpenSpidModal(false)} + aria-busy={loading} + aria-live="polite" + > + + {t('spidSelect.modalTitle')} + + {loading ? : } + + + + + ); +}; + +export default SpidModal; diff --git a/src/oneid/oneid-ecs-core/src/main/webui/src/pages/login/SpidSelect.test.tsx b/src/oneid/oneid-ecs-core/src/main/webui/src/pages/login/components/SpidSelect/SpidSelect.test.tsx similarity index 84% rename from src/oneid/oneid-ecs-core/src/main/webui/src/pages/login/SpidSelect.test.tsx rename to src/oneid/oneid-ecs-core/src/main/webui/src/pages/login/components/SpidSelect/SpidSelect.test.tsx index fd57ce2f..3259c8e4 100644 --- a/src/oneid/oneid-ecs-core/src/main/webui/src/pages/login/SpidSelect.test.tsx +++ b/src/oneid/oneid-ecs-core/src/main/webui/src/pages/login/components/SpidSelect/SpidSelect.test.tsx @@ -1,16 +1,15 @@ +/* eslint-disable functional/immutable-data */ import { render, screen, fireEvent } from '@testing-library/react'; import { vi } from 'vitest'; -import { ENV } from '../../utils/env'; -import SpidSelect from './SpidSelect'; +import { ENV } from '../../../../utils/env'; +import SpidSelect from '../SpidSelect'; const oldWindowLocation = global.window.location; beforeAll(() => { - // eslint-disable-next-line functional/immutable-data Object.defineProperty(window, 'location', { value: { assign: vi.fn() } }); }); afterAll(() => { - // eslint-disable-next-line functional/immutable-data Object.defineProperty(window, 'location', { value: oldWindowLocation }); }); const idpList = { diff --git a/src/oneid/oneid-ecs-core/src/main/webui/src/pages/login/components/SpidSelect/index.tsx b/src/oneid/oneid-ecs-core/src/main/webui/src/pages/login/components/SpidSelect/index.tsx new file mode 100644 index 00000000..fe135a95 --- /dev/null +++ b/src/oneid/oneid-ecs-core/src/main/webui/src/pages/login/components/SpidSelect/index.tsx @@ -0,0 +1,76 @@ +import { Fragment } from 'react'; +import Grid from '@mui/material/Grid'; +import Typography from '@mui/material/Typography'; +import Button from '@mui/material/Button'; +import { useTranslation } from 'react-i18next'; +import { IdentityProviders } from '../../../../utils/IDPS'; +import SpidBig from '../../../../assets/spid_big.svg'; +import { SpidSkeleton } from '../SpidSkeleton'; +import { ContentSelection } from '../SpidModal'; + +type SpidSelectProps = { + onBack: () => void; + idpList?: IdentityProviders; + loading?: boolean; +}; + +const SpidSelect = ({ onBack, idpList, loading }: SpidSelectProps) => { + const { t } = useTranslation(); + + return ( + + + + + + + + + + + {t('spidSelect.title')} + + + {loading ? : } + + + + + + + ); +}; + +export default SpidSelect; diff --git a/src/oneid/oneid-ecs-core/src/main/webui/src/pages/login/components/SpidSelection.tsx b/src/oneid/oneid-ecs-core/src/main/webui/src/pages/login/components/SpidSelection.tsx new file mode 100644 index 00000000..7dbe7733 --- /dev/null +++ b/src/oneid/oneid-ecs-core/src/main/webui/src/pages/login/components/SpidSelection.tsx @@ -0,0 +1,69 @@ +import { Button, Grid, Icon } from '@mui/material'; +import { ImageWithFallback } from '../../../components/ImageFallback'; +import { IDP_PLACEHOLDER_IMG } from '../../../utils/constants'; +import { IdentityProvider } from '../../../utils/IDPS'; +import { trackEvent } from '../../../services/analyticsService'; +import { ENV } from '../../../utils/env'; +import { forwardSearchParams } from '../../../utils/utils'; + +export const getSPID = (IDP: IdentityProvider) => { + const params = forwardSearchParams(IDP.entityID); + const redirectUrl = `${ENV.URL_API.AUTHORIZE}?${params}`; + trackEvent( + 'LOGIN_IDP_SELECTED', + { + SPID_IDP_NAME: IDP.name, + SPID_IDP_ID: IDP.entityID, + FORWARD_PARAMETERS: params, + }, + () => window.location.assign(redirectUrl) + ); +}; + +export const SpidSelection = ({ + identityProviders, +}: { + identityProviders: Array; +}) => ( + + + {identityProviders?.map((IDP, i) => ( + + + + ))} + + +); diff --git a/src/oneid/oneid-ecs-core/src/main/webui/src/pages/login/components/SpidSkeleton.test.tsx b/src/oneid/oneid-ecs-core/src/main/webui/src/pages/login/components/SpidSkeleton.test.tsx new file mode 100644 index 00000000..f06c3034 --- /dev/null +++ b/src/oneid/oneid-ecs-core/src/main/webui/src/pages/login/components/SpidSkeleton.test.tsx @@ -0,0 +1,40 @@ +import { describe, it, expect } from 'vitest'; +import { render, screen } from '@testing-library/react'; +import { SpidSkeleton } from './SpidSkeleton'; + +describe('SpidSkeleton', () => { + it('renders the SpidSkeleton component with the correct structure', () => { + render(); + + // Check that the component has the correct role and aria-label + const skeletonContainer = screen.getByRole('status', { name: 'loading' }); + expect(skeletonContainer).toBeInTheDocument(); + + // Check that there are two primary Stack containers + const stackContainers = + skeletonContainer.querySelectorAll('.MuiStack-root'); + expect(stackContainers).toHaveLength(2); + + // Verify the presence of six Skeleton components (3 in each stack) + const skeletons = screen.getAllByRole('presentation'); + expect(skeletons).toHaveLength(6); + + // Check that each Skeleton component has the correct attributes + skeletons.forEach((skeleton) => { + expect(skeleton).toHaveAttribute('aria-busy', 'true'); + }); + }); + + it('has the correct styles for each Skeleton component', () => { + render(); + const skeletons = screen.getAllByRole('presentation'); + + skeletons.forEach((skeleton) => { + expect(skeleton).toHaveStyle({ + borderRadius: '4px', + height: '48px', + width: '148px', + }); + }); + }); +}); diff --git a/src/oneid/oneid-ecs-core/src/main/webui/src/pages/login/components/SpidSkeleton.tsx b/src/oneid/oneid-ecs-core/src/main/webui/src/pages/login/components/SpidSkeleton.tsx new file mode 100644 index 00000000..e422efd0 --- /dev/null +++ b/src/oneid/oneid-ecs-core/src/main/webui/src/pages/login/components/SpidSkeleton.tsx @@ -0,0 +1,35 @@ +import { Skeleton, Stack } from '@mui/material'; + +export const SpidSkeleton = () => { + const ImgSkeleton = () => ( + + ); + return ( + + + + + + + + + + + + + ); +}; diff --git a/src/oneid/oneid-ecs-core/src/main/webui/src/pages/login/Login.tsx b/src/oneid/oneid-ecs-core/src/main/webui/src/pages/login/index.tsx similarity index 54% rename from src/oneid/oneid-ecs-core/src/main/webui/src/pages/login/Login.tsx rename to src/oneid/oneid-ecs-core/src/main/webui/src/pages/login/index.tsx index 2ba45d8d..9e69343b 100644 --- a/src/oneid/oneid-ecs-core/src/main/webui/src/pages/login/Login.tsx +++ b/src/oneid/oneid-ecs-core/src/main/webui/src/pages/login/index.tsx @@ -1,51 +1,23 @@ -import { useEffect, useState } from 'react'; -import Button from '@mui/material/Button'; +import { useState } from 'react'; import Link from '@mui/material/Link'; import Grid from '@mui/material/Grid'; import Box from '@mui/material/Box'; -import Icon from '@mui/material/Icon'; import { Alert } from '@mui/material'; import Typography from '@mui/material/Typography'; import { Trans, useTranslation } from 'react-i18next'; import { theme } from '@pagopa/mui-italia'; import Layout from '../../components/Layout'; -import SpidIcon from '../../assets/SpidIcon.svg'; -import CIEIcon from '../../assets/CIEIcon.svg'; import { ENV } from '../../utils/env'; import { IDP_PLACEHOLDER_IMG } from '../../utils/constants'; import { trackEvent } from '../../services/analyticsService'; import { forwardSearchParams } from '../../utils/utils'; -import type { IdentityProvider, IdentityProviders } from '../../utils/IDPS'; import { ImageWithFallback } from '../../components/ImageFallback'; -import SpidSelect from './SpidSelect'; -import SpidModal from './SpidModal'; - -type BannerContent = { - enable: boolean; - severity: 'warning' | 'error' | 'info' | 'success'; - description: string; -}; - -type Client = { - clientID: string; - friendlyName: string; - logoUri: string; - policyUri: string; - tosUri: string; -}; - -export const SpidIconWrapper = () => ( - - - -); - -export const CieIconWrapper = () => ( - - - -); +import SpidModal from './components/SpidModal'; +import { useLoginData } from '../../hooks/useLoginData'; +import { SpidButton } from './components/SpidButton'; +import { CieButton } from './components/CieButton'; +import SpidSelect from './components/SpidSelect'; export const LinkWrapper = ({ onClick, @@ -55,7 +27,6 @@ export const LinkWrapper = ({ children?: React.ReactNode; }) => ( { - const [showIDPS, setShowIDPS] = useState(false); - const [bannerContent, setBannerContent] = useState>(); const [openSpidModal, setOpenSpidModal] = useState(false); - const [idpList, setIdpList] = useState({ - identityProviders: [], - richiediSpid: '', - }); - const [clientData, setClientData] = useState(); - - const mapToArray = (json: Record) => { - const mapped = Object.values(json); - setBannerContent(mapped as Array); - }; - - const alertMessage = async (loginBanner: string) => { - try { - const response = await fetch(loginBanner); - const res = await response.json(); - mapToArray(res); - } catch (error) { - console.error(error); - } - }; - - const getIdpList = async (idpListUrl: string) => { - try { - const response = await fetch(idpListUrl); - const res: Array = await response.json(); - const assetsIDPUrl = ENV.URL_FE.ASSETS + '/idps'; - const rawIDPS = res - .map((i) => ({ - ...i, - imageUrl: `${assetsIDPUrl}/${btoa(i.entityID)}.png`, - })) - .sort(() => 0.5 - Math.random()); - const IDPS: { - identityProviders: Array; - richiediSpid: string; - } = { - identityProviders: rawIDPS, - richiediSpid: 'https://www.spid.gov.it/cos-e-spid/come-attivare-spid/', - }; - setIdpList(IDPS); - } catch (error) { - console.error(error); - } - }; - - const getClientData = async (clientBaseListUrl: string) => { - try { - const query = new URLSearchParams(window.location.search); - const clientID = query.get('client_id'); - - if (clientID && clientID.match(/^[A-Za-z0-9_-]{43}$/)) { - const clientListUrl = `${clientBaseListUrl}/${clientID}`; - const response = await fetch(clientListUrl); - const res: Client = await response.json(); - setClientData(res); - } else { - console.warn('no client_id supplied, or not valid 32bit Base64Url'); - } - } catch (error) { - console.error(error); - } - }; - - useEffect(() => { - void alertMessage(ENV.JSON_URL.ALERT); - void getIdpList(ENV.JSON_URL.IDP_LIST); - void getClientData(ENV.JSON_URL.CLIENT_BASE_URL); - // eslint-disable-next-line react-hooks/exhaustive-deps - }, []); + const [showIDPS, setShowIDPS] = useState(false); + const { bannerQuery, clientQuery, idpQuery } = useLoginData(); const { t } = useTranslation(); + const columnsOccupiedByAlert = 5; + const goCIE = () => { const params = forwardSearchParams(ENV.SPID_CIE_ENTITY_ID); const redirectUrl = `${ENV.URL_API.AUTHORIZE}?${params}`; @@ -169,23 +73,27 @@ const Login = () => { const redirectPrivacyLink = () => trackEvent('LOGIN_PRIVACY', { SPID_IDP_NAME: 'LOGIN_PRIVACY' }, () => window.location.assign( - clientData?.policyUri || ENV.URL_FOOTER.PRIVACY_DISCLAIMER + clientQuery.data?.policyUri || ENV.URL_FOOTER.PRIVACY_DISCLAIMER ) ); const redirectToTOS = () => trackEvent('LOGIN_TOS', { SPID_IDP_NAME: 'LOGIN_TOS' }, () => window.location.assign( - clientData?.tosUri || ENV.URL_FOOTER.TERMS_AND_CONDITIONS + clientQuery.data?.tosUri || ENV.URL_FOOTER.TERMS_AND_CONDITIONS ) ); if (showIDPS) { - return ; + return ( + + ); } - const columnsOccupiedByAlert = 5; - return ( @@ -219,15 +127,15 @@ const Login = () => { - {clientData?.logoUri && ( - - + + + {clientQuery.isFetched && ( { maxHeight: '100px', objectFit: 'cover', }} - src={clientData?.logoUri} - alt={clientData?.friendlyName} + src={clientQuery.data?.logoUri} + alt={clientQuery.data?.friendlyName || 'PagoPa Logo'} placeholder={IDP_PLACEHOLDER_IMG} /> - + )} - )} + {ENV.ENABLED_SPID_TEMPORARY_SELECT && ( @@ -262,8 +170,8 @@ const Login = () => { )} - {bannerContent && - bannerContent.map( + {bannerQuery.isSuccess && + bannerQuery.data.map( (bc, index) => bc.enable && ( @@ -301,45 +209,14 @@ const Login = () => { - + setOpenSpidModal(true)} /> - + @@ -361,8 +238,16 @@ const Login = () => { privacyLink: `<1>${t('loginPage.privacyAndCondition.privacy')}`, }} components={[ - , - , + , + , ]} /> diff --git a/src/oneid/oneid-ecs-core/src/main/webui/src/services/api.test.ts b/src/oneid/oneid-ecs-core/src/main/webui/src/services/api.test.ts new file mode 100644 index 00000000..c659d376 --- /dev/null +++ b/src/oneid/oneid-ecs-core/src/main/webui/src/services/api.test.ts @@ -0,0 +1,142 @@ +/* eslint-disable sonarjs/no-duplicate-string */ +/* eslint-disable functional/immutable-data */ +import { describe, it, expect, vi, beforeEach, Mock } from 'vitest'; +import { ENV } from '../utils/env'; +import { IdentityProvider } from '../utils/IDPS'; +import { getIdpList, getClientData, fetchBannerContent } from './api'; + +vi.stubGlobal('fetch', vi.fn()); + +describe('Utils functions', () => { + beforeEach(() => { + vi.spyOn(global.Math, 'random').mockReturnValue(0.5); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + describe('getIdpList', () => { + const mockIDPList: Array> = [ + { entityID: 'idp1', name: 'IDP 1', identifier: 'idp-identifier-1' }, + { entityID: 'idp2', name: 'IDP 2', identifier: 'idp-identifier-2' }, + ]; + + it('returns a enhanced IDP list', async () => { + (global.fetch as Mock).mockResolvedValueOnce({ + ok: true, + json: async () => mockIDPList, + }); + + const result = await getIdpList('https://example.com/idp-list'); + const assetsIDPUrl = ENV.URL_FE.ASSETS + '/idps'; + + expect(result.identityProviders).toHaveLength(2); + expect(result.identityProviders[0]).toHaveProperty( + 'imageUrl', + `${assetsIDPUrl}/${btoa(mockIDPList[0].entityID)}.png` + ); + }); + + it('throws an error if the fetch fails', async () => { + (global.fetch as Mock).mockResolvedValueOnce({ + ok: false, + statusText: 'Not Found', + }); + + await expect(getIdpList('https://example.com/idp-list')).rejects.toThrow( + 'Failed to fetch IDP list: Not Found' + ); + }); + }); + + describe('getClientData', () => { + const clientID = '0000000000000000000000000000000000000000000'; + + const mockClientData = { + clientID: clientID, + friendlyName: 'Test Client', + logoUri: 'https://example.com/logo.png', + policyUri: 'https://example.com/policy', + tosUri: 'https://example.com/tos', + }; + + beforeEach(() => { + vi.stubGlobal('window', { + location: { search: `?client_id=${clientID}` }, + }); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + it('fetches client data successfully', async () => { + (global.fetch as Mock).mockResolvedValueOnce({ + ok: true, + json: async () => mockClientData, + }); + + const result = await getClientData('https://example.com/clients'); + expect(result).toEqual(mockClientData); + }); + + it('throws an error if client_id is invalid or missing', async () => { + vi.stubGlobal('window', { + location: { search: `?client_id=` }, + }); + + await expect( + getClientData('https://example.com/clients') + ).rejects.toThrow('Invalid or missing client_id'); + }); + + it('throws an error if the fetch fails', async () => { + (global.fetch as Mock).mockResolvedValueOnce({ + ok: false, + statusText: 'Unauthorized', + }); + + await expect( + getClientData('https://example.com/clients') + ).rejects.toThrow('Failed to fetch client data: Unauthorized'); + }); + }); + + describe('fetchBannerContent', () => { + const mockBannerContent = { + banner1: { + enable: true, + severity: 'info', + description: 'This is a test banner', + }, + banner2: { + enable: false, + severity: 'warning', + description: 'This is another test banner', + }, + }; + + it('returns an array of banner content', async () => { + (global.fetch as Mock).mockResolvedValueOnce({ + ok: true, + json: async () => mockBannerContent, + }); + + const result = await fetchBannerContent('https://example.com/banner'); + expect(result).toHaveLength(2); + expect(result[0]).toHaveProperty('description', 'This is a test banner'); + }); + + it('throws an error if the fetch fails', async () => { + (global.fetch as Mock).mockResolvedValueOnce({ + ok: false, + statusText: 'Forbidden', + }); + + await expect( + fetchBannerContent('https://example.com/banner') + ).rejects.toThrow('Failed to fetch banner content: Forbidden'); + }); + }); +}); diff --git a/src/oneid/oneid-ecs-core/src/main/webui/src/services/api.ts b/src/oneid/oneid-ecs-core/src/main/webui/src/services/api.ts new file mode 100644 index 00000000..b85aeb44 --- /dev/null +++ b/src/oneid/oneid-ecs-core/src/main/webui/src/services/api.ts @@ -0,0 +1,73 @@ +import { ENV } from '../utils/env'; +import { IdentityProvider } from '../utils/IDPS'; + +export type BannerContent = { + enable: boolean; + severity: 'warning' | 'error' | 'info' | 'success'; + description: string; +}; + +export type Client = { + clientID: string; + friendlyName: string; + logoUri: string; + policyUri: string; + tosUri: string; +}; + +export type IDPList = { + identityProviders: Array; + richiediSpid: string; +}; + +export const getIdpList = async (idpListUrl: string) => { + const response = await fetch(idpListUrl); + if (!response.ok) { + throw new Error(`Failed to fetch IDP list: ${response.statusText}`); + } + + const res: Array = await response.json(); + const assetsIDPUrl = ENV.URL_FE.ASSETS + '/idps'; + const rawIDPS = res + .map((i) => ({ + ...i, + imageUrl: `${assetsIDPUrl}/${btoa(i.entityID)}.png`, + })) + .sort(() => 0.5 - Math.random()); + + const out: IDPList = { + identityProviders: rawIDPS, + richiediSpid: 'https://www.spid.gov.it/cos-e-spid/come-attivare-spid/', + }; + + return out; +}; + +export const getClientData = async (clientBaseListUrl: string) => { + const query = new URLSearchParams(window.location.search); + const clientID = query.get('client_id'); + + if (!clientID || !clientID.match(/^[A-Za-z0-9_-]{43}$/)) { + throw new Error('Invalid or missing client_id'); + } + + const clientListUrl = `${clientBaseListUrl}/${clientID}`; + const response = await fetch(clientListUrl); + if (!response.ok) { + throw new Error(`Failed to fetch client data: ${response.statusText}`); + } + + return await response.json(); +}; + +export const fetchBannerContent = async ( + loginBannerUrl: string +): Promise> => { + const response = await fetch(loginBannerUrl); + if (!response.ok) { + throw new Error(`Failed to fetch banner content: ${response.statusText}`); + } + + const data = await response.json(); + return Object.values(data) as Array; +}; diff --git a/src/oneid/oneid-ecs-core/src/main/webui/yarn.lock b/src/oneid/oneid-ecs-core/src/main/webui/yarn.lock index 6fd21050..54a24cbb 100644 --- a/src/oneid/oneid-ecs-core/src/main/webui/yarn.lock +++ b/src/oneid/oneid-ecs-core/src/main/webui/yarn.lock @@ -984,6 +984,18 @@ resolved "https://registry.yarnpkg.com/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.23.0.tgz#54e3562ebd264ef5839f8091618310c40d43d8a9" integrity sha512-lqCK5GQC8fNo0+JvTSxcG7YB1UKYp8yrNLhsArlvPWN+16ovSZgoehlVHg6X0sSWPUkpjRBR5TuR12ZugowZ4g== +"@tanstack/query-core@5.62.2": + version "5.62.2" + resolved "https://registry.yarnpkg.com/@tanstack/query-core/-/query-core-5.62.2.tgz#4eef3201422f246788fb41d01662c2dea3136d9a" + integrity sha512-LcwVcC5qpsDpHcqlXUUL5o9SaOBwhNkGeV+B06s0GBoyBr8FqXPuXT29XzYXR36lchhnerp6XO+CWc84/vh7Zg== + +"@tanstack/react-query@^5.62.2": + version "5.62.2" + resolved "https://registry.yarnpkg.com/@tanstack/react-query/-/react-query-5.62.2.tgz#fbcb8f991ddcf484ce7968fb58bb4790d6c98cd3" + integrity sha512-fkTpKKfwTJtVPKVR+ag7YqFgG/7TRVVPzduPAUF9zRCiiA8Wu305u+KJl8rCrh98Qce77vzIakvtUyzWLtaPGA== + dependencies: + "@tanstack/query-core" "5.62.2" + "@testing-library/dom@^10.2.0": version "10.2.0" resolved "https://registry.yarnpkg.com/@testing-library/dom/-/dom-10.2.0.tgz#d3b22515bc0603a06f119c6ae6670669c3f2085f"