From 9be84859e71da7b615e9ee5ff0d1b1677d733b7b Mon Sep 17 00:00:00 2001 From: Marko Haarni Date: Fri, 22 Sep 2023 15:56:53 +0300 Subject: [PATCH 1/2] 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 = () => { } /> } /> } /> + } /> } /> ); 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 = { + 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( + + + + + + + + + + + + + + , + ); +} + +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 {t('hankeUsers:notifications:userIdentifiedError')}; + } + + if (!isAuthenticated) { + return ; + } + + if (isSuccess) { + return ; + } + + 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(`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." + "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.", + "userIdentifiedLabel": "Tunnistautuminen onnistui", + "userIdentifiedText": "Tunnistautuminen onnistui. Sinut on nyt lisätty hankkeelle.", + "userIdentifiedError": "Tunnistautuminen epäonnistui" } }, "map": { From 3568bec042c895fd62726e44da7d46a20ebe7cdc Mon Sep 17 00:00:00 2001 From: Marko Haarni Date: Mon, 25 Sep 2023 15:20:16 +0300 Subject: [PATCH 2/2] HAI-1941 Show hanke name and tunnus in user identification success notification (#375) Added hanke name and tunnus to notification that is shown after successfully identifying user. --- src/domain/auth/components/UserIdentify.test.tsx | 12 ++++++++++-- src/domain/auth/components/UserIdentify.tsx | 4 ++-- src/domain/hanke/hankeUsers/hankeUser.ts | 6 ++++++ src/domain/hanke/hankeUsers/hankeUsersApi.ts | 4 ++-- src/domain/mocks/handlers.ts | 11 +++++++++-- src/locales/fi.json | 2 +- 6 files changed, 30 insertions(+), 9 deletions(-) diff --git a/src/domain/auth/components/UserIdentify.test.tsx b/src/domain/auth/components/UserIdentify.test.tsx index 4bc4824ce..d5915fe54 100644 --- a/src/domain/auth/components/UserIdentify.test.tsx +++ b/src/domain/auth/components/UserIdentify.test.tsx @@ -14,12 +14,14 @@ import { store } from '../../../common/redux/store'; import i18n from '../../../locales/i18n'; import authService from '../authService'; import { REDIRECT_PATH_KEY } from '../../../common/routes/constants'; +import * as hankeUsersApi from '../../hanke/hankeUsers/hankeUsersApi'; afterEach(() => { sessionStorage.clear(); }); -const path = '/fi/kutsu?id=5ArrqPT6kW97QTK7t7ya9PA2'; +const id = '5ArrqPT6kW97QTK7t7ya9PA2'; +const path = `/fi/kutsu?id=${id}`; const mockUser: Partial = { id_token: 'fffff-aaaaaa-11111', @@ -72,9 +74,15 @@ test('Should save path with query string to session storage and navigate to logi test('Should identify user after login', async () => { sessionStorage.setItem(REDIRECT_PATH_KEY, path); jest.spyOn(authService.userManager, 'getUser').mockResolvedValue(mockUser as User); + const identifyUser = jest.spyOn(hankeUsersApi, 'identifyUser'); getWrapper(); await waitFor(() => expect(window.document.title).toBe('Haitaton - Etusivu')); - expect(screen.queryByText('Tunnistautuminen onnistui')).toBeInTheDocument(); + expect(identifyUser).toHaveBeenCalledWith(id); + expect( + screen.queryByText( + 'Tunnistautuminen onnistui. Sinut on nyt lisätty hankkeelle (Aidasmäentien vesihuollon rakentaminen HAI22-2).', + ), + ).toBeInTheDocument(); }); diff --git a/src/domain/auth/components/UserIdentify.tsx b/src/domain/auth/components/UserIdentify.tsx index 802f021dd..947a67b64 100644 --- a/src/domain/auth/components/UserIdentify.tsx +++ b/src/domain/auth/components/UserIdentify.tsx @@ -31,10 +31,10 @@ function UserIdentify() { useEffect(() => { if (isAuthenticated && id !== null && !identifyUserCalled.current) { mutate(id, { - onSuccess() { + onSuccess({ hankeNimi, hankeTunnus }) { setNotification(true, { label: t('hankeUsers:notifications:userIdentifiedLabel'), - message: t('hankeUsers:notifications:userIdentifiedText'), + message: t('hankeUsers:notifications:userIdentifiedText', { hankeNimi, hankeTunnus }), type: 'success', dismissible: true, closeButtonLabelText: t('common:components:notification:closeButtonLabelText'), diff --git a/src/domain/hanke/hankeUsers/hankeUser.ts b/src/domain/hanke/hankeUsers/hankeUser.ts index 75d4f7866..b64ec1faa 100644 --- a/src/domain/hanke/hankeUsers/hankeUser.ts +++ b/src/domain/hanke/hankeUsers/hankeUser.ts @@ -30,3 +30,9 @@ export type SignedInUser = { kayttooikeustaso: keyof typeof AccessRightLevel; kayttooikeudet: UserRights; }; + +export type IdentificationResponse = { + kayttajaId: string; + hankeTunnus: string; + hankeNimi: string; +}; diff --git a/src/domain/hanke/hankeUsers/hankeUsersApi.ts b/src/domain/hanke/hankeUsers/hankeUsersApi.ts index 2b4a39ee7..af7e728c6 100644 --- a/src/domain/hanke/hankeUsers/hankeUsersApi.ts +++ b/src/domain/hanke/hankeUsers/hankeUsersApi.ts @@ -1,5 +1,5 @@ import api from '../../api/api'; -import { HankeUser, SignedInUser } from './hankeUser'; +import { HankeUser, IdentificationResponse, SignedInUser } from './hankeUser'; export async function getHankeUsers(hankeTunnus: string) { const { data } = await api.get<{ kayttajat: HankeUser[] }>(`hankkeet/${hankeTunnus}/kayttajat`); @@ -24,6 +24,6 @@ export async function getSignedInUser(hankeTunnus?: string): Promise('kayttajat', { tunniste: id }); return data; } diff --git a/src/domain/mocks/handlers.ts b/src/domain/mocks/handlers.ts index 00167b154..9178c69a9 100644 --- a/src/domain/mocks/handlers.ts +++ b/src/domain/mocks/handlers.ts @@ -5,7 +5,7 @@ import * as hankkeetDB from './data/hankkeet'; import * as hakemuksetDB from './data/hakemukset'; import * as usersDB from './data/users'; import ApiError from './apiError'; -import { SignedInUser } from '../hanke/hankeUsers/hankeUser'; +import { IdentificationResponse, SignedInUser } from '../hanke/hankeUsers/hankeUser'; const apiUrl = '/api'; @@ -206,6 +206,13 @@ export const handlers = [ }), rest.post(`${apiUrl}/kayttajat`, async (req, res, ctx) => { - return res(ctx.status(204)); + return res( + ctx.status(200), + ctx.json({ + kayttajaId: '3fa85f64-5717-4562-b3fc-2c963f66afa6', + hankeTunnus: 'HAI22-2', + hankeNimi: 'Aidasmäentien vesihuollon rakentaminen', + }), + ); }), ]; diff --git a/src/locales/fi.json b/src/locales/fi.json index 6b72d2536..504d3125e 100644 --- a/src/locales/fi.json +++ b/src/locales/fi.json @@ -859,7 +859,7 @@ "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.", "userIdentifiedLabel": "Tunnistautuminen onnistui", - "userIdentifiedText": "Tunnistautuminen onnistui. Sinut on nyt lisätty hankkeelle.", + "userIdentifiedText": "Tunnistautuminen onnistui. Sinut on nyt lisätty hankkeelle ({{hankeNimi}} {{hankeTunnus}}).", "userIdentifiedError": "Tunnistautuminen epäonnistui" } },