diff --git a/react/package-lock.json b/react/package-lock.json index 88d0fde5..12977a53 100644 --- a/react/package-lock.json +++ b/react/package-lock.json @@ -24,6 +24,7 @@ "eslint-plugin-prettier": "^4.2.1", "eslint-plugin-react-hooks": "^4.6.0", "formik": "^2.4.5", + "jwt-decode": "^4.0.0", "leaflet": "^1.9.3", "postcss-nesting": "^12.0.3", "prettier": "^2.7.1", @@ -3363,9 +3364,9 @@ } }, "node_modules/@tacc/core-styles": { - "version": "2.24.1", - "resolved": "https://registry.npmjs.org/@tacc/core-styles/-/core-styles-2.24.1.tgz", - "integrity": "sha512-agJZ3+86tfj+yM4z+hoW5nTT6gBJpi6kL6KASBVgm5lEiApIrPJN2BKaQDNKcht12sPBxyNtFIGtfGrPTESirQ==", + "version": "2.30.1", + "resolved": "https://registry.npmjs.org/@tacc/core-styles/-/core-styles-2.30.1.tgz", + "integrity": "sha512-VdtVaz41bBBUhPkIS+iXWVCbxoY5yTdm2PIU9hZBh2I9cJF4jbTAnpmxSGPRDMTYLCljDNP2scL1fLjwSY/8+Q==", "bin": { "core-styles": "src/cli.js" }, @@ -3379,11 +3380,12 @@ "js-yaml": "^4.1.0", "merge-lite": "^1.0.2", "node-cmd": "^5.0.0", - "postcss": "^8.4.18", + "postcss": "^8.4.38", "postcss-banner": "^4.0.1", "postcss-cli": "^10.0.0", "postcss-extend": "^1.0.5", "postcss-import": "^15.0.0", + "postcss-mixins": "^10.0.1", "postcss-preset-env": "^7.8.3", "postcss-replace": "^2.0.1" } @@ -5998,11 +6000,11 @@ } }, "node_modules/axios": { - "version": "1.6.7", - "resolved": "https://registry.npmjs.org/axios/-/axios-1.6.7.tgz", - "integrity": "sha512-/hDJGff6/c7u0hDkvkGxR/oy6CbCs8ziCsC7SqmhjfozqiJGc8Z11wrv9z9lYfY4K8l+H9TpjcMDX0xOZmx+RA==", + "version": "1.7.4", + "resolved": "https://registry.npmjs.org/axios/-/axios-1.7.4.tgz", + "integrity": "sha512-DukmaFRnY6AzAALSH4J2M3k6PkaC+MfaAGdEERRWcC9q3/TWQwLpHR8ZRLKTdQ3aBDL64EdluRDjJqKw+BPZEw==", "dependencies": { - "follow-redirects": "^1.15.4", + "follow-redirects": "^1.15.6", "form-data": "^4.0.0", "proxy-from-env": "^1.1.0" } @@ -6266,11 +6268,11 @@ } }, "node_modules/braces": { - "version": "3.0.2", - "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.2.tgz", - "integrity": "sha512-b8um+L1RzM3WDSzvhm6gIz1yfTbBt6YTlcEKAvsmqCZZFw46z626lVj9j1yEPW33H5H+lBQpZMP1k8l+78Ha0A==", + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz", + "integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==", "dependencies": { - "fill-range": "^7.0.1" + "fill-range": "^7.1.1" }, "engines": { "node": ">=8" @@ -6384,6 +6386,15 @@ "node": ">=6" } }, + "node_modules/camelcase-css": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/camelcase-css/-/camelcase-css-2.0.1.tgz", + "integrity": "sha512-QOSvevhslijgYwRx6Rv7zKdMF8lbRmx+uQGx2+vDc+KI/eBnsy9kit5aj23AgGu3pa4t9AgwbnXWqS+iOY+2aA==", + "peer": true, + "engines": { + "node": ">= 6" + } + }, "node_modules/caniuse-api": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/caniuse-api/-/caniuse-api-3.0.0.tgz", @@ -8483,9 +8494,9 @@ } }, "node_modules/fill-range": { - "version": "7.0.1", - "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.0.1.tgz", - "integrity": "sha512-qOo9F+dMUmC2Lcb4BbVvnKJxTPjCm+RRpe4gDuGrzkL7mEVl/djYSu2OdQ2Pa302N4oqkSg9ir6jaLWJ2USVpQ==", + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", + "integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==", "dependencies": { "to-regex-range": "^5.0.1" }, @@ -8527,9 +8538,9 @@ "integrity": "sha512-X8cqMLLie7KsNUDSdzeN8FYK9rEt4Dt67OsG/DNGnYTSDBG4uFAJFBnUeiV+zCVAvwFy56IjM9sH51jVaEhNxw==" }, "node_modules/follow-redirects": { - "version": "1.15.5", - "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.5.tgz", - "integrity": "sha512-vSFWUON1B+yAw1VN4xMfxgn5fTUiaOzAJCKBwIIgT/+7CuGy9+r+5gITvP62j3RmaD5Ph65UaERdOSRGUzZtgw==", + "version": "1.15.6", + "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.6.tgz", + "integrity": "sha512-wWN62YITEaOpSK584EZXJafH1AGpO8RVgElfkuXbTOrPX4fIfOyEpW/CsiNd8JdYrAoOvafRTOEnvsO++qCqFA==", "funding": [ { "type": "individual", @@ -11739,6 +11750,14 @@ "node": ">=4.0" } }, + "node_modules/jwt-decode": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/jwt-decode/-/jwt-decode-4.0.0.tgz", + "integrity": "sha512-+KJGIyHgkGuIq3IEBNftfhW/LfWhXUIY6OmyVWjliu5KH1y0fw7VQ8YndE2O4qZdMSd9SqbnC8GOcZEy0Om7sA==", + "engines": { + "node": ">=18" + } + }, "node_modules/kdbush": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/kdbush/-/kdbush-3.0.0.tgz", @@ -12545,9 +12564,9 @@ } }, "node_modules/picocolors": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.0.0.tgz", - "integrity": "sha512-1fygroTLlHu66zi26VoTDv8yRgm0Fccecssto+MhsZ0D/DGW2sm8E8AjW7NU5VVTRt5GxbeZ5qBuJr+HyLYkjQ==" + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.0.1.tgz", + "integrity": "sha512-anP1Z8qwhkbmu7MFP5iTt+wQKXgwzf7zTyGlcdzabySa9vd0Xt392U0rVmz9poOaBj0uHJKyyo9/upk0HrEQew==" }, "node_modules/picomatch": { "version": "2.3.1", @@ -12670,9 +12689,9 @@ } }, "node_modules/postcss": { - "version": "8.4.35", - "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.35.tgz", - "integrity": "sha512-u5U8qYpBCpN13BsiEB0CbR1Hhh4Gc0zLFuedrHJKMctHCHAGrMdG0PRM/KErzAL3CU6/eckEtmHNB3x6e3c0vA==", + "version": "8.4.41", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.41.tgz", + "integrity": "sha512-TesUflQ0WKZqAvg52PWL6kHgLKP6xB6heTOdoYM0Wt2UHyxNa4K25EZZMgKns3BH1RLVbZCREPpLY0rhnNoHVQ==", "funding": [ { "type": "opencollective", @@ -12689,8 +12708,8 @@ ], "dependencies": { "nanoid": "^3.3.7", - "picocolors": "^1.0.0", - "source-map-js": "^1.0.2" + "picocolors": "^1.0.1", + "source-map-js": "^1.2.0" }, "engines": { "node": "^10 || ^12 || >=14" @@ -13283,6 +13302,25 @@ "postcss": "^8.0.0" } }, + "node_modules/postcss-js": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/postcss-js/-/postcss-js-4.0.1.tgz", + "integrity": "sha512-dDLF8pEO191hJMtlHFPRa8xsizHaM82MLfNkUHdUtVEV3tgTp5oj+8qbEqYM57SLfc74KSbw//4SeJma2LRVIw==", + "peer": true, + "dependencies": { + "camelcase-css": "^2.0.1" + }, + "engines": { + "node": "^12 || ^14 || >= 16" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + "peerDependencies": { + "postcss": "^8.4.21" + } + }, "node_modules/postcss-lab-function": { "version": "4.2.1", "resolved": "https://registry.npmjs.org/postcss-lab-function/-/postcss-lab-function-4.2.1.tgz", @@ -13484,6 +13522,34 @@ "postcss": "^8.2.15" } }, + "node_modules/postcss-mixins": { + "version": "10.0.1", + "resolved": "https://registry.npmjs.org/postcss-mixins/-/postcss-mixins-10.0.1.tgz", + "integrity": "sha512-5+cI9r8L5ChegVsLM9pXa53Ft03Mt9xAq+kvzqfrUHGPCArVGOfUvmQK2CLP3XWWP2dqxDLQI+lIcXG+GTqOBQ==", + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "peer": true, + "dependencies": { + "fast-glob": "^3.3.2", + "postcss-js": "^4.0.1", + "postcss-simple-vars": "^7.0.1", + "sugarss": "^4.0.1" + }, + "engines": { + "node": "^18.0 || >= 20.0" + }, + "peerDependencies": { + "postcss": "^8.2.14" + } + }, "node_modules/postcss-nesting": { "version": "12.0.4", "resolved": "https://registry.npmjs.org/postcss-nesting/-/postcss-nesting-12.0.4.tgz", @@ -13963,6 +14029,22 @@ "node": ">=4" } }, + "node_modules/postcss-simple-vars": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/postcss-simple-vars/-/postcss-simple-vars-7.0.1.tgz", + "integrity": "sha512-5GLLXaS8qmzHMOjVxqkk1TZPf1jMqesiI7qLhnlyERalG0sMbHIbJqrcnrpmZdKCLglHnRHoEBB61RtGTsj++A==", + "peer": true, + "engines": { + "node": ">=14.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + "peerDependencies": { + "postcss": "^8.2.1" + } + }, "node_modules/postcss-svgo": { "version": "5.1.0", "resolved": "https://registry.npmjs.org/postcss-svgo/-/postcss-svgo-5.1.0.tgz", @@ -14947,9 +15029,9 @@ } }, "node_modules/source-map-js": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.0.2.tgz", - "integrity": "sha512-R0XvVJ9WusLiqTCEiGCmICCMplcCkIwwR11mOSD9CR5u+IXYdiseeEuXCVAjS54zqwkLcPNnmU4OeJ6tUrWhDw==", + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.0.tgz", + "integrity": "sha512-itJW8lvSA0TXEphiRoawsCksnlf8SyvmFzIhltqAHluXd88pkCd+cXJVHTDwdCr0IzwptSm035IHQktUu1QUMg==", "engines": { "node": ">=0.10.0" } @@ -15170,6 +15252,22 @@ "postcss": "^8.2.15" } }, + "node_modules/sugarss": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/sugarss/-/sugarss-4.0.1.tgz", + "integrity": "sha512-WCjS5NfuVJjkQzK10s8WOBY+hhDxxNt/N6ZaGwxFZ+wN3/lKKFSaaKUNecULcTTvE4urLcKaZFQD8vO0mOZujw==", + "peer": true, + "engines": { + "node": ">=12.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + "peerDependencies": { + "postcss": "^8.3.3" + } + }, "node_modules/supercluster": { "version": "7.1.5", "resolved": "https://registry.npmjs.org/supercluster/-/supercluster-7.1.5.tgz", @@ -15770,9 +15868,9 @@ } }, "node_modules/vite": { - "version": "3.2.8", - "resolved": "https://registry.npmjs.org/vite/-/vite-3.2.8.tgz", - "integrity": "sha512-EtQU16PLIJpAZol2cTLttNP1mX6L0SyI0pgQB1VOoWeQnMSvtiwovV3D6NcjN8CZQWWyESD2v5NGnpz5RvgOZA==", + "version": "3.2.10", + "resolved": "https://registry.npmjs.org/vite/-/vite-3.2.10.tgz", + "integrity": "sha512-Dx3olBo/ODNiMVk/cA5Yft9Ws+snLOXrhLtrI3F4XLt4syz2Yg8fayZMWScPKoz12v5BUv7VEmQHnsfpY80fYw==", "dev": true, "dependencies": { "esbuild": "^0.15.9", @@ -16052,9 +16150,9 @@ } }, "node_modules/ws": { - "version": "8.16.0", - "resolved": "https://registry.npmjs.org/ws/-/ws-8.16.0.tgz", - "integrity": "sha512-HS0c//TP7Ina87TfiPUz1rQzMhHrl/SG2guqRcTOIUYD2q8uhUdNHZYJUaQ8aTGPzCh+c6oawMKW35nFl1dxyQ==", + "version": "8.18.0", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.18.0.tgz", + "integrity": "sha512-8VbfWfHLbbwu3+N6OKsOMpBdT4kXPDDB9cJk2bJ6mh9ucxdlnNvH1e+roYkKmN9Nxw2yjz7VzeO9oOz2zJ04Pw==", "dev": true, "engines": { "node": ">=10.0.0" diff --git a/react/package.json b/react/package.json index 2256fc7b..c6a9bd14 100644 --- a/react/package.json +++ b/react/package.json @@ -48,6 +48,7 @@ "eslint-plugin-prettier": "^4.2.1", "eslint-plugin-react-hooks": "^4.6.0", "formik": "^2.4.5", + "jwt-decode": "^4.0.0", "leaflet": "^1.9.3", "postcss-nesting": "^12.0.3", "prettier": "^2.7.1", diff --git a/react/src/__fixtures__/appConfigurationFixture.ts b/react/src/__fixtures__/appConfigurationFixture.ts index 933dbf37..374d360b 100644 --- a/react/src/__fixtures__/appConfigurationFixture.ts +++ b/react/src/__fixtures__/appConfigurationFixture.ts @@ -4,9 +4,6 @@ import { MapillaryConfiguration, } from '../types'; -const clientId = 'abc_123_JWT'; -const jwtId = 'abc_123_client_id'; - export const mapillaryConfig: MapillaryConfiguration = { authUrl: 'https://www.mapillary.com/connect', tokenUrl: 'https://graph.mapillary.com/token', @@ -21,12 +18,9 @@ export const mapillaryConfig: MapillaryConfiguration = { export const localDevConfiguration: AppConfiguration = { basePath: '/', - clientId: clientId, geoapiBackend: GeoapiBackendEnvironment.Local, geoapiUrl: 'http://localhost:8888', - designSafeUrl: 'https://agave.designsafe-ci.org/', - designsafePortalUrl: 'https://designsafeci-dev.tacc.utexas.edu/', + designsafePortalUrl: 'https://designsafeci-dev.tacc.utexas.edu', mapillary: mapillaryConfig, taggitUrl: 'http://localhost:4200/taggit-staging', - jwt: jwtId, }; diff --git a/react/src/__fixtures__/authStateFixtures.ts b/react/src/__fixtures__/authStateFixtures.ts index fdc63fe9..4d6215b1 100644 --- a/react/src/__fixtures__/authStateFixtures.ts +++ b/react/src/__fixtures__/authStateFixtures.ts @@ -1,13 +1,15 @@ import { AuthState } from '../types'; +// Convert the timestamp to a Date object +const expiresAtDate = new Date(3153600000000); //2070 + export const authenticatedUser: AuthState = { user: { username: 'user', - email: 'user@user.com', }, authToken: { token: 'auth-token', - expires: 3153600000000, // 2070 + expiresAt: expiresAtDate.toISOString(), }, }; diff --git a/react/src/components/CreateMapModal/CreateMapModal.test.tsx b/react/src/components/CreateMapModal/CreateMapModal.test.tsx index 585e99e0..c8beaa2d 100644 --- a/react/src/components/CreateMapModal/CreateMapModal.test.tsx +++ b/react/src/components/CreateMapModal/CreateMapModal.test.tsx @@ -6,14 +6,16 @@ import { screen, waitFor, } from '@testing-library/react'; + import CreateMapModal from './CreateMapModal'; import { BrowserRouter as Router } from 'react-router-dom'; +import { act } from 'react-dom/test-utils'; import { QueryClient, QueryClientProvider } from 'react-query'; jest.mock('../../hooks/user/useAuthenticatedUser', () => ({ __esModule: true, default: () => ({ - data: { username: 'mockUser', email: 'mockUser@example.com' }, + data: { username: 'mockUser' }, isLoading: false, error: null, }), @@ -45,14 +47,16 @@ jest.mock('react-router-dom', () => ({ const toggleMock = jest.fn(); const queryClient = new QueryClient(); -const renderComponent = (isOpen = true) => { - render( - - - - - - ); +const renderComponent = async (isOpen = true) => { + await act(async () => { + render( + + + + + + ); + }); }; describe('CreateMapModal', () => { @@ -60,23 +64,27 @@ describe('CreateMapModal', () => { cleanup(); }); - test('renders the modal when open', () => { - renderComponent(); - expect(screen.getByText(/Create a New Map/)).toBeTruthy(); + test('renders the modal when open', async () => { + await renderComponent(); + await waitFor(() => { + expect(screen.getByText(/Create a New Map/)).toBeTruthy(); + }); }); test('submits form data successfully', async () => { - renderComponent(); - fireEvent.change(screen.getByTestId('name-input'), { - target: { value: 'Success Map' }, + await renderComponent(); + await act(async () => { + fireEvent.change(screen.getByTestId('name-input'), { + target: { value: 'Success Map' }, + }); + fireEvent.change(screen.getByLabelText(/Description/), { + target: { value: 'A successful map' }, + }); + fireEvent.change(screen.getByLabelText(/Custom File Name/), { + target: { value: 'success-file' }, + }); + fireEvent.click(screen.getByRole('button', { name: /Create/ })); }); - fireEvent.change(screen.getByLabelText(/Description/), { - target: { value: 'A successful map' }, - }); - fireEvent.change(screen.getByLabelText(/Custom File Name/), { - target: { value: 'success-file' }, - }); - fireEvent.click(screen.getByRole('button', { name: /Create/ })); await waitFor(() => { expect(mockNavigate).toHaveBeenCalledWith('/project/123'); @@ -84,17 +92,19 @@ describe('CreateMapModal', () => { }); test('displays error message on submission error', async () => { - renderComponent(); - fireEvent.change(screen.getByTestId('name-input'), { - target: { value: 'Error Map' }, - }); - fireEvent.change(screen.getByLabelText(/Description/), { - target: { value: 'A map with an error' }, - }); - fireEvent.change(screen.getByLabelText(/Custom File Name/), { - target: { value: 'error-file' }, + await renderComponent(); + await act(async () => { + fireEvent.change(screen.getByTestId('name-input'), { + target: { value: 'Error Map' }, + }); + fireEvent.change(screen.getByLabelText(/Description/), { + target: { value: 'A map with an error' }, + }); + fireEvent.change(screen.getByLabelText(/Custom File Name/), { + target: { value: 'error-file' }, + }); + fireEvent.click(screen.getByRole('button', { name: /Create/ })); }); - fireEvent.click(screen.getByRole('button', { name: /Create/ })); await waitFor(() => { expect( diff --git a/react/src/components/Projects/ProjectListing.tsx b/react/src/components/Projects/ProjectListing.tsx index c082f9a3..7f5100f8 100644 --- a/react/src/components/Projects/ProjectListing.tsx +++ b/react/src/components/Projects/ProjectListing.tsx @@ -7,9 +7,15 @@ import { } from '../../hooks'; import { Button, LoadingSpinner } from '../../core-components'; import CreateMapModal from '../CreateMapModal/CreateMapModal'; +import { useNavigate } from 'react-router-dom'; export const ProjectListing: React.FC = () => { const [isModalOpen, setIsModalOpen] = useState(false); + const navigate = useNavigate(); + + const navigateToProject = (projectId) => { + navigate(`/project/${projectId}`); + }; const toggleModal = () => { setIsModalOpen(!isModalOpen); @@ -49,7 +55,7 @@ export const ProjectListing: React.FC = () => { {projectsData?.map((proj) => ( - + navigateToProject(proj.uuid)}> {proj.name} {proj.ds_project_id} {proj.ds_project_title} diff --git a/react/src/constants/routes.ts b/react/src/constants/routes.ts index 274aab65..8599525b 100644 --- a/react/src/constants/routes.ts +++ b/react/src/constants/routes.ts @@ -3,5 +3,5 @@ export const LOGIN = '/login'; export const LOGOUT = '/logout'; export const PUBLIC_PROJECT = '/project-public/:projectUUID'; export const PROJECT = '/project/:projectUUID'; -export const CALLBACK = '/callback'; +export const CALLBACK = '/handle-login'; export const STREETVIEW_CALLBACK = '/streetview/callback'; diff --git a/react/src/hooks/environment/useAppConfiguration.ts b/react/src/hooks/environment/useAppConfiguration.ts index 59d34341..27089ee0 100644 --- a/react/src/hooks/environment/useAppConfiguration.ts +++ b/react/src/hooks/environment/useAppConfiguration.ts @@ -18,12 +18,14 @@ function getGeoapiUrl(backend: GeoapiBackendEnvironment): string { switch (backend) { case GeoapiBackendEnvironment.Local: return 'http://localhost:8888'; + case GeoapiBackendEnvironment.Experimental: + return 'https://hazmapper.tacc.utexas.edu/geoapi-experimental'; case GeoapiBackendEnvironment.Dev: - return 'https://agave.designsafe-ci.org/geo-dev/v2'; + return 'https://hazmapper.tacc.utexas.edu/geoapi-dev'; case GeoapiBackendEnvironment.Staging: - return 'https://agave.designsafe-ci.org/geo-staging/v2'; + return 'https://hazmapper.tacc.utexas.edu/geoapi-staging'; case GeoapiBackendEnvironment.Production: - return 'https://agave.designsafe-ci.org/geo/v2'; + return 'https://hazmapper.tacc.utexas.edu/geoapi'; default: throw new Error( 'Unsupported TARGET/GEOAPI_BACKEND Type. Please check the .env file.' @@ -36,9 +38,13 @@ function getGeoapiUrl(backend: GeoapiBackendEnvironment): string { */ function getDesignsafePortalUrl(backend: DesignSafePortalEnvironment): string { if (backend === DesignSafePortalEnvironment.Production) { - return 'https://www.designsafe-ci.org/'; + return 'https://www.designsafe-ci.org'; + } else if (backend === DesignSafePortalEnvironment.Next) { + return 'https://designsafeci-next.tacc.utexas.edu'; + } else if (backend === DesignSafePortalEnvironment.Dev) { + return 'https://designsafeci-dev.tacc.utexas.edu'; } else { - return 'https://designsafeci-dev.tacc.utexas.edu/'; + throw new Error('Unsupported DS environment'); } } @@ -62,40 +68,15 @@ export const useAppConfiguration = (): AppConfiguration => { }; if (/^localhost/.test(hostname) || /^hazmapper.local/.test(hostname)) { - // Check if jwt has been set properly if we are using local geoapi - if ( - localDevelopmentConfiguration.geoapiBackend === - GeoapiBackendEnvironment.Local - ) { - if ( - localDevelopmentConfiguration.jwt.startsWith('INSERT YOUR JWT HERE') - ) { - console.error( - 'JWT has not been added to secret_local.ts; see README' - ); - throw new Error('JWT has not been added to secret_local.ts'); - } - } - - // local devevelopers can use localhost or hazmapper.local but - // hazmapper.local has been preferred in the past as TAPIS only supported it as a frame ancestor - // then (i.e. it allows for point cloud iframe preview) - const clientId = /^localhost/.test(hostname) - ? 'XgCBlhfAaqfv7jTu3NRc4IJDGdwa' - : 'Eb9NCCtWkZ83c01UbIAITFvhD9ka'; - const appConfig: AppConfiguration = { basePath: basePath, - clientId: clientId, geoapiBackend: localDevelopmentConfiguration.geoapiBackend, geoapiUrl: getGeoapiUrl(localDevelopmentConfiguration.geoapiBackend), - designSafeUrl: 'https://agave.designsafe-ci.org/', designsafePortalUrl: getDesignsafePortalUrl( DesignSafePortalEnvironment.Dev ), mapillary: mapillaryConfig, taggitUrl: origin + '/taggit-staging', - jwt: localDevelopmentConfiguration.jwt, }; appConfig.mapillary.clientId = '5156692464392931'; appConfig.mapillary.clientSecret = @@ -107,15 +88,10 @@ export const useAppConfiguration = (): AppConfiguration => { /^hazmapper.tacc.utexas.edu/.test(hostname) && pathname.startsWith('/staging') ) { - const clientId = basePath.includes('react') - ? 'AhV_h3Ilvrfs1S2Cj10yj82G0Uoa' // "staging-react" client - : 'foitdqFcimPzKZuMhbQ1oyh3Anka'; // "staging client" client const appConfig: AppConfiguration = { basePath: basePath, - clientId: clientId, geoapiBackend: GeoapiBackendEnvironment.Staging, geoapiUrl: getGeoapiUrl(GeoapiBackendEnvironment.Staging), - designSafeUrl: 'https://agave.designsafe-ci.org/', designsafePortalUrl: getDesignsafePortalUrl( DesignSafePortalEnvironment.Dev ), @@ -133,15 +109,10 @@ export const useAppConfiguration = (): AppConfiguration => { /^hazmapper.tacc.utexas.edu/.test(hostname) && pathname.startsWith('/dev') ) { - const clientId = basePath.includes('react') - ? '9rWjQLiJb0XPXHicmUh1RUq6rOEa' // "react-dev" client - : 'oEuGsl7xi015wnrEpxIeUmvzc6Qa'; // "dev" client const appConfig: AppConfiguration = { basePath: basePath, - clientId: clientId, geoapiBackend: GeoapiBackendEnvironment.Dev, geoapiUrl: getGeoapiUrl(GeoapiBackendEnvironment.Dev), - designSafeUrl: 'https://agave.designsafe-ci.org/', designsafePortalUrl: getDesignsafePortalUrl( DesignSafePortalEnvironment.Dev ), @@ -157,15 +128,10 @@ export const useAppConfiguration = (): AppConfiguration => { 'MLY|4936281379826603|f8c4732d3c9d96582b86158feb1c1a7a'; return appConfig; } else if (/^hazmapper.tacc.utexas.edu/.test(hostname)) { - const clientId = basePath.includes('react') - ? 'XEMnINR8b8hA6kFxE69HVTyoNCga' // "hazmapper-react" client - : 'tMvAiRdcsZ52S_89lCkO4x3d6VMa'; // "hazmapper" client const appConfig: AppConfiguration = { basePath: basePath, - clientId: clientId, geoapiBackend: GeoapiBackendEnvironment.Production, geoapiUrl: getGeoapiUrl(GeoapiBackendEnvironment.Production), - designSafeUrl: 'https://agave.designsafe-ci.org/', designsafePortalUrl: getDesignsafePortalUrl( DesignSafePortalEnvironment.Production ), diff --git a/react/src/hooks/index.ts b/react/src/hooks/index.ts index 859181ec..6f530573 100644 --- a/react/src/hooks/index.ts +++ b/react/src/hooks/index.ts @@ -4,3 +4,4 @@ export { useTileServers } from './tileServers/useTileServers'; export { default as useSystems } from './systems/useSystems'; export * from './environment'; export * from './projects'; +export * from './user'; diff --git a/react/src/hooks/projects/useProjects.ts b/react/src/hooks/projects/useProjects.ts index 10a22ec2..c37787e4 100644 --- a/react/src/hooks/projects/useProjects.ts +++ b/react/src/hooks/projects/useProjects.ts @@ -39,7 +39,7 @@ export const useProject = ({ export const useDsProjects = (): UseQueryResult => { const query = useGet({ - endpoint: `projects/v2/`, + endpoint: `/api/projects/v2/`, key: ['projectsv2'], apiService: ApiService.DesignSafe, }); diff --git a/react/src/hooks/systems/useSystems.ts b/react/src/hooks/systems/useSystems.ts index 4732cebd..99ddf609 100644 --- a/react/src/hooks/systems/useSystems.ts +++ b/react/src/hooks/systems/useSystems.ts @@ -4,9 +4,9 @@ import { useGet } from '../../requests'; const useSystems = (): UseQueryResult => { return useGet({ - endpoint: '/systems/v2?type=STORAGE', - key: ['systemsv2'], - apiService: ApiService.DesignSafe, + endpoint: '/v3/systems/?listType=ALL', + key: ['systemsv3'], + apiService: ApiService.Tapis, transform: (data) => data.result, }); }; diff --git a/react/src/hooks/user/index.ts b/react/src/hooks/user/index.ts new file mode 100644 index 00000000..aede95c7 --- /dev/null +++ b/react/src/hooks/user/index.ts @@ -0,0 +1,2 @@ +export { useEnsureAuthenticatedUserHasValidTapisToken } from './useEnsureAuthenticatedUserHasValidTapisToken'; +export { default as useAuthenticatedUser } from './useAuthenticatedUser'; diff --git a/react/src/hooks/user/useAuthenticatedUser.ts b/react/src/hooks/user/useAuthenticatedUser.ts index 46754c8e..d884c411 100644 --- a/react/src/hooks/user/useAuthenticatedUser.ts +++ b/react/src/hooks/user/useAuthenticatedUser.ts @@ -1,17 +1,22 @@ -import { UseQueryResult } from 'react-query'; -import { ApiService, AuthenticatedUser } from '../../types'; -import { useGet } from '../../requests'; +import { useSelector } from 'react-redux'; +import { RootState } from '../../redux/store'; +import { AuthenticatedUser } from '../../types'; -const useAuthenticatedUser = (): UseQueryResult => { - return useGet({ - endpoint: '/oauth2/userinfo?schema=openid', - key: ['username'], - apiService: ApiService.Tapis, - transform: (data) => ({ - username: data.name, - email: data.email, - }), - }); +type SuccessResult = { + data: T; + isLoading: false; + error: null; +}; + +// TODO remove this placeholder hook +const useAuthenticatedUser = (): SuccessResult => { + let username = useSelector((state: RootState) => state.auth.user?.username); + + if (!username) { + username = ''; + } + + return { data: { username }, isLoading: false, error: null }; }; export default useAuthenticatedUser; diff --git a/react/src/hooks/user/useEnsureAuthenticatedUserHasValidTapisToken.ts b/react/src/hooks/user/useEnsureAuthenticatedUserHasValidTapisToken.ts new file mode 100644 index 00000000..be795b28 --- /dev/null +++ b/react/src/hooks/user/useEnsureAuthenticatedUserHasValidTapisToken.ts @@ -0,0 +1,15 @@ +import { useNavigate, useLocation } from 'react-router-dom'; +import { useSelector } from 'react-redux'; +import { isTokenValid } from '../../utils/authUtils'; +import { RootState } from '../../redux/store'; + +export function useEnsureAuthenticatedUserHasValidTapisToken() { + const navigate = useNavigate(); + const location = useLocation(); + const authToken = useSelector((state: RootState) => state.auth.authToken); + + // if user has auth token, ensure its valid and if not, redirect to login + if (authToken && !isTokenValid(authToken)) { + navigate(`/login?to=${encodeURIComponent(location.pathname)}`); + } +} diff --git a/react/src/pages/Callback/Callback.tsx b/react/src/pages/Callback/Callback.tsx index 26fa78a9..63913b08 100644 --- a/react/src/pages/Callback/Callback.tsx +++ b/react/src/pages/Callback/Callback.tsx @@ -1,7 +1,9 @@ import React, { useEffect } from 'react'; import { useLocation, useNavigate } from 'react-router-dom'; import { useDispatch } from 'react-redux'; -import { loginSuccess, logout } from '../../redux/authSlice'; +import { jwtDecode } from 'jwt-decode'; +import { loginSuccess } from '../../redux/authSlice'; +import { AuthenticatedUser, AuthToken } from '../../types'; export default function CallbackPage() { const dispatch = useDispatch(); @@ -9,31 +11,23 @@ export default function CallbackPage() { const navigate = useNavigate(); useEffect(() => { - // Parse the query parameters from the URL - const params = new URLSearchParams(location.hash.slice(1)); - - // Check the state value against the expected value - const state = params.get('state'); - const expectedState = localStorage.getItem('authState'); - if (state !== expectedState) { - console.error('State for callback is incorrect. Send to login'); + /* TODO use hash instead of search https://tacc-main.atlassian.net/browse/WG-367 */ + const params = new URLSearchParams(location.search); + const redirectTo = localStorage.getItem('toParam') || '/'; + const token = params.get('access_token'); + const expiresAt = params.get('expires_at'); - // Redirect the user to the login page - dispatch(logout()); - return; - } + if (token && expiresAt) { + const username = jwtDecode(token)['tapis/username']; - const redirectTo = localStorage.getItem('toParam') || '/'; + const authToken: AuthToken = { token, expiresAt }; + const user: AuthenticatedUser = { username }; - const token = params.get('access_token'); - const expiresIn = params.get('expires_in'); - if (token && expiresIn) { - const expires = Date.now() + parseInt(expiresIn) * 1000; - // Save the token to the Redux store - dispatch(loginSuccess({ token, expires })); + // Save the token/username to the Redux store + dispatch(loginSuccess({ user, authToken })); navigate(redirectTo); } - }, [dispatch]); + }, [dispatch, location, navigate]); - return
Logging in...
; + return
Logging in.
; } diff --git a/react/src/pages/Login/Login.test.tsx b/react/src/pages/Login/Login.test.tsx index 5bac8d82..f7a13d8c 100644 --- a/react/src/pages/Login/Login.test.tsx +++ b/react/src/pages/Login/Login.test.tsx @@ -7,6 +7,23 @@ import { QueryClientProvider } from 'react-query'; import { testQueryClient } from '../../testUtil'; import { MemoryRouter } from 'react-router'; +beforeAll(() => { + const mockLocation = { + href: 'http://localhost:4200/login', + hostname: 'localhost', + pathname: '/login', + assign: jest.fn(), + replace: jest.fn(), + // You can add other properties if needed + }; + + jest.spyOn(window, 'location', 'get').mockReturnValue(mockLocation as any); +}); + +afterAll(() => { + jest.restoreAllMocks(); // Restore the original window.location after tests +}); + test('renders login', async () => { const { getByText } = render( @@ -18,7 +35,11 @@ test('renders login', async () => { ); expect(getByText(/Logging in/)).toBeDefined(); + await waitFor(() => { - expect(localStorage.getItem('authState')).not.toBeNull(); + // Check that localStorage was set with the correct "toParam" + expect(localStorage.getItem('toParam')).toBe('/'); + // Check that the mocked location was set correctly + expect(window.location.href).toContain('geoapi'); }); }); diff --git a/react/src/pages/Login/Login.tsx b/react/src/pages/Login/Login.tsx index 16c94393..cca182fe 100644 --- a/react/src/pages/Login/Login.tsx +++ b/react/src/pages/Login/Login.tsx @@ -20,20 +20,11 @@ function Login() { if (isAuthenticated) { navigate(toParam); } else { - const state = Math.random().toString(36); - // Save the authState parameter to localStorage - localStorage.setItem('authState', state); + // Save the "to" parameter to localStorage localStorage.setItem('toParam', toParam); - const callbackUrl = ( - window.location.origin + - configuration.basePath + - '/callback' - ).replace(/([^:])(\/{2,})/g, '$1/'); - // Construct the authentication URL with the client_id, redirect_uri, scope, response_type, and state parameters - const authUrl = `https://agave.designsafe-ci.org/authorize?client_id=${configuration.clientId}&redirect_uri=${callbackUrl}&scope=openid&response_type=token&state=${state}`; - - window.location.replace(authUrl); + const GEOAPI_AUTH_URL = `${configuration.geoapiUrl}/auth/login?to=${toParam}`; + window.location.href = GEOAPI_AUTH_URL; } }, []); diff --git a/react/src/redux/authSlice.ts b/react/src/redux/authSlice.ts index 3bc1822c..cee8bb2e 100644 --- a/react/src/redux/authSlice.ts +++ b/react/src/redux/authSlice.ts @@ -1,17 +1,13 @@ import { createSlice, PayloadAction } from '@reduxjs/toolkit'; import { - getTokenFromLocalStorage, - setTokenToLocalStorage, - removeTokenFromLocalStorage, + getAuthenticatedUserFromLocalStorage, + setAuthenticatedUserFromLocalStorage, + removeAuthenticatedUserFromLocalStorage, } from '../utils/authUtils'; -import { AuthState, AuthenticatedUser } from '../types'; +import { AuthenticatedUser, AuthToken } from '../types'; -// TODO consider moving to ../types/ // check local storage for our initial state -const initialState: AuthState = { - authToken: getTokenFromLocalStorage(), - user: null, -}; +const initialState = getAuthenticatedUserFromLocalStorage(); const authSlice = createSlice({ name: 'auth', @@ -19,25 +15,21 @@ const authSlice = createSlice({ reducers: { loginSuccess( state, - action: PayloadAction<{ token: string; expires: number }> + action: PayloadAction<{ user: AuthenticatedUser; authToken: AuthToken }> ) { - state.authToken = { - token: action.payload.token, - expires: action.payload.expires, + state = { + user: action.payload.user, + authToken: action.payload.authToken, }; // save to local storage - setTokenToLocalStorage(state.authToken); + setAuthenticatedUserFromLocalStorage(state); }, logout(state) { state.user = null; state.authToken = null; //remove from local storage - removeTokenFromLocalStorage(); - }, - - setUser(state, action: PayloadAction<{ user: AuthenticatedUser }>) { - state.user = action.payload.user; + removeAuthenticatedUserFromLocalStorage(); }, }, }); diff --git a/react/src/requests.test.ts b/react/src/requests.test.ts index 5aeb50d4..95f41bda 100644 --- a/react/src/requests.test.ts +++ b/react/src/requests.test.ts @@ -1,47 +1,20 @@ import { getHeaders } from './requests'; -import { ApiService, GeoapiBackendEnvironment } from './types'; +import { ApiService } from './types'; import { authenticatedUser, unauthenticatedUser, } from './__fixtures__/authStateFixtures'; -import { localDevConfiguration } from './__fixtures__/appConfigurationFixture'; describe('getHeaders', () => { - it('returns JWT header when using local Geoapi', () => { - const headers = getHeaders( - ApiService.Geoapi, - { - ...localDevConfiguration, - geoapiBackend: GeoapiBackendEnvironment.Local, - }, - authenticatedUser - ); - - expect(headers).toEqual({ - 'X-JWT-Assertion-designsafe': localDevConfiguration.jwt, - }); - }); - - it('returns Authorization header for non-local Geoapi', () => { - const headers = getHeaders( - ApiService.Geoapi, - { - ...localDevConfiguration, - geoapiBackend: GeoapiBackendEnvironment.Production, // Or any other non-local environment - }, - authenticatedUser - ); + it('returns Authorization header for Geoapi', () => { + const headers = getHeaders(ApiService.Geoapi, authenticatedUser); expect(headers).toEqual({ - Authorization: `Bearer ${authenticatedUser.authToken?.token}`, + 'X-Tapis-Token': `${authenticatedUser.authToken?.token}`, }); }); it('returns no auth-related headers for unauthenticatedUser', () => { - const headers = getHeaders( - ApiService.Geoapi, - localDevConfiguration, - unauthenticatedUser - ); + const headers = getHeaders(ApiService.Geoapi, unauthenticatedUser); expect(headers).toEqual({}); }); }); diff --git a/react/src/requests.ts b/react/src/requests.ts index df9b90d7..7e1b9be7 100644 --- a/react/src/requests.ts +++ b/react/src/requests.ts @@ -8,13 +8,11 @@ import { UseMutationOptions, QueryKey, } from 'react-query'; -import { useAppConfiguration } from './hooks'; import { - ApiService, - AppConfiguration, - AuthState, - GeoapiBackendEnvironment, -} from './types'; + useAppConfiguration, + useEnsureAuthenticatedUserHasValidTapisToken, +} from './hooks'; +import { ApiService, AppConfiguration, AuthState } from './types'; import { v4 as uuidv4 } from 'uuid'; function getBaseApiUrl( @@ -25,32 +23,32 @@ function getBaseApiUrl( case ApiService.Geoapi: return configuration.geoapiUrl; case ApiService.DesignSafe: - return configuration.designSafeUrl; + return configuration.designsafePortalUrl; case ApiService.Tapis: - // Tapis and DesignSafe are currently the same - return configuration.designSafeUrl; + return 'https://designsafe.tapis.io'; default: throw new Error('Unsupported api service Type.'); } } -export function getHeaders( - apiService: ApiService, - configuration: AppConfiguration, - auth: AuthState -) { - // TODO_REACT add mapillary support - if (auth.authToken?.token && apiService !== ApiService.Mapillary) { - //Add auth information in header for DesignSafe, Tapis, Geoapi for logged in users - if ( - apiService === ApiService.Geoapi && - configuration.geoapiBackend === GeoapiBackendEnvironment.Local - ) { - // Use JWT in request header because local geoapi API is not behind ws02 - return { 'X-JWT-Assertion-designsafe': configuration.jwt }; - } - return { Authorization: `Bearer ${auth.authToken?.token}` }; +function usesTapisToken(apiService: ApiService) { + const servicesUsingTapisToken = [ + ApiService.Geoapi, + ApiService.Tapis, + ApiService.DesignSafe, + ]; + return servicesUsingTapisToken.includes(apiService); +} + +export function getHeaders(apiService: ApiService, auth: AuthState) { + const hasTapisAuthToken = !!auth.authToken?.token; + + if (hasTapisAuthToken && usesTapisToken(apiService)) { + return { 'X-Tapis-Token': auth.authToken?.token }; } + + // TODO_REACT add mapillary support + return {}; } @@ -81,8 +79,11 @@ export function useGet({ const client = axios; const state = store.getState(); const configuration = useAppConfiguration(); + + useEnsureAuthenticatedUserHasValidTapisToken(); + const baseUrl = getBaseApiUrl(apiService, configuration); - const headers = getHeaders(apiService, configuration, state.auth); + const headers = getHeaders(apiService, state.auth); let url = `${baseUrl}${endpoint}`; @@ -137,8 +138,11 @@ export function usePost({ const state = store.getState(); const configuration = useAppConfiguration(); + useEnsureAuthenticatedUserHasValidTapisToken(); + const baseUrl = getBaseApiUrl(apiService, configuration); - const headers = getHeaders(apiService, configuration, state.auth); + + const headers = getHeaders(apiService, state.auth); const postUtil = async (requestData: RequestType) => { const response = await client.post( diff --git a/react/src/secret_local.example.ts b/react/src/secret_local.example.ts index 396ba952..ae7e1816 100644 --- a/react/src/secret_local.example.ts +++ b/react/src/secret_local.example.ts @@ -1,9 +1,5 @@ import { GeoapiBackendEnvironment, LocalAppConfiguration } from './types'; -// prettier-ignore -const jwt = 'INSERT YOUR JWT HERE; See README '; - export const localDevelopmentConfiguration: LocalAppConfiguration = { - jwt: jwt, geoapiBackend: GeoapiBackendEnvironment.Production, }; diff --git a/react/src/types/auth.ts b/react/src/types/auth.ts index 5d0207e1..c78ba839 100644 --- a/react/src/types/auth.ts +++ b/react/src/types/auth.ts @@ -1,11 +1,10 @@ export interface AuthenticatedUser { username: string; - email: string; } export interface AuthToken { - token: string | null; - expires: number | null; + token: string; + expiresAt: string; } export interface AuthState { diff --git a/react/src/types/environment.ts b/react/src/types/environment.ts index d1ae074a..6e398eff 100644 --- a/react/src/types/environment.ts +++ b/react/src/types/environment.ts @@ -5,6 +5,7 @@ export enum GeoapiBackendEnvironment { Production = 'production', Staging = 'staging', Dev = 'dev', + Experimental = 'experimental', Local = 'local', } @@ -12,8 +13,10 @@ export enum GeoapiBackendEnvironment { * Environment for Geoapi Backend */ export enum DesignSafePortalEnvironment { - Production = 'production', - Dev = 'dev' /* DesignSafe has 2 deployed environments: prod and dev. This dev is comparable to Geoapi's staging */, + Production = 'production' /* https://www.designsafe-ci.org/ */, + Dev = 'dev' /* https://designsafeci-dev.tacc.utexas.edu/ This dev is comparable to Geoapi's staging */, + Next = 'experimental' /* https://designsafeci-next.tacc.utexas.edu/ */, + Local = 'local' /* not supported but would be designsafe.dev */, } /** @@ -23,7 +26,7 @@ export enum ApiService { /* Geoapi api */ Geoapi = 'geoapi', - /* DesignSafe api - for project listings */ + /* DesignSafe api - for project listings + project metadata read/update */ DesignSafe = 'designsafe', /* Tapis api - for system listings and file operations */ @@ -40,9 +43,6 @@ export enum ApiService { * */ export interface LocalAppConfiguration { - /* Developer's JWT token used for authentication during local development. */ - jwt: string; - /* The type of backend environment (production, staging, development, or local) */ geoapiBackend: GeoapiBackendEnvironment; } @@ -69,19 +69,13 @@ export interface AppConfiguration { /** Base URL path for the application. */ basePath: string; - /** Client ID used for Tapis authentication. */ - clientId: string; - /* The type of backend environment */ geoapiBackend: GeoapiBackendEnvironment; /** URL for the GeoAPI service. */ geoapiUrl: string; - /** URL for the DesignSafe/tapis API. */ - designSafeUrl: string; - - /** URL for the DesignSafe portal. */ + /** URL for the DesignSafe portal and API. */ designsafePortalUrl: string; /** Mapillary related configuration */ @@ -89,7 +83,4 @@ export interface AppConfiguration { /** URL for taggit */ taggitUrl: string; - - /** Optional JWT token used for development with local geoapi service. */ - jwt?: string; } diff --git a/react/src/utils/authUtils.test.ts b/react/src/utils/authUtils.test.ts new file mode 100644 index 00000000..0a15eaaf --- /dev/null +++ b/react/src/utils/authUtils.test.ts @@ -0,0 +1,89 @@ +import { + isTokenValid, + getAuthenticatedUserFromLocalStorage, + setAuthenticatedUserFromLocalStorage, + removeAuthenticatedUserFromLocalStorage, + AUTH_KEY, +} from './authUtils'; +import { AuthState, AuthToken } from '../types'; + +describe('Auth Utils', () => { + describe('isTokenValid', () => { + it('should return false if authToken is null', () => { + expect(isTokenValid(null)).toBe(false); + }); + + it('should return false if token is expired', () => { + const authToken: AuthToken = { + token: 'fakeToken', + expiresAt: new Date(Date.now() - 1000).toISOString(), // Expired 1 second ago + }; + expect(isTokenValid(authToken)).toBe(false); + }); + + it('should return true if token is valid', () => { + const authToken: AuthToken = { + token: 'fakeToken', + expiresAt: new Date(Date.now() + 1000 * 60 * 10).toISOString(), // Expires in 10 minutes + }; + expect(isTokenValid(authToken)).toBe(true); + }); + }); + + describe('getAuthenticatedUserFromLocalStorage', () => { + beforeEach(() => { + localStorage.clear(); + }); + + it('should return null user and token if localStorage is empty', () => { + const result = getAuthenticatedUserFromLocalStorage(); + expect(result).toEqual({ user: null, authToken: null }); + }); + + it('should return the user and token from localStorage', () => { + const authState: AuthState = { + user: { username: 'testUser' }, + authToken: { token: 'fakeToken', expiresAt: '2024-12-31T23:59:59Z' }, + }; + localStorage.setItem(AUTH_KEY, JSON.stringify(authState)); + + const result = getAuthenticatedUserFromLocalStorage(); + expect(result).toEqual(authState); + }); + }); + + describe('setAuthenticatedUserFromLocalStorage', () => { + beforeEach(() => { + localStorage.clear(); + }); + + it('should store the user and token in localStorage', () => { + const authState: AuthState = { + user: { username: 'testUser' }, + authToken: { token: 'fakeToken', expiresAt: '2024-12-31T23:59:59Z' }, + }; + + setAuthenticatedUserFromLocalStorage(authState); + const storedValue = localStorage.getItem(AUTH_KEY); + expect(storedValue).toEqual(JSON.stringify(authState)); + }); + }); + + describe('removeAuthenticatedUserFromLocalStorage', () => { + beforeEach(() => { + localStorage.clear(); + }); + + it('should remove the user and token from localStorage', () => { + const authState: AuthState = { + user: { username: 'testUser' }, + authToken: { token: 'fakeToken', expiresAt: '2024-12-31T23:59:59Z' }, + }; + localStorage.setItem(AUTH_KEY, JSON.stringify(authState)); + + removeAuthenticatedUserFromLocalStorage(); + const storedValue = localStorage.getItem(AUTH_KEY); + expect(storedValue).toBeNull(); + }); + }); +}); diff --git a/react/src/utils/authUtils.ts b/react/src/utils/authUtils.ts index 0432cb6e..3a8c9146 100644 --- a/react/src/utils/authUtils.ts +++ b/react/src/utils/authUtils.ts @@ -1,37 +1,43 @@ -import { AuthToken } from '../types'; +import { AuthToken, AuthState } from '../types'; -export const AUTH_KEY = 'auth'; +export const AUTH_KEY = 'authV3'; export function isTokenValid(authToken: AuthToken | null): boolean { if (authToken) { - if (!authToken.expires) { + if (!authToken.expiresAt) { return false; } - const now = Date.now(); - return now < authToken.expires; + const now = new Date(); + const expiresAtDate: Date = new Date(authToken.expiresAt); + return now < expiresAtDate; } else { return false; } } -export function getTokenFromLocalStorage(): AuthToken { +/** + * Retrieves the authentication information (user, token etc) from local storage. + * + * If not found in local storage, the function returns `null`. + */ +export function getAuthenticatedUserFromLocalStorage(): AuthState { try { - const tokenStr = localStorage.getItem(AUTH_KEY); - if (tokenStr) { - const auth = JSON.parse(tokenStr); - return auth; + const authenticatedUserJson = localStorage.getItem(AUTH_KEY); + if (authenticatedUserJson) { + const authState = JSON.parse(authenticatedUserJson); + return authState; } } catch (e) { console.error('Error loading state from localStorage:', e); } - return { token: null, expires: null }; + return { user: null, authToken: null }; } -export function setTokenToLocalStorage(authToken: AuthToken) { +export function setAuthenticatedUserFromLocalStorage(authToken: AuthState) { localStorage.setItem(AUTH_KEY, JSON.stringify(authToken)); } -export function removeTokenFromLocalStorage() { +export function removeAuthenticatedUserFromLocalStorage() { localStorage.removeItem(AUTH_KEY); }