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": {