diff --git a/package-lock.json b/package-lock.json index 9d60fba67..5f567586a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -31,6 +31,7 @@ "mapbox-gl": "^2.15.0", "notistack": "^3.0.1", "path-browserify": "^1.0.1", + "query-string": "^7.1.3", "react": "^18.2.0", "react-avatar-editor": "^13.0.0", "react-beautiful-dnd": "^13.1.1", @@ -48,7 +49,7 @@ "slate-history": "^0.93.0", "slate-hyperscript": "^0.77.0", "slate-react": "^0.99.0", - "terraso-client-shared": "github:techmatters/terraso-client-shared#a0e3a5e", + "terraso-client-shared": "github:techmatters/terraso-client-shared#5e56086", "use-debounce": "^9.0.4", "uuid": "^9.0.0", "web-vitals": "^3.5.0", @@ -6758,9 +6759,9 @@ } }, "node_modules/@reduxjs/toolkit": { - "version": "1.9.5", - "resolved": "https://registry.npmjs.org/@reduxjs/toolkit/-/toolkit-1.9.5.tgz", - "integrity": "sha512-Rt97jHmfTeaxL4swLRNPD/zV4OxTes4la07Xc4hetpUW/vc75t5m1ANyxG6ymnEQ2FsLQsoMlYB2vV1sO3m8tQ==", + "version": "1.9.7", + "resolved": "https://registry.npmjs.org/@reduxjs/toolkit/-/toolkit-1.9.7.tgz", + "integrity": "sha512-t7v8ZPxhhKgOKtU+uyJT13lu4vL7az5aFi4IdoDs/eS548edn2M8Ik9h8fxgvMjGoAUVFSt6ZC1P5cWmQ014QQ==", "dependencies": { "immer": "^9.0.21", "redux": "^4.2.1", @@ -11623,6 +11624,14 @@ "integrity": "sha512-F29o+vci4DodHYT9UrR5IEbfBw9pE5eSapIJdTqXK5+6hq+t8VRxwQyKlW2i+KDKFkkJQRvFyI/QXD83h8LyQw==", "dev": true }, + "node_modules/decode-uri-component": { + "version": "0.2.2", + "resolved": "https://registry.npmjs.org/decode-uri-component/-/decode-uri-component-0.2.2.tgz", + "integrity": "sha512-FqUYQ+8o158GyGTrMFJms9qh3CqTKvAqgqsTnkLI8sKu0028orqBhxNMFkFen0zGyg6epACD32pjVk58ngIErQ==", + "engines": { + "node": ">=0.10" + } + }, "node_modules/decompress-response": { "version": "6.0.0", "resolved": "https://registry.npmjs.org/decompress-response/-/decompress-response-6.0.0.tgz", @@ -14062,6 +14071,14 @@ "node": ">=8" } }, + "node_modules/filter-obj": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/filter-obj/-/filter-obj-1.1.0.tgz", + "integrity": "sha512-8rXg1ZnX7xzy2NGDVkBVaAy+lSlPNwad13BtgSlLuxfIslyt5Vg64U7tFcCt4WS1R0hvtnQybT/IyCkGZ3DpXQ==", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/finalhandler": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-1.2.0.tgz", @@ -24017,6 +24034,23 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/query-string": { + "version": "7.1.3", + "resolved": "https://registry.npmjs.org/query-string/-/query-string-7.1.3.tgz", + "integrity": "sha512-hh2WYhq4fi8+b+/2Kg9CEge4fDPvHS534aOOvOZeQ3+Vf2mCFsaFBYj0i+iXcAq6I9Vzp5fjMFBlONvayDC1qg==", + "dependencies": { + "decode-uri-component": "^0.2.2", + "filter-obj": "^1.1.0", + "split-on-first": "^1.0.0", + "strict-uri-encode": "^2.0.0" + }, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/querystringify": { "version": "2.2.0", "resolved": "https://registry.npmjs.org/querystringify/-/querystringify-2.2.0.tgz", @@ -26344,6 +26378,14 @@ "node": ">= 6" } }, + "node_modules/split-on-first": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/split-on-first/-/split-on-first-1.1.0.tgz", + "integrity": "sha512-43ZssAJaMusuKWL8sKUBQXHWOpq8d6CfN/u1p4gUzfJkM05C8rxTmYrkIPTXapZpORA6LkkzcUulJ8FqA7Uudw==", + "engines": { + "node": ">=6" + } + }, "node_modules/sponge-case": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/sponge-case/-/sponge-case-1.0.1.tgz", @@ -26418,6 +26460,14 @@ "node": ">=10.0.0" } }, + "node_modules/strict-uri-encode": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/strict-uri-encode/-/strict-uri-encode-2.0.0.tgz", + "integrity": "sha512-QwiXZgpRcKkhTj2Scnn++4PKtWsH0kpzZ62L2R6c/LUVYv7hVnZqcg2+sMuT6R7Jusu1vviK/MFsu6kNJfWlEQ==", + "engines": { + "node": ">=4" + } + }, "node_modules/string_decoder": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz", @@ -27310,20 +27360,19 @@ }, "node_modules/terraso-backend": { "version": "0.1.0", - "resolved": "git+ssh://git@github.com/techmatters/terraso-backend.git#8813449c8b2f4eb4b70b4add87007d2a93d25baa", - "integrity": "sha512-WXumMW1dnbEFfZjmNkRuibIuO7TDeRUQ8vr5K2nyhUs8bDZXUS67zM6TtLx92czO+PvdzI36ncH5abGHg3rpOQ==" + "resolved": "git+ssh://git@github.com/techmatters/terraso-backend.git#4929e5f4599cebc2f107873dd0075885c4df5e46" }, "node_modules/terraso-client-shared": { "version": "0.1.0", - "resolved": "git+ssh://git@github.com/techmatters/terraso-client-shared.git#691d4f0928a4b24d1a2a6506a1780a91ce26cc19", - "integrity": "sha512-lv7XZ0LZ1xx2DsVyj2EdrTKVIFSBOSy8BQdaJVng+5D5FV8IwoBGcxcEVqEOIxvnd8GLwOClUHyhO1ow72792g==", + "resolved": "git+ssh://git@github.com/techmatters/terraso-client-shared.git#b86117e3735b2d8b2f85ffdb68547eb9738b9f77", + "integrity": "sha512-33236wvHVdvHmd6R0icNzt1PWtVhqZq8nkRguDfhq5bYrH8H3MCKf1umcsJQHJ+Y4csYw6VlFSHKrIzbayIrZw==", "dependencies": { - "@reduxjs/toolkit": "^1.9.5", + "@reduxjs/toolkit": "^1.9.6", "jwt-decode": "^3.1.2", "lodash": "^4.17.21", "react": "^18.2.0", "react-redux": "^8.1.2", - "terraso-backend": "github:techmatters/terraso-backend#8813449c8b2f4eb4b70b4add87007d2a93d25baa", + "terraso-backend": "github:techmatters/terraso-backend#4929e5f", "uuid": "^9.0.0" } }, diff --git a/package.json b/package.json index 13dd64570..6e11bd30e 100644 --- a/package.json +++ b/package.json @@ -29,6 +29,7 @@ "mapbox-gl": "^2.15.0", "notistack": "^3.0.1", "path-browserify": "^1.0.1", + "query-string": "^7.1.3", "react": "^18.2.0", "react-avatar-editor": "^13.0.0", "react-beautiful-dnd": "^13.1.1", @@ -46,7 +47,7 @@ "slate-history": "^0.93.0", "slate-hyperscript": "^0.77.0", "slate-react": "^0.99.0", - "terraso-client-shared": "github:techmatters/terraso-client-shared#a0e3a5e", + "terraso-client-shared": "github:techmatters/terraso-client-shared#5e56086", "use-debounce": "^9.0.4", "uuid": "^9.0.0", "web-vitals": "^3.5.0", diff --git a/src/account/components/AccountLogin.js b/src/account/components/AccountLogin.js index e753b601b..55d84f8ae 100644 --- a/src/account/components/AccountLogin.js +++ b/src/account/components/AccountLogin.js @@ -14,10 +14,11 @@ * You should have received a copy of the GNU Affero General Public License * along with this program. If not, see https://www.gnu.org/licenses/. */ -import React from 'react'; +import React, { useEffect } from 'react'; +import queryString from 'query-string'; import { Trans, useTranslation } from 'react-i18next'; import { useSelector } from 'react-redux'; -import { useSearchParams } from 'react-router-dom'; +import { useNavigate, useSearchParams } from 'react-router-dom'; import { fetchAuthURLs } from 'terraso-client-shared/account/accountSlice'; import { useFetchData } from 'terraso-client-shared/store/utils'; import AppleIcon from '@mui/icons-material/Apple'; @@ -40,25 +41,59 @@ const MicrosoftIcon = props => { return ; }; -const appendReferrer = (url, referrer) => - referrer ? `${url}&state=${referrer}` : url; +const appendReferrer = (url, referrer) => { + if (!referrer) { + return url; + } + const parsedUrl = queryString.parseUrl(url); + const redirectUrl = queryString.stringifyUrl({ + url: 'account', + query: { + referrerBase64: btoa(referrer), + }, + }); + return queryString.stringifyUrl({ + ...parsedUrl, + query: { + ...parsedUrl.query, + state: redirectUrl, + }, + }); +}; const AccountForm = () => { const { t } = useTranslation(); const { trackEvent } = useAnalytics(); + const navigate = useNavigate(); const [searchParams] = useSearchParams(); const { fetching, urls } = useSelector(state => state.account.login); + const hasToken = useSelector(state => state.account.hasToken); const referrer = searchParams.get('referrer'); + const referrerBase64 = searchParams.get('referrerBase64'); useDocumentTitle(t('account.login_document_title')); useDocumentDescription(t('account.login_document_description')); useFetchData(fetchAuthURLs); + useEffect(() => { + if (!hasToken) { + return; + } + const url = referrerBase64 ? atob(referrerBase64) : referrer; + navigate(url ? decodeURIComponent(url) : '/', { + replace: true, + }); + }, [hasToken, navigate, referrer, referrerBase64]); + if (fetching) { return ; } + if (hasToken) { + return null; + } + return ( ({ ...jest.requireActual('react-router-dom'), useSearchParams: jest.fn(), + useNavigate: jest.fn(), })); beforeEach(() => { useSearchParams.mockReturnValue([new URLSearchParams(), () => {}]); + useNavigate.mockReturnValue(jest.fn()); }); test('AccountLogin: Display error', async () => { @@ -50,36 +52,100 @@ test('AccountLogin: Display loader', async () => { test('AccountLogin: Display buttons', async () => { accountService.getAuthURLs.mockReturnValue( Promise.resolve({ - google: 'google.url', - apple: 'apple.url', + google: 'google.url?param=value', + apple: 'apple.url?param=value', }) ); await render(); expect(screen.getByText('Continue with Google')).toBeInTheDocument(); + expect(screen.getByText('Continue with Google')).toHaveAttribute( + 'href', + `google.url?param=value` + ); expect(screen.getByText('Continue with Apple')).toBeInTheDocument(); }); test('AccountLogin: Add referrer', async () => { const searchParams = new URLSearchParams(); - searchParams.set('referrer', 'groups?sort=-name'); + const referrer = encodeURIComponent('groups?sort=-name&other=1'); + searchParams.set('referrer', referrer); useSearchParams.mockReturnValue([searchParams]); accountService.getAuthURLs.mockReturnValue( Promise.resolve({ - google: 'google.url', - apple: 'apple.url', + google: 'google.url?param=value', + apple: 'apple.url?param=value', }) ); await render(); expect(screen.getByText('Continue with Google')).toBeInTheDocument(); + const state = `account%3FreferrerBase64%3D${btoa(referrer)}`; expect(screen.getByText('Continue with Google')).toHaveAttribute( 'href', - 'google.url&state=groups?sort=-name' + `google.url?param=value&state=${state}` ); expect(screen.getByText('Continue with Apple')).toBeInTheDocument(); expect(screen.getByText('Continue with Apple')).toHaveAttribute( 'href', - 'apple.url&state=groups?sort=-name' + `apple.url?param=value&state=${state}` + ); +}); + +test('AccountLogin: Navigate to referrer if logged in', async () => { + accountService.getAuthURLs.mockReturnValue( + Promise.resolve({ + google: 'google.url', + apple: 'apple.url', + }) ); + const navigate = jest.fn(); + useNavigate.mockReturnValue(navigate); + const searchParams = new URLSearchParams(); + const referrer = encodeURIComponent('groups?sort=-name&other=1'); + searchParams.set('referrer', referrer); + useSearchParams.mockReturnValue([searchParams]); + await render(, { + account: { + hasToken: true, + login: {}, + currentUser: { + data: { + email: 'test@test.com', + }, + }, + }, + }); + expect(navigate).toHaveBeenCalledWith('groups?sort=-name&other=1', { + replace: true, + }); +}); + +test('AccountLogin: Navigate to referrer base 64 if logged in', async () => { + accountService.getAuthURLs.mockReturnValue( + Promise.resolve({ + google: 'google.url', + apple: 'apple.url', + }) + ); + const navigate = jest.fn(); + useNavigate.mockReturnValue(navigate); + const searchParams = new URLSearchParams(); + const referrer = encodeURIComponent('groups?sort=-name&other=1'); + searchParams.set('referrerBase64', btoa(referrer)); + useSearchParams.mockReturnValue([searchParams]); + await render(, { + account: { + hasToken: true, + login: {}, + currentUser: { + data: { + email: 'test@test.com', + }, + }, + }, + }); + expect(navigate).toHaveBeenCalledWith('groups?sort=-name&other=1', { + replace: true, + }); }); test('AccountLogin: Display locale picker', async () => { diff --git a/src/account/components/RequireAuth.js b/src/account/components/RequireAuth.js index 22400a743..d9b2d2faf 100644 --- a/src/account/components/RequireAuth.js +++ b/src/account/components/RequireAuth.js @@ -15,6 +15,7 @@ * along with this program. If not, see https://www.gnu.org/licenses/. */ import React, { useCallback } from 'react'; +import queryString from 'query-string'; import { useSelector } from 'react-redux'; import { Navigate, useLocation } from 'react-router-dom'; import { fetchUser } from 'terraso-client-shared/account/accountSlice'; @@ -48,7 +49,15 @@ const RequireAuth = ({ children }) => { const referrer = generateReferrerPath(location); - const to = referrer ? `/account?referrer=${referrer}` : '/account'; + const to = referrer + ? queryString.stringifyUrl({ + url: '/account', + query: { + referrer, + }, + }) + : '/account'; + return ; }; diff --git a/src/account/components/RequireAuth.test.js b/src/account/components/RequireAuth.test.js index 1593bb07f..0feae1e54 100644 --- a/src/account/components/RequireAuth.test.js +++ b/src/account/components/RequireAuth.test.js @@ -85,10 +85,24 @@ test('Auth: test redirect', async () => { expect(terrasoApi.requestGraphQL).toHaveBeenCalledTimes(2); expect(screen.getByText('To: /account')).toBeInTheDocument(); }); -test('Auth: test redirect referrer', async () => { + +const REDIRECT_PATHNAME = '/groups'; +const REDIRECT_SEARCH = '?sort=-name&other=1'; +const REFERRER_PATH = `/account?referrer=${encodeURIComponent( + `${REDIRECT_PATHNAME}${REDIRECT_SEARCH}` +)}`; +const REFERRER_URL = new URL(`http://127.0.0.1${REFERRER_PATH}`); + +test('Auth: Test url parsing for referrer', async () => { + expect(REFERRER_URL.searchParams.get('referrer')).toBe( + '/groups?sort=-name&other=1' + ); +}); + +test('Auth: Test redirect referrer', async () => { useLocation.mockReturnValue({ - pathname: '/groups', - search: '?sort=-name', + pathname: REDIRECT_PATHNAME, + search: REDIRECT_SEARCH, }); await render( @@ -97,9 +111,12 @@ test('Auth: test redirect referrer', async () => { ); expect( - screen.getByText('To: /account?referrer=groups?sort=-name') + screen.getByText( + 'To: /account?referrer=%2Fgroups%3Fsort%3D-name%26other%3D1' + ) ).toBeInTheDocument(); }); + test('Auth: test refresh tokens', async () => { useParams.mockReturnValue({ slug: 'slug-1', diff --git a/src/localization/locales/en-US.json b/src/localization/locales/en-US.json index 0a1aa9547..6ec75b2c7 100644 --- a/src/localization/locales/en-US.json +++ b/src/localization/locales/en-US.json @@ -1014,7 +1014,8 @@ "addMemberships.unique": "One or more users are already collaborators of this story map.", "approved_membership": "You have been added to “{{storyMapTitle}}”", "approve_invite_success": "You have been added to “{{storyMapTitle}}”", - "approve_invite_error": "Failed to accept Story Map invite", + "approveMembership.update_not_allowed": "Unable to accept Story Map invitation", + "approveMembership.update_not_allowed_permissions_validation": "You can't accept the invitation to edit “{{storyMapTitle}}.” You must log in using the email address shown in the invitation.", "invite_document_title": "Story Map Invite" }, "site": { diff --git a/src/localization/locales/es-ES.json b/src/localization/locales/es-ES.json index a95cf040a..11315d17d 100644 --- a/src/localization/locales/es-ES.json +++ b/src/localization/locales/es-ES.json @@ -1021,7 +1021,8 @@ "addMemberships.unique": "Uno o más usuarios ya son colaboradores de este story map.", "approved_membership": "Te han agregado a “{{storyMapTitle}}”", "approve_invite_success": "Te han agregado a “{{storyMapTitle}}”", - "approve_invite_error": "No se pudo aceptar la invitación al Story Map", + "approveMembership.update_not_allowed": "No se puede aceptar la invitación de Story Map", + "approveMembership.update_not_allowed_permissions_validation": "No puedes aceptar la invitación para editar “{{storyMapTitle}}”. Debes iniciar sesión utilizando la dirección de correo electrónico que se muestra en la invitación.", "invite_document_title": "Invitación a Story Map" }, "site": { diff --git a/src/navigation/components/Routes.js b/src/navigation/components/Routes.js index 9fa9f11d6..c3c159b6b 100644 --- a/src/navigation/components/Routes.js +++ b/src/navigation/components/Routes.js @@ -193,11 +193,7 @@ const paths = [ isEmbedded: true, }, }), - path('/tools/story-maps/accept', StoryMapInvite, { - optionalAuth: { - enabled: true, - }, - }), + path('/tools/story-maps/accept', StoryMapInvite), path('*', NotFound), ]; diff --git a/src/navigation/navigationUtils.js b/src/navigation/navigationUtils.js index 10d9e6d32..034f31ccb 100644 --- a/src/navigation/navigationUtils.js +++ b/src/navigation/navigationUtils.js @@ -22,5 +22,5 @@ export const generateReferrerPath = location => { const referrer = [path.substring(1), queryParams] .filter(part => part) .join(''); - return referrer; + return referrer ? `/${referrer}` : null; }; diff --git a/src/storyMap/components/StoryMapInvite.js b/src/storyMap/components/StoryMapInvite.js index 7caa23c22..a28a606d5 100644 --- a/src/storyMap/components/StoryMapInvite.js +++ b/src/storyMap/components/StoryMapInvite.js @@ -52,44 +52,26 @@ const StoryMapInvite = () => { }, [token, decodedToken, membershipId]) ); - useEffect(() => { - if (!success && !error) { - return; - } - - if (success) { - dispatch( - addMessage({ - severity: 'success', - content: 'storyMap.approve_invite_success', - params: { - storyMapTitle: storyMap.title, - }, - }) - ); - } - - if (error) { - dispatch( - addMessage({ - severity: 'error', - content: 'storyMap.approve_invite_error', - }) - ); - } - }, [success, error, storyMap, dispatch, navigate]); - useEffect(() => { if (!success) { return; } navigate(`/tools/story-maps/${storyMap.storyMapId}/${storyMap.slug}/edit`); trackEvent('storymap.share.accept'); - }, [success, navigate, trackEvent, storyMap]); + dispatch( + addMessage({ + severity: 'success', + content: 'storyMap.approve_invite_success', + params: { + storyMapTitle: storyMap.title, + }, + }) + ); + }, [success, navigate, trackEvent, dispatch, storyMap]); useDocumentTitle(t('storyMap.invite_document_title')); - if (processing || !storyMap) { + if (processing) { return ; } @@ -99,7 +81,14 @@ const StoryMapInvite = () => { return ( - {t('storyMap.approve_invite_error')} + {error?.parsedErrors.map((error, index) => ( + + {t(error.content, { + ...error.params, + storyMapTitle: error.params?.response?.storyMap?.title, + })} + + ))} ); }; diff --git a/src/storyMap/components/StoryMapInvite.test.js b/src/storyMap/components/StoryMapInvite.test.js index eee7b65dc..23fff0fd4 100644 --- a/src/storyMap/components/StoryMapInvite.test.js +++ b/src/storyMap/components/StoryMapInvite.test.js @@ -110,7 +110,9 @@ test('StoryMapInvite: Invalid token', async () => { useSearchParams.mockReturnValue([searchParams]); mockTerrasoAPIrequestGraphQL({ - 'mutation approveMembershipToken': Promise.reject('Error'), + 'mutation approveMembershipToken': Promise.reject({ + content: 'update_not_allowed', + }), }); await setup(); @@ -118,8 +120,40 @@ test('StoryMapInvite: Invalid token', async () => { await waitFor(() => expect( within(screen.getByRole('alert')).getByText( - /Failed to accept Story Map invite/i + /Unable to accept Story Map invitation/i ) ).toBeInTheDocument() ); }); + +test('StoryMapInvite: Different user token', async () => { + const navigate = jest.fn(); + useNavigate.mockReturnValue(navigate); + + const searchParams = new URLSearchParams(); + searchParams.set('token', TOKEN); + useSearchParams.mockReturnValue([searchParams]); + + mockTerrasoAPIrequestGraphQL({ + 'mutation approveMembershipToken': Promise.reject({ + content: 'update_not_allowed_permissions_validation', + params: { + response: { + storyMap: { + title: 'Story Map title', + }, + }, + }, + }), + }); + + await setup(); + + expect( + within(screen.getByRole('alert')).getByText( + /You can't accept the invitation to edit “Story Map title.” You must log in using the email address shown in the invitation./i + ) + ).toBeInTheDocument(); + + expect(navigate).not.toHaveBeenCalled(); +}); diff --git a/src/storyMap/storyMapSlice.js b/src/storyMap/storyMapSlice.js index 120c19670..c5fa46eec 100644 --- a/src/storyMap/storyMapSlice.js +++ b/src/storyMap/storyMapSlice.js @@ -363,7 +363,7 @@ const storyMapSlice = createSlice({ ...state.memberships.approve, [action.meta.arg.membership.membershipId]: { processing: false, - error: true, + error: action.payload, }, }, },