From 9be84859e71da7b615e9ee5ff0d1b1677d733b7b Mon Sep 17 00:00:00 2001 From: Marko Haarni <marko.haarni@gofore.com> Date: Fri, 22 Sep 2023 15:56:53 +0300 Subject: [PATCH] HAI-1884 Identify user coming from invitation link When user comes to Haitaton from invitation link, call identify endpoint with token included in the link. After identifying user, show success notification and redirect user to front page. If user is not signed in before coming to Haitaton, redirect user to login and after login, do the identifying. --- src/common/routes/LocaleRoutes.tsx | 2 + .../auth/components/UserIdentify.test.tsx | 80 +++++++++++++++++++ src/domain/auth/components/UserIdentify.tsx | 66 +++++++++++++++ src/domain/hanke/hankeUsers/hankeUsersApi.ts | 5 ++ src/domain/mocks/handlers.ts | 4 + src/locales/fi.json | 12 ++- 6 files changed, 168 insertions(+), 1 deletion(-) create mode 100644 src/domain/auth/components/UserIdentify.test.tsx create mode 100644 src/domain/auth/components/UserIdentify.tsx diff --git a/src/common/routes/LocaleRoutes.tsx b/src/common/routes/LocaleRoutes.tsx index 2a9e5750c..b72f17577 100644 --- a/src/common/routes/LocaleRoutes.tsx +++ b/src/common/routes/LocaleRoutes.tsx @@ -22,6 +22,7 @@ import EditJohtoselvitysPage from '../../pages/EditJohtoselvitysPage'; import NotFoundPage from '../../pages/staticPages/404Page'; import ManualPage from '../../pages/staticPages/ManualPage'; import AccessRightsPage from '../../pages/AccessRightsPage'; +import UserIdentify from '../../domain/auth/components/UserIdentify'; const LocaleRoutes = () => { const { t } = useTranslation(); @@ -74,6 +75,7 @@ const LocaleRoutes = () => { <Route path={t('routes:REFERENCES:path')} element={<ReferencesPage />} /> <Route path={t('routes:PRIVACY_POLICY:path')} element={<PrivacyPolicyPage />} /> <Route path={t('routes:MANUAL:path')} element={<ManualPage />} /> + <Route path={t('routes:IDENTIFY_USER:path')} element={<UserIdentify />} /> <Route path="*" element={<NotFoundPage />} /> </Routes> ); diff --git a/src/domain/auth/components/UserIdentify.test.tsx b/src/domain/auth/components/UserIdentify.test.tsx new file mode 100644 index 000000000..4bc4824ce --- /dev/null +++ b/src/domain/auth/components/UserIdentify.test.tsx @@ -0,0 +1,80 @@ +import React from 'react'; +import { render, screen, waitFor } from '@testing-library/react'; +import { MemoryRouter } from 'react-router-dom'; +import { I18nextProvider } from 'react-i18next'; +import { QueryClient, QueryClientProvider } from 'react-query'; +import { InitialEntry } from '@remix-run/router'; +import { User } from 'oidc-client'; +import { Provider } from 'react-redux'; +import AppRoutes from '../../../common/routes/AppRoutes'; +import { FeatureFlagsProvider } from '../../../common/components/featureFlags/FeatureFlagsContext'; +import { GlobalNotificationProvider } from '../../../common/components/globalNotification/GlobalNotificationContext'; +import GlobalNotification from '../../../common/components/globalNotification/GlobalNotification'; +import { store } from '../../../common/redux/store'; +import i18n from '../../../locales/i18n'; +import authService from '../authService'; +import { REDIRECT_PATH_KEY } from '../../../common/routes/constants'; + +afterEach(() => { + sessionStorage.clear(); +}); + +const path = '/fi/kutsu?id=5ArrqPT6kW97QTK7t7ya9PA2'; + +const mockUser: Partial<User> = { + id_token: 'fffff-aaaaaa-11111', + access_token: '.BnutWVN1x7RSAP5bU2a-tXdVPuof_9pBNd_Ozw', + profile: { + iss: '', + sub: '', + aud: '', + exp: 0, + iat: 0, + }, +}; + +function getWrapper(routerInitialEntries?: InitialEntry[]) { + const queryClient = new QueryClient({ + defaultOptions: { + queries: { + retryDelay: 0, + }, + }, + }); + + return render( + <MemoryRouter initialEntries={routerInitialEntries}> + <Provider store={store}> + <QueryClientProvider client={queryClient}> + <I18nextProvider i18n={i18n}> + <FeatureFlagsProvider> + <GlobalNotificationProvider> + <AppRoutes /> + <GlobalNotification /> + </GlobalNotificationProvider> + </FeatureFlagsProvider> + </I18nextProvider> + </QueryClientProvider> + </Provider> + </MemoryRouter>, + ); +} + +test('Should save path with query string to session storage and navigate to login when going to invitation route', async () => { + const login = jest.spyOn(authService, 'login').mockResolvedValue(); + + getWrapper([path]); + + await waitFor(() => expect(login).toHaveBeenCalled()); + expect(window.sessionStorage.getItem(REDIRECT_PATH_KEY)).toBe(path); +}); + +test('Should identify user after login', async () => { + sessionStorage.setItem(REDIRECT_PATH_KEY, path); + jest.spyOn(authService.userManager, 'getUser').mockResolvedValue(mockUser as User); + + getWrapper(); + + await waitFor(() => expect(window.document.title).toBe('Haitaton - Etusivu')); + expect(screen.queryByText('Tunnistautuminen onnistui')).toBeInTheDocument(); +}); diff --git a/src/domain/auth/components/UserIdentify.tsx b/src/domain/auth/components/UserIdentify.tsx new file mode 100644 index 000000000..802f021dd --- /dev/null +++ b/src/domain/auth/components/UserIdentify.tsx @@ -0,0 +1,66 @@ +import { useEffect, useRef } from 'react'; +import { useTranslation } from 'react-i18next'; +import { useMutation } from 'react-query'; +import { Navigate, useLocation, useSearchParams } from 'react-router-dom'; +import ErrorLoadingText from '../../../common/components/errorLoadingText/ErrorLoadingText'; +import { useGlobalNotification } from '../../../common/components/globalNotification/GlobalNotificationContext'; +import useLocale from '../../../common/hooks/useLocale'; +import { REDIRECT_PATH_KEY } from '../../../common/routes/constants'; +import { identifyUser } from '../../hanke/hankeUsers/hankeUsersApi'; +import useUser from '../useUser'; + +function UserIdentify() { + const { data: user } = useUser(); + const isAuthenticated = Boolean(user?.profile); + const locale = useLocale(); + const { t } = useTranslation(); + const location = useLocation(); + const [searchParams] = useSearchParams(); + const id = searchParams.get('id'); + const { setNotification } = useGlobalNotification(); + const identifyUserCalled = useRef(false); + + const { mutate, isSuccess, isError } = useMutation(identifyUser); + + useEffect(() => { + if (!isAuthenticated) { + sessionStorage.setItem(REDIRECT_PATH_KEY, `${location.pathname}${location.search}`); + } + }, [isAuthenticated, location.pathname, location.search]); + + useEffect(() => { + if (isAuthenticated && id !== null && !identifyUserCalled.current) { + mutate(id, { + onSuccess() { + setNotification(true, { + label: t('hankeUsers:notifications:userIdentifiedLabel'), + message: t('hankeUsers:notifications:userIdentifiedText'), + type: 'success', + dismissible: true, + closeButtonLabelText: t('common:components:notification:closeButtonLabelText'), + }); + }, + onSettled() { + identifyUserCalled.current = true; + sessionStorage.removeItem(REDIRECT_PATH_KEY); + }, + }); + } + }, [isAuthenticated, id, mutate, setNotification, t]); + + if (isError) { + return <ErrorLoadingText>{t('hankeUsers:notifications:userIdentifiedError')}</ErrorLoadingText>; + } + + if (!isAuthenticated) { + return <Navigate to="/login" />; + } + + if (isSuccess) { + return <Navigate to={`/${locale}`} />; + } + + return null; +} + +export default UserIdentify; diff --git a/src/domain/hanke/hankeUsers/hankeUsersApi.ts b/src/domain/hanke/hankeUsers/hankeUsersApi.ts index 0d962df4e..2b4a39ee7 100644 --- a/src/domain/hanke/hankeUsers/hankeUsersApi.ts +++ b/src/domain/hanke/hankeUsers/hankeUsersApi.ts @@ -22,3 +22,8 @@ export async function getSignedInUser(hankeTunnus?: string): Promise<SignedInUse const { data } = await api.get<SignedInUser>(`hankkeet/${hankeTunnus}/whoami`); return data; } + +export async function identifyUser(id: string) { + const { data } = await api.post('kayttajat', { tunniste: id }); + return data; +} diff --git a/src/domain/mocks/handlers.ts b/src/domain/mocks/handlers.ts index fba56251c..00167b154 100644 --- a/src/domain/mocks/handlers.ts +++ b/src/domain/mocks/handlers.ts @@ -204,4 +204,8 @@ export const handlers = [ }), ); }), + + rest.post(`${apiUrl}/kayttajat`, async (req, res, ctx) => { + return res(ctx.status(204)); + }), ]; diff --git a/src/locales/fi.json b/src/locales/fi.json index f9ccb1e90..6b72d2536 100644 --- a/src/locales/fi.json +++ b/src/locales/fi.json @@ -346,6 +346,13 @@ "meta": { "title": "Haitaton - Käyttöohjeet ja tuki" } + }, + "IDENTIFY_USER": { + "path": "/kutsu", + "headerLabel": "Tunnistaudu Haitattomaan", + "meta": { + "title": "Haitaton - Tunnistaudu Haitattomaan" + } } }, "hanke": { @@ -850,7 +857,10 @@ "rightsUpdatedSuccessLabel": "Käyttöoikeudet päivitetty", "rightsUpdatedSuccessText": "Käyttöoikeudet on päivitetty onnistuneesti", "rightsUpdatedErrorLabel": "Virhe päivityksessä", - "rightsUpdatedErrorText": "<0>Käyttöoikeuksien päivityksessä tapahtui virhe. Yritä myöhemmin uudelleen tai ota yhteyttä Haitattoman tekniseen tukeen sähköpostiosoitteessa <1>haitatontuki@hel.fi</1>.</0>" + "rightsUpdatedErrorText": "<0>Käyttöoikeuksien päivityksessä tapahtui virhe. Yritä myöhemmin uudelleen tai ota yhteyttä Haitattoman tekniseen tukeen sähköpostiosoitteessa <1>haitatontuki@hel.fi</1>.</0>", + "userIdentifiedLabel": "Tunnistautuminen onnistui", + "userIdentifiedText": "Tunnistautuminen onnistui. Sinut on nyt lisätty hankkeelle.", + "userIdentifiedError": "Tunnistautuminen epäonnistui" } }, "map": {