From 1c5b9a9c8e0f95f95fee91261da56d336ad125ac Mon Sep 17 00:00:00 2001 From: Henri Nieminen Date: Thu, 29 Aug 2024 12:55:51 +0300 Subject: [PATCH 01/13] update hds-react to 3.9.0 --- package.json | 2 +- yarn.lock | 141 +++++++++++++++++++++++++++++++++++++++++++++++---- 2 files changed, 133 insertions(+), 10 deletions(-) diff --git a/package.json b/package.json index ed8909fd..48fe8443 100644 --- a/package.json +++ b/package.json @@ -27,7 +27,7 @@ "file-saver": "^2.0.5", "font-awesome": "^4.7.0", "foundation-sites": "6.5.3", - "hds-react": "^3.8.0", + "hds-react": "^3.9.0", "history": "^4.9.0", "leaflet": "^1.5.1", "leaflet-draw": "^1.0.4", diff --git a/yarn.lock b/yarn.lock index 1616b7d6..f0642c4b 100644 --- a/yarn.lock +++ b/yarn.lock @@ -23,6 +23,26 @@ "@jridgewell/gen-mapping" "^0.3.5" "@jridgewell/trace-mapping" "^0.3.24" +"@apollo/client@^3.10.1": + version "3.11.5" + resolved "https://registry.yarnpkg.com/@apollo/client/-/client-3.11.5.tgz#6645a716f28e9c712912de369ac0f9d74e163762" + integrity sha512-gmTKgXhYH2Q3VT9vUWChuMy34gfK7n/EEJYc7kXt1GP7678Vz2L0xUlHSMEoPoqit317eamZjXQSyxlpn03lnQ== + dependencies: + "@graphql-typed-document-node/core" "^3.1.1" + "@wry/caches" "^1.0.0" + "@wry/equality" "^0.5.6" + "@wry/trie" "^0.5.0" + graphql-tag "^2.12.6" + hoist-non-react-statics "^3.3.2" + optimism "^0.18.0" + prop-types "^15.7.2" + rehackt "^0.1.0" + response-iterator "^0.2.6" + symbol-observable "^4.0.0" + ts-invariant "^0.10.3" + tslib "^2.3.0" + zen-observable-ts "^1.2.5" + "@babel/code-frame@^7.0.0": version "7.10.1" resolved "https://registry.yarnpkg.com/@babel/code-frame/-/code-frame-7.10.1.tgz#d5481c5095daa1c57e16e54c6f9198443afb49ff" @@ -1355,6 +1375,11 @@ resolved "https://registry.yarnpkg.com/@eslint/js/-/js-9.9.0.tgz#d8437adda50b3ed4401964517b64b4f59b0e2638" integrity sha512-hhetes6ZHP3BlXLxmd8K2SNgkhNSi+UcecbnwWKwpP7kyi/uC75DJ1lOOBO3xrC4jyojtGE3YxKZPHfk4yrgug== +"@graphql-typed-document-node/core@^3.1.1": + version "3.2.0" + resolved "https://registry.yarnpkg.com/@graphql-typed-document-node/core/-/core-3.2.0.tgz#5f3d96ec6b2354ad6d8a28bf216a1d97b5426861" + integrity sha512-mB9oAsNCm9aM3/SOv4YtBMqZbYj10R7dkq8byBqxGY/ncFwhf2oQzMV+LCRlWoDSEBJ3COiR1yeDvMtsoOsuFQ== + "@hookform/resolvers@^2.9.11": version "2.9.11" resolved "https://registry.yarnpkg.com/@hookform/resolvers/-/resolvers-2.9.11.tgz#9ce96e7746625a89239f68ca57c4f654264c17ef" @@ -2171,6 +2196,41 @@ loupe "^3.1.1" tinyrainbow "^1.2.0" +"@wry/caches@^1.0.0": + version "1.0.1" + resolved "https://registry.yarnpkg.com/@wry/caches/-/caches-1.0.1.tgz#8641fd3b6e09230b86ce8b93558d44cf1ece7e52" + integrity sha512-bXuaUNLVVkD20wcGBWRyo7j9N3TxePEWFZj2Y+r9OoUzfqmavM84+mFykRicNsBqatba5JLay1t48wxaXaWnlA== + dependencies: + tslib "^2.3.0" + +"@wry/context@^0.7.0": + version "0.7.4" + resolved "https://registry.yarnpkg.com/@wry/context/-/context-0.7.4.tgz#e32d750fa075955c4ab2cfb8c48095e1d42d5990" + integrity sha512-jmT7Sb4ZQWI5iyu3lobQxICu2nC/vbUhP0vIdd6tHC9PTfenmRmuIFqktc6GH9cgi+ZHnsLWPvfSvc4DrYmKiQ== + dependencies: + tslib "^2.3.0" + +"@wry/equality@^0.5.6": + version "0.5.7" + resolved "https://registry.yarnpkg.com/@wry/equality/-/equality-0.5.7.tgz#72ec1a73760943d439d56b7b1e9985aec5d497bb" + integrity sha512-BRFORjsTuQv5gxcXsuDXx6oGRhuVsEGwZy6LOzRRfgu+eSfxbhUQ9L9YtSEIuIjY/o7g3iWFjrc5eSY1GXP2Dw== + dependencies: + tslib "^2.3.0" + +"@wry/trie@^0.4.3": + version "0.4.3" + resolved "https://registry.yarnpkg.com/@wry/trie/-/trie-0.4.3.tgz#077d52c22365871bf3ffcbab8e95cb8bc5689af4" + integrity sha512-I6bHwH0fSf6RqQcnnXLJKhkSXG45MFral3GxPaY4uAl0LYDZM+YDVDAiU9bYwjTuysy1S0IeecWtmq1SZA3M1w== + dependencies: + tslib "^2.3.0" + +"@wry/trie@^0.5.0": + version "0.5.0" + resolved "https://registry.yarnpkg.com/@wry/trie/-/trie-0.5.0.tgz#11e783f3a53f6e4cd1d42d2d1323f5bc3fa99c94" + integrity sha512-FNoYzHawTMk/6KMQoEG5O4PuioX19UbwdQKF44yw0nLfOypfQdjtfZzo/UIJWAJ23sNIFbD1Ug9lbaDGMwbqQA== + dependencies: + tslib "^2.3.0" + acorn-jsx@^5.3.2: version "5.3.2" resolved "https://registry.yarnpkg.com/acorn-jsx/-/acorn-jsx-5.3.2.tgz#7ed5bb55908b3b2f1bc55c6af1653bada7f07937" @@ -3832,6 +3892,18 @@ graphemer@^1.4.0: resolved "https://registry.yarnpkg.com/graphemer/-/graphemer-1.4.0.tgz#fb2f1d55e0e3a1849aeffc90c4fa0dd53a0e66c6" integrity sha512-EtKwoO6kxCL9WO5xipiHTZlSzBm7WLT627TqC/uVRd0HKmq8NXyebnNYxDoBi7wt8eTWrUrKXCOVaFq9x1kgag== +graphql-tag@^2.12.6: + version "2.12.6" + resolved "https://registry.yarnpkg.com/graphql-tag/-/graphql-tag-2.12.6.tgz#d441a569c1d2537ef10ca3d1633b48725329b5f1" + integrity sha512-FdSNcu2QQcWnM2VNvSCCDCVS5PpPqpzgFT8+GXzqJuoDd0CBncxCY278u4mhRO7tMgo2JjgJA5aZ+nWSQ/Z+xg== + dependencies: + tslib "^2.1.0" + +graphql@^16.8.1: + version "16.9.0" + resolved "https://registry.yarnpkg.com/graphql/-/graphql-16.9.0.tgz#1c310e63f16a49ce1fbb230bd0a000e99f6f115f" + integrity sha512-GGTKBX4SD7Wdb8mqeDLni2oaRGYQWjWHGKPQ24ZMnUtKfcsVoiv4uX8+LJr1K6U5VW2Lu1BwJnj7uiori0YtRw== + gud@^1.0.0: version "1.0.0" resolved "https://registry.yarnpkg.com/gud/-/gud-1.0.0.tgz#a489581b17e6a70beca9abe3ae57de7a499852c0" @@ -3921,16 +3993,17 @@ hasown@^2.0.1, hasown@^2.0.2: dependencies: function-bind "^1.1.2" -hds-core@3.8.0: - version "3.8.0" - resolved "https://registry.yarnpkg.com/hds-core/-/hds-core-3.8.0.tgz#39003a0c129de767239fc1f904d89b6424edc9a1" - integrity sha512-T0+/tkyT5WCbGYHnSFXZeBE4wRu4PjD29njb2ANfiYFwlQfdkLGx4hCod0bjFb9kde/RktRwhXadwOwpmVg3dA== +hds-core@3.9.0: + version "3.9.0" + resolved "https://registry.yarnpkg.com/hds-core/-/hds-core-3.9.0.tgz#1833dc137aaadf25bec33b6377e382be772e3fea" + integrity sha512-vAB1n5AV9GsZJAUROli+yHpty7EiGeUx4z6NsOdR6vv0pLonM1xC3vyFEYMJ4/cHLFtxgh4M2t8Pl9TMEJP+sw== -hds-react@^3.8.0: - version "3.8.0" - resolved "https://registry.yarnpkg.com/hds-react/-/hds-react-3.8.0.tgz#03f65f744f0192af7097846f447621b62fd52681" - integrity sha512-nb8c2eUcLdFRvGg+pnyaUZbBm8V5cfDp20emHQQWxbMmo+R3aj6Azc8tM02fjkSkqr/+oqL6xvz2AIxbIDqkqA== +hds-react@^3.9.0: + version "3.9.0" + resolved "https://registry.yarnpkg.com/hds-react/-/hds-react-3.9.0.tgz#6f8c77f63c2688847933d94f27c0d55b8f468c3b" + integrity sha512-n3ADTSc+KkuJAU7H303K83bn8gWaJltwTXpCuOfDRDd+wteFYfqOUeJtnNpK2WbCQEKRzQXPNHELFRo6cGJvdw== dependencies: + "@apollo/client" "^3.10.1" "@babel/runtime" "7.17.9" "@emotion/styled-base" "^11.0.0" "@hookform/resolvers" "^2.9.11" @@ -3945,7 +4018,8 @@ hds-react@^3.8.0: crc-32 "1.2.0" date-fns "2.16.1" downshift "6.0.6" - hds-core "3.8.0" + graphql "^16.8.1" + hds-core "3.9.0" http-status-typed "^1.0.1" jwt-decode "^3.1.2" kashe "1.0.4" @@ -5090,6 +5164,16 @@ onetime@^6.0.0: dependencies: mimic-fn "^4.0.0" +optimism@^0.18.0: + version "0.18.0" + resolved "https://registry.yarnpkg.com/optimism/-/optimism-0.18.0.tgz#e7bb38b24715f3fdad8a9a7fc18e999144bbfa63" + integrity sha512-tGn8+REwLRNFnb9WmcY5IfpOqeX2kpaYJ1s6Ae3mn12AeydLkR3j+jSCmVQFoXqU8D41PAJ1RG1rCRNWmNZVmQ== + dependencies: + "@wry/caches" "^1.0.0" + "@wry/context" "^0.7.0" + "@wry/trie" "^0.4.3" + tslib "^2.3.0" + optionator@^0.9.3: version "0.9.3" resolved "https://registry.yarnpkg.com/optionator/-/optionator-0.9.3.tgz#007397d44ed1872fdc6ed31360190f81814e2c64" @@ -5823,6 +5907,11 @@ regjsparser@^0.9.1: dependencies: jsesc "~0.5.0" +rehackt@^0.1.0: + version "0.1.0" + resolved "https://registry.yarnpkg.com/rehackt/-/rehackt-0.1.0.tgz#a7c5e289c87345f70da8728a7eb878e5d03c696b" + integrity sha512-7kRDOuLHB87D/JESKxQoRwv4DzbIdwkAGQ7p6QKGdVlY1IZheUnVhlk/4UZlNUVxdAXpyxikE3URsG067ybVzw== + requires-port@^1.0.0: version "1.0.0" resolved "https://registry.yarnpkg.com/requires-port/-/requires-port-1.0.0.tgz#925d2601d39ac485e091cf0da5c6e694dc3dcaff" @@ -5873,6 +5962,11 @@ resolve@^2.0.0-next.5: path-parse "^1.0.7" supports-preserve-symlinks-flag "^1.0.0" +response-iterator@^0.2.6: + version "0.2.6" + resolved "https://registry.yarnpkg.com/response-iterator/-/response-iterator-0.2.6.tgz#249005fb14d2e4eeb478a3f735a28fd8b4c9f3da" + integrity sha512-pVzEEzrsg23Sh053rmDUvLSkGXluZio0qu8VT6ukrYuvtjVfCbDZH9d6PGXb8HZfzdNZt8feXv/jvUzlhRgLnw== + reusify@^1.0.4: version "1.0.4" resolved "https://registry.yarnpkg.com/reusify/-/reusify-1.0.4.tgz#90da382b1e126efc02146e90845a88db12925d76" @@ -6414,6 +6508,11 @@ symbol-observable@^1.2.0: resolved "https://registry.yarnpkg.com/symbol-observable/-/symbol-observable-1.2.0.tgz#c22688aed4eab3cdc2dfeacbb561660560a00804" integrity sha512-e900nM8RRtGhlV36KGEU9k65K3mPb1WV70OdjfxlG2EAuM1noi/E/BaW/uMhL7bPEssK8QV57vN3esixjUvcXQ== +symbol-observable@^4.0.0: + version "4.0.0" + resolved "https://registry.yarnpkg.com/symbol-observable/-/symbol-observable-4.0.0.tgz#5b425f192279e87f2f9b937ac8540d1984b39205" + integrity sha512-b19dMThMV4HVFynSAM1++gBHAbk2Tc/osgLIBZMKsyqh34jb2e8Os7T6ZW/Bt3pJFdBTd2JwAnAAEQV7rSNvcQ== + symbol-tree@^3.2.4: version "3.2.4" resolved "https://registry.yarnpkg.com/symbol-tree/-/symbol-tree-3.2.4.tgz#430637d248ba77e078883951fb9aa0eed7c63fa2" @@ -6536,6 +6635,13 @@ ts-api-utils@^1.0.1, ts-api-utils@^1.3.0: resolved "https://registry.yarnpkg.com/ts-api-utils/-/ts-api-utils-1.3.0.tgz#4b490e27129f1e8e686b45cc4ab63714dc60eea1" integrity sha512-UQMIo7pb8WRomKR1/+MFVLTroIvDVtMX3K6OUir8ynLyzB8Jeriont2bTAtmNPa1ekAgN7YPDyf6V+ygrdU+eQ== +ts-invariant@^0.10.3: + version "0.10.3" + resolved "https://registry.yarnpkg.com/ts-invariant/-/ts-invariant-0.10.3.tgz#3e048ff96e91459ffca01304dbc7f61c1f642f6c" + integrity sha512-uivwYcQaxAucv1CzRp2n/QdYPo4ILf9VXgH19zEIjFx2EJufV16P0JtJVpYHy89DItG6Kwj2oIUjrcK5au+4tQ== + dependencies: + tslib "^2.1.0" + ts-node@^10.9.2: version "10.9.2" resolved "https://registry.yarnpkg.com/ts-node/-/ts-node-10.9.2.tgz#70f021c9e185bccdca820e26dc413805c101c71f" @@ -6564,6 +6670,11 @@ tsconfig-paths@^4.2.0: minimist "^1.2.6" strip-bom "^3.0.0" +tslib@^2.1.0, tslib@^2.3.0: + version "2.7.0" + resolved "https://registry.yarnpkg.com/tslib/-/tslib-2.7.0.tgz#d9b40c5c40ab59e8738f297df3087bf1a2690c01" + integrity sha512-gLXCKdN1/j47AiHiOkJN69hJmcbGTHI0ImLmbYLHykhgeN0jVGola9yVjFgzCUklsZQMW55o+dW7IXv3RCXDzA== + tslib@^2.4.0: version "2.6.2" resolved "https://registry.yarnpkg.com/tslib/-/tslib-2.6.2.tgz#703ac29425e7b37cd6fd456e92404d46d1f3e4ae" @@ -7100,3 +7211,15 @@ yup@^1.0.2: tiny-case "^1.0.3" toposort "^2.0.2" type-fest "^2.19.0" + +zen-observable-ts@^1.2.5: + version "1.2.5" + resolved "https://registry.yarnpkg.com/zen-observable-ts/-/zen-observable-ts-1.2.5.tgz#6c6d9ea3d3a842812c6e9519209365a122ba8b58" + integrity sha512-QZWQekv6iB72Naeake9hS1KxHlotfRpe+WGNbNx5/ta+R3DNjVO2bswf63gXlWDcs+EMd7XY8HfVQyP1X6T4Zg== + dependencies: + zen-observable "0.8.15" + +zen-observable@0.8.15: + version "0.8.15" + resolved "https://registry.yarnpkg.com/zen-observable/-/zen-observable-0.8.15.tgz#96415c512d8e3ffd920afd3889604e30b9eaac15" + integrity sha512-PQ2PC7R9rslx84ndNBZB/Dkv8V8fZEpk83RLgXtYd0fwUgEjseMn1Dgajh2x6S8QbZAFa9p2qVCEuYZNgve0dQ== From ffd71cb0dbb448abec019b6c9a017c4da64e5dd5 Mon Sep 17 00:00:00 2001 From: Henri Nieminen Date: Thu, 5 Sep 2024 13:59:58 +0300 Subject: [PATCH 02/13] introduce login and apiToken handling with hds-react LoginProvider --- src/app/App.tsx | 409 ++++++++++++--------------- src/auth/actions.ts | 12 +- src/auth/components/CallbackPage.tsx | 31 +- src/auth/constants.ts | 12 + src/auth/reducer.ts | 18 +- src/auth/selectors.ts | 4 +- src/auth/types.ts | 4 +- src/index.tsx | 13 +- src/root/Root.tsx | 27 -- src/root/configureStore.ts | 1 - src/root/createRootReducer.ts | 5 +- src/root/createRootSaga.ts | 49 +++- 12 files changed, 290 insertions(+), 295 deletions(-) create mode 100644 src/auth/constants.ts delete mode 100644 src/root/Root.tsx diff --git a/src/app/App.tsx b/src/app/App.tsx index 9ce6b073..29974b31 100644 --- a/src/app/App.tsx +++ b/src/app/App.tsx @@ -1,5 +1,5 @@ -import React, { Component } from "react"; -import { connect } from "react-redux"; +import React, { useState, useEffect, useCallback } from "react"; +import { connect, useDispatch } from "react-redux"; import ReduxToastr from "react-redux-toastr"; import { withRouter } from "react-router"; import flowRight from "lodash/flowRight"; @@ -17,10 +17,10 @@ import TopNavigation from "@/components/topNavigation/TopNavigation"; import userManager from "@/auth/util/user-manager"; import { Routes, getRouteById } from "@/root/routes"; import { clearError } from "@/api/actions"; -import { clearApiToken, fetchApiToken } from "@/auth/actions"; +import { userFound, clearUser, receiveApiToken, clearApiToken } from "@/auth/actions"; import { getEpochTime } from "@/util/helpers"; import { getError } from "@/api/selectors"; -import { getApiToken, getApiTokenExpires, getIsFetching, getLoggedInUser } from "@/auth/selectors"; +import { getIsFetching, getLoggedInUser } from "@/auth/selectors"; import { getLinkUrl, getPageTitle, getShowSearch } from "@/components/topNavigation/selectors"; import { getUserGroups, getUserActiveServiceUnit, getUserServiceUnits } from "@/usersPermissions/selectors"; import { setRedirectUrlToSessionStorage } from "@/util/storage"; @@ -29,6 +29,8 @@ import type { ApiToken } from "@/auth/types"; import type { UserGroups, UserServiceUnit, UserServiceUnits } from "@/usersPermissions/types"; import type { RootState } from "@/root/types"; import "@/main.scss"; +import { useApiTokens, useOidcClient, useApiTokensClientTracking, isApiTokensUpdatedSignal, useAuthenticatedUser } from "hds-react"; + const url = window.location.toString(); const IS_DEVELOPMENT_URL = url.includes('ninja') || url.includes('localhost'); type OwnProps = { @@ -36,14 +38,9 @@ type OwnProps = { }; type Props = OwnProps & { apiError: ApiError; - apiToken: ApiToken; - apiTokenExpires: number; - clearApiToken: (...args: Array) => any; clearError: typeof clearError; closeReveal: (...args: Array) => any; - fetchApiToken: (...args: Array) => any; history: Record; - isApiTokenFetching: boolean; linkUrl: string; location: Record; pageTitle: string; @@ -59,272 +56,220 @@ type State = { displayUserGroups: boolean, }; -class App extends Component { - state = { - displaySideMenu: false, - loggedIn: false, - displayUserGroups: false - }; - timerID: any; - - componentWillUnmount() { - this.stopApiTokenTimer(); - } - - startApiTokenTimer = () => { - this.timerID = setInterval(() => this.checkApiToken(), 5000); - }; - stopApiTokenTimer = () => { - clearInterval(this.timerID); - }; +const App: React.FC = (props) => { + const [loggedIn, setLoggedIn] = useState(false); + const [displaySideMenu, setDisplaySideMenu] = useState(false); + const [displayUserGroups, setDisplayUserGroups] = useState(false); + const { login, logout, getUser, isAuthenticated } = useOidcClient(); + const authenticatedUser = useAuthenticatedUser(); + const dispatch = useDispatch(); + const [apiTokensClientSignal, apiTokensClientSignalReset] = useApiTokensClientTracking(); + const { getStoredApiTokens, isRenewing } = useApiTokens(); + + const setLoggedInIfApiTokenExists = useCallback(() => { + // apiToken is required to make requests to the API, therefore we assume that user is "logged in" when they have a token + const [_error, apiToken] = getStoredApiTokens(); + if (apiToken) { + dispatch(receiveApiToken(apiToken)); + setLoggedIn(true); + } + }, [getStoredApiTokens, dispatch]); + + // Handle apiToken fetched or updated, e.g. after login + useEffect(() => { + // apiTokensClientSignal is a required dependency in order to pick up that apiToken was fetched + if (isApiTokensUpdatedSignal(apiTokensClientSignal)) { + setLoggedInIfApiTokenExists(); + apiTokensClientSignalReset(); + } + return apiTokensClientSignalReset; + }, [apiTokensClientSignal, getStoredApiTokens, dispatch]); + + // Handle apiToken already exists in session storage + useEffect(() => { + // isAuthenticated checks only that the user is authenticated, but apiToken is required to make requests to API + if (isAuthenticated()) { + dispatch(userFound(authenticatedUser)); + // Must ensure that apiToken exists before making any requests to API + setLoggedInIfApiTokenExists(); + } + }, [authenticatedUser, dispatch]); - componentDidUpdate(prevProps: Props) { + useEffect(() => { const { apiError, - apiToken, - clearApiToken, - fetchApiToken, - history, - isApiTokenFetching, - user - } = this.props; - const { - loggedIn - } = this.state; - + } = props; if (apiError) { return; } + }, [props.apiError]); - // Fetch api token if user info is received but Api token is empty - if (!isApiTokenFetching && user && user.access_token && (isEmpty(apiToken) || user.access_token !== get(prevProps, 'user.access_token'))) { - fetchApiToken(user.access_token); - this.startApiTokenTimer(); - return; - } - - if (apiToken && !prevProps.apiToken) { - this.setState({ - loggedIn: true - }); - } - - // Clear API token when user has logged out - if (!user && !isEmpty(apiToken)) { - clearApiToken(); - this.stopApiTokenTimer(); - - // If user has pressed logout button move to lease list page - if (!loggedIn) { - history.push(getRouteById(Routes.LEASES)); - } - } - } - - handleLogin = (event: any) => { - const { - location: { - pathname, - search - } - } = this.props; - event.preventDefault(); - userManager.signinRedirect(); + const handleLogin = () => { + const { pathname, search } = props.location; setRedirectUrlToSessionStorage(`${pathname}${search}` || getRouteById(Routes.LEASES)); + login(); }; - logOut = () => { - this.setState({ - loggedIn: false - }, () => { - userManager.removeUser(); - sessionStorage.clear(); - }); - }; - - checkApiToken() { - const { - apiTokenExpires, - fetchApiToken - } = this.props; - if (apiTokenExpires <= getEpochTime() && get(this.props, 'user.access_token')) { - fetchApiToken(this.props.user.access_token); - } - } + const logOut = () => { + setLoggedIn(false); + dispatch(clearUser()); + dispatch(clearApiToken()); + logout(); + }; - toggleSideMenu = () => { - return this.setState({ - displaySideMenu: !this.state.displaySideMenu - }); + const toggleSideMenu = () => { + setDisplaySideMenu(!displaySideMenu); }; - toggleDisplayUserGroups = () => { - this.setState({ - displayUserGroups: !this.state.displayUserGroups, - }); + const toggleDisplayUserGroups = () => { + setDisplayUserGroups(!displayUserGroups); }; - handleDismissErrorModal = () => { - this.props.closeReveal('apiError'); - this.props.clearError(); + const handleDismissErrorModal = () => { + props.closeReveal('apiError'); + props.clearError(); }; - render() { - const { - apiError, - apiToken, - children, - isApiTokenFetching, - linkUrl, - location, - pageTitle, - showSearch, - user, - userGroups, - userActiveServiceUnit, - userServiceUnits - } = this.props; - const { - displaySideMenu, - displayUserGroups - } = this.state; - const appStyle = IS_DEVELOPMENT_URL ? 'app-dev' : 'app'; - if (isEmpty(user) || isEmpty(apiToken)) { - return
- + const { + apiError, + children, + linkUrl, + location, + pageTitle, + showSearch, + userGroups, + userActiveServiceUnit, + userServiceUnits + } = props; - + const user = getUser(); + const appStyle = IS_DEVELOPMENT_URL ? 'app-dev' : 'app'; - - + if (!loggedIn) { + return
+ - {location.pathname === getRouteById(Routes.CALLBACK) && children} -
; - } + - return - - {({ - isConfirmationModalOpen, - confirmationFunction, - confirmationModalButtonClassName, - confirmationModalButtonText, - confirmationModalLabel, - confirmationModalTitle, - dispatch - }) => { - const handleConfirmation = () => { - confirmationFunction?.(); - handleHideConfirmationModal(); - }; + + - const handleHideConfirmationModal = () => { - dispatch({ - type: ActionTypes.HIDE_CONFIRMATION_MODAL - }); - }; - - return
- - - - - - - - - {displayUserGroups && -
- Käyttäjäryhmät ja palvelukokonaisuudet - {userGroups && userGroups.length > 1 && - userGroups.map((group, index) => { - return ( -

- {group} -

- ); - })} -
- } - -
- -
- {children} -
-
-
; - }} -
-
; + {location.pathname === getRouteById(Routes.CALLBACK) && children} +
; } -} + return + + {({ + isConfirmationModalOpen, + confirmationFunction, + confirmationModalButtonClassName, + confirmationModalButtonText, + confirmationModalLabel, + confirmationModalTitle, + dispatch + }) => { + const handleConfirmation = () => { + confirmationFunction?.(); + handleHideConfirmationModal(); + }; + + const handleHideConfirmationModal = () => { + dispatch({ + type: ActionTypes.HIDE_CONFIRMATION_MODAL + }); + }; + + return
+ + + + + + + + + {displayUserGroups && +
+ Käyttäjäryhmät ja palvelukokonaisuudet + {userGroups && userGroups.length > 1 && + userGroups.map((group, index) => { + return ( +

+ {group} +

+ ); + })} +
+ } + +
+ +
+ {children} +
+
+
; + }} +
+
; +}; const mapStateToProps = (state: RootState) => { const user = getLoggedInUser(state); - - if (!user || user.expired) { + if (!user) { return { - apiToken: getApiToken(state), pageTitle: getPageTitle(state), showSearch: getShowSearch(state), user: null }; } - return { apiError: getError(state), - apiToken: getApiToken(state), - apiTokenExpires: getApiTokenExpires(state), - isApiTokenFetching: getIsFetching(state), linkUrl: getLinkUrl(state), pageTitle: getPageTitle(state), showSearch: getShowSearch(state), - user, userGroups: getUserGroups(state), userServiceUnits: getUserServiceUnits(state), userActiveServiceUnit: getUserActiveServiceUnit(state) }; }; -export default (flowRight(withRouter, connect(mapStateToProps, { +export default flowRight(withRouter, connect(mapStateToProps, { clearError, - clearApiToken, - fetchApiToken -}), revealContext())(App) as React.ComponentType); \ No newline at end of file +}), revealContext())(App) as React.ComponentType; \ No newline at end of file diff --git a/src/auth/actions.ts b/src/auth/actions.ts index a951f473..84c48525 100644 --- a/src/auth/actions.ts +++ b/src/auth/actions.ts @@ -1,6 +1,12 @@ -import { createAction } from "redux-actions"; -import type { ClearApiTokenAction, FetchApiTokenAction, ReceiveApiTokenAction, TokenNotFoundAction } from "./types"; +import { createAction } from 'redux-actions'; +import type { Action } from 'redux'; +import type { User } from 'hds-react'; +import type { ClearApiTokenAction, FetchApiTokenAction, ReceiveApiTokenAction, TokenNotFoundAction } from './types'; export const clearApiToken = (): ClearApiTokenAction => createAction('mvj/auth/CLEAR_API_TOKEN')(); export const fetchApiToken = (accessToken: string): FetchApiTokenAction => createAction('mvj/auth/FETCH_API_TOKEN')(accessToken); export const receiveApiToken = (token: Record): ReceiveApiTokenAction => createAction('mvj/auth/RECEIVE_API_TOKEN')(token); -export const tokenNotFound = (): TokenNotFoundAction => createAction('mvj/auth/TOKEN_NOT_FOUND')(); \ No newline at end of file +export const tokenNotFound = (): TokenNotFoundAction => createAction('mvj/auth/TOKEN_NOT_FOUND')(); +export const userFound = (user: User): Action => + createAction('mvj/auth/USER_FOUND')(user); +export const clearUser = (): Action => + createAction('mvj/auth/USER_CLEAR')(null); \ No newline at end of file diff --git a/src/auth/components/CallbackPage.tsx b/src/auth/components/CallbackPage.tsx index effcbba8..a0393d0c 100644 --- a/src/auth/components/CallbackPage.tsx +++ b/src/auth/components/CallbackPage.tsx @@ -1,33 +1,30 @@ -import React, { PureComponent } from "react"; +import React from "react"; import { withRouter } from "react-router"; import { CallbackComponent, CallbackComponentProps } from "redux-oidc"; +import { LoginProvider, LoginCallbackHandler, isHandlingLoginCallbackError } from "hds-react"; +import type { OidcClientError, User } from "hds-react"; import { getRedirectUrlFromSessionStorage } from "@/util/storage"; import userManager from "@/auth/util/user-manager"; import { getRouteById, Routes } from "@/root/routes"; + type Props = { history: Record; }; -const CallbackComponentWithChildren = ({ children, ...rest }: CallbackComponentProps & { children: JSX.Element }): JSX.Element => { - return - {children} - ; -} - -class CallbackPage extends PureComponent { - successCallback = () => { - const { - history - } = this.props; +const CallbackPage = (props: Props) => { + const onSuccess = (user: User) => { + const { history } = props; history.push(getRedirectUrlFromSessionStorage() || getRouteById(Routes.LEASES)); }; + const onError = (error: OidcClientError) => { + console.error("Login Callback Error:", error); + }; - render() { - return + return ( +
-
; - } - + + ) } export default withRouter(CallbackPage); \ No newline at end of file diff --git a/src/auth/constants.ts b/src/auth/constants.ts new file mode 100644 index 00000000..f47aca6b --- /dev/null +++ b/src/auth/constants.ts @@ -0,0 +1,12 @@ +import type { LoginProviderProps } from 'hds-react'; + +export const loginProviderProperties: LoginProviderProps = { + userManagerSettings: { + authority: import.meta.env.VITE_OPENID_CONNECT_AUTHORITY_URL || 'https://api.hel.fi/sso/openid/', + client_id: import.meta.env.VITE_OPENID_CONNECT_CLIENT_ID || '', + scope: import.meta.env.VITE_OPENID_CONNECT_SCOPE || 'openid profile https://api.hel.fi/auth/mvj', + redirect_uri: `${location.origin}/callback`, + }, + apiTokensClientSettings: { url: import.meta.env.VITE_OPENID_CONNECT_API_TOKEN_URL }, + sessionPollerSettings: { pollIntervalInMs: 300000 } // 300000ms = 5min +}; diff --git a/src/auth/reducer.ts b/src/auth/reducer.ts index d5de7ff9..43ae4fa5 100644 --- a/src/auth/reducer.ts +++ b/src/auth/reducer.ts @@ -1,7 +1,7 @@ import { handleActions } from "redux-actions"; import { combineReducers } from "redux"; import type { Reducer } from "@/types"; -import type { ReceiveApiTokenAction } from "./types"; +import type { ReceiveApiTokenAction, ReceiveUserAction } from "./types"; const isFetchingReducer: Reducer = handleActions({ 'mvj/auth/FETCH_API_TOKEN': () => true, 'mvj/auth/TOKEN_NOT_FOUND': () => false, @@ -16,4 +16,18 @@ const apiTokenReducer: Reducer = handleActions({ export default combineReducers, any>({ apiToken: apiTokenReducer, isFetching: isFetchingReducer -}); \ No newline at end of file +}); + +const userReducer: Reducer = handleActions( + { + 'mvj/auth/USER_FOUND': (state: any, { + payload + }: ReceiveUserAction) => payload, + 'mvj/auth/CLEAR_USER': () => null, + }, + null +); + +export const oidcReducer = combineReducers({ + user: userReducer, +}); diff --git a/src/auth/selectors.ts b/src/auth/selectors.ts index b4d7ed56..8c69f9ce 100644 --- a/src/auth/selectors.ts +++ b/src/auth/selectors.ts @@ -1,7 +1,7 @@ import type { Selector } from "@/types"; import type { ApiToken, AuthState } from "./types"; +import type { User } from 'hds-react'; // Helper functions to select state export const getApiToken: Selector = (state: Record): AuthState => state.auth.apiToken[import.meta.env.VITE_OPENID_CONNECT_API_TOKEN_KEY || 'https://api.hel.fi/auth/mvj']; -export const getApiTokenExpires: Selector = (state: Record): AuthState => state.auth.apiToken['expires_at']; export const getIsFetching: Selector = (state: Record): AuthState => state.auth.isFetching; -export const getLoggedInUser: Selector, void> = (state: Record): Record => state.oidc.user; \ No newline at end of file +export const getLoggedInUser: Selector, void> = (state: Record): User | null => state.oidc.user; \ No newline at end of file diff --git a/src/auth/types.ts b/src/auth/types.ts index 161cacc4..abbf2c47 100644 --- a/src/auth/types.ts +++ b/src/auth/types.ts @@ -1,7 +1,9 @@ import type { Action } from "@/types"; +import type { User } from "hds-react"; export type ApiToken = Record | null; export type AuthState = Record | null; export type FetchApiTokenAction = Action; export type ReceiveApiTokenAction = Action>; export type ClearApiTokenAction = Action; -export type TokenNotFoundAction = Action; \ No newline at end of file +export type TokenNotFoundAction = Action; +export type ReceiveUserAction = Action; \ No newline at end of file diff --git a/src/index.tsx b/src/index.tsx index bc1ddc7c..44449dec 100644 --- a/src/index.tsx +++ b/src/index.tsx @@ -3,25 +3,26 @@ import ReactDOM from 'react-dom'; import { Provider } from 'react-redux'; import { ConnectedRouter } from 'connected-react-router'; import { OidcProvider } from 'redux-oidc'; +import { LoginProvider } from 'hds-react'; import configureStore, { history } from '@/root/configureStore'; import routes from '@/root/routes'; import userManager from '@/auth/util/user-manager'; +import { loginProviderProperties } from '@/auth/constants'; import './polyfills'; + + export const store = configureStore(); const container = document.getElementById('root'); + ReactDOM.render( - {/* - // @ts-ignore: Children not included in type error */} - - {/* - // @ts-ignore: Children not included in type error */} + {routes} - + , container ); \ No newline at end of file diff --git a/src/root/Root.tsx b/src/root/Root.tsx deleted file mode 100644 index 9a194cb5..00000000 --- a/src/root/Root.tsx +++ /dev/null @@ -1,27 +0,0 @@ -import React from "react"; -import { Provider } from "react-redux"; -import { ConnectedRouter } from "connected-react-router"; -import { OidcProvider } from "redux-oidc"; -import userManager from "@/auth/util/user-manager"; -import routes from "./routes"; -export type RootProps = { - history: any; - store: any; -}; - -const Root = ({ - history, - store -}: RootProps) => - {/* - // @ts-ignore: Children not included in type error */} - - {/* - // @ts-ignore: Children not included in type error */} - - {routes} - - - ; - -export default Root; \ No newline at end of file diff --git a/src/root/configureStore.ts b/src/root/configureStore.ts index 1022a6c6..ff666a87 100644 --- a/src/root/configureStore.ts +++ b/src/root/configureStore.ts @@ -22,7 +22,6 @@ export default (() => { const sagaMiddleware = createSagaMiddleware(); const enhancer = compose(applyMiddleware(sagaMiddleware, routerMiddleware), window.__REDUX_DEVTOOLS_EXTENSION__ ? window.__REDUX_DEVTOOLS_EXTENSION__() : f => f); const store = createStore(rootReducer, enhancer); - loadUser(store, userManager); sagaMiddleware.run(rootSaga); return store; diff --git a/src/root/createRootReducer.ts b/src/root/createRootReducer.ts index c0eed24d..74b55b09 100644 --- a/src/root/createRootReducer.ts +++ b/src/root/createRootReducer.ts @@ -1,13 +1,12 @@ import { combineReducers } from "redux"; import { reducer as formReducer } from "redux-form"; -import { reducer as oidc } from "redux-oidc"; import { reducer as toastrReducer } from "react-redux-toastr"; import { connectRouter } from "connected-react-router"; import apiReducer from "@/api/reducer"; import areaNoteReducer from "@/areaNote/reducer"; import areaSearchReducer from "@/areaSearch/reducer"; import auditLogReducer from "@/auditLog/reducer"; -import authReducer from "@/auth/reducer"; +import authReducer, { oidcReducer } from "@/auth/reducer"; import batchrunReducer from "@/batchrun/reducer"; import billingPeriodReducer from "@/billingPeriods/reducer"; import collectionCourtDecisionReducer from "@/collectionCourtDecision/reducer"; @@ -87,7 +86,7 @@ export default ((history: Record): Reducer => combineRed leaseStatisticReport: leaseStatisticReportReducer, leaseType: leaseTypeReducer, lessor: lessorReducer, - oidc, + oidc: oidcReducer, penaltyInterest: penaltyInterestReducer, previewInvoices: previewInvoicesReducer, rentBasis: rentBasisReducer, diff --git a/src/root/createRootSaga.ts b/src/root/createRootSaga.ts index 3b2b4c1b..269d154f 100644 --- a/src/root/createRootSaga.ts +++ b/src/root/createRootSaga.ts @@ -49,5 +49,52 @@ import applicationSaga from "@/application/saga"; export default (() => function* rootSaga() { - yield all([fork(areaNoteSaga), fork(areaSearchSaga), fork(auditLogSaga), fork(authSaga), fork(batchrunSaga), fork(billingPeriodsSaga), fork(collectionCourtDecisionSaga), fork(collectionLetterSaga), fork(collectionNoteSaga), fork(commentSaga), fork(contactSaga), fork(contractFileSaga), fork(createCollectionLetterSaga), fork(creditDecisionSaga), fork(districtSaga), fork(indexSaga), fork(infillDevelopmentSaga), fork(infillDevelopmentAttachmentSaga), fork(invoiceSaga), fork(landUseinvoiceSaga), fork(invoiceNoteSaga), fork(invoiceSetSaga), fork(landUseContractSaga), fork(landUseAgreementAttachmentSaga), fork(leaseSaga), fork(leaseAreaAttachmentSaga), fork(leaseCreateChargeSaga), fork(leaseholdTransferSaga), fork(leaseInspectionAttachmentSaga), fork(leaseStatisticReportSaga), fork(leaseTypeSaga), fork(lessorSaga), fork(penaltyInterestSaga), fork(previewInvoicesSaga), fork(relatedLeaseSaga), fork(rentBasisSaga), fork(rentForPeriodSaga), fork(sapInvoicesSaga), fork(serviceUnitsSaga), fork(tradeRegisterSaga), fork(uiDataSaga), fork(userSaga), fork(usersPermissionsSaga), fork(vatSaga), fork(plotSearchSaga), fork(plotApplicationsSaga), fork(applicationSaga)]); + yield all([ + fork(areaNoteSaga), + fork(areaSearchSaga), + fork(auditLogSaga), + fork(batchrunSaga), + fork(billingPeriodsSaga), + fork(collectionCourtDecisionSaga), + fork(collectionLetterSaga), + fork(collectionNoteSaga), + fork(commentSaga), + fork(contactSaga), + fork(contractFileSaga), + fork(createCollectionLetterSaga), + fork(creditDecisionSaga), + fork(districtSaga), + fork(indexSaga), + fork(infillDevelopmentSaga), + fork(infillDevelopmentAttachmentSaga), + fork(invoiceSaga), + fork(landUseinvoiceSaga), + fork(invoiceNoteSaga), + fork(invoiceSetSaga), + fork(landUseContractSaga), + fork(landUseAgreementAttachmentSaga), + fork(leaseSaga), + fork(leaseAreaAttachmentSaga), + fork(leaseCreateChargeSaga), + fork(leaseholdTransferSaga), + fork(leaseInspectionAttachmentSaga), + fork(leaseStatisticReportSaga), + fork(leaseTypeSaga), + fork(lessorSaga), + fork(penaltyInterestSaga), + fork(previewInvoicesSaga), + fork(relatedLeaseSaga), + fork(rentBasisSaga), + fork(rentForPeriodSaga), + fork(sapInvoicesSaga), + fork(serviceUnitsSaga), + fork(tradeRegisterSaga), + fork(uiDataSaga), + fork(userSaga), + fork(usersPermissionsSaga), + fork(vatSaga), + fork(plotSearchSaga), + fork(plotApplicationsSaga), + fork(applicationSaga)] + ); }); \ No newline at end of file From 0a13454ef1b47422a7e2dd2543f1ffa4c0c244f1 Mon Sep 17 00:00:00 2001 From: Henri Nieminen Date: Thu, 5 Sep 2024 15:46:11 +0300 Subject: [PATCH 03/13] clear old auth implementation, remove oidc-client & redux-oidc add new silent_renew.html from HDS docs remove unneeded tests, add a few semiuseful reducer/action tests --- package.json | 2 - public/silent_renew.html | 20 +++++++++ src/app/App.tsx | 6 +-- src/auth/actions.ts | 6 +-- src/auth/auth.spec.ts | 61 +++++++++++++++++----------- src/auth/components/CallbackPage.tsx | 4 +- src/auth/reducer.ts | 10 ++--- src/auth/saga.ts | 49 ---------------------- src/auth/selectors.ts | 1 - src/auth/types.ts | 2 - src/auth/util/user-manager.ts | 50 ----------------------- src/index.tsx | 2 - src/root/configureStore.ts | 2 - src/root/createRootReducer.ts | 2 +- src/root/createRootSaga.ts | 1 - src/silent_renew.html | 9 ---- src/silent_renew.ts | 2 - src/util/helpers.tsx | 6 --- src/util/util.spec.ts | 5 +-- yarn.lock | 42 ------------------- 20 files changed, 67 insertions(+), 215 deletions(-) create mode 100644 public/silent_renew.html delete mode 100644 src/auth/saga.ts delete mode 100644 src/auth/util/user-manager.ts delete mode 100644 src/silent_renew.html delete mode 100644 src/silent_renew.ts diff --git a/package.json b/package.json index 48fe8443..d7e52d72 100644 --- a/package.json +++ b/package.json @@ -36,7 +36,6 @@ "leaflet-zoombox": "^0.5.1", "lodash": "^4.17.21", "normalize.css": "^8.0.1", - "oidc-client": "^1.8.2", "proj4": "^2.5.0", "proj4leaflet": "^1.0.2", "prop-types": "^15.7.2", @@ -59,7 +58,6 @@ "redux": "^4.0.4", "redux-actions": "^2.6.5", "redux-form": "^8.2.5", - "redux-oidc": "^3.1.4", "redux-saga": "^1.0.5", "utility-types": "^3.11.0", "whatwg-fetch": "^3.0.0" diff --git a/public/silent_renew.html b/public/silent_renew.html new file mode 100644 index 00000000..36d01bda --- /dev/null +++ b/public/silent_renew.html @@ -0,0 +1,20 @@ + + + + Silent renewal + + + + + + diff --git a/src/app/App.tsx b/src/app/App.tsx index 29974b31..00b2a23c 100644 --- a/src/app/App.tsx +++ b/src/app/App.tsx @@ -3,7 +3,6 @@ import { connect, useDispatch } from "react-redux"; import ReduxToastr from "react-redux-toastr"; import { withRouter } from "react-router"; import flowRight from "lodash/flowRight"; -import isEmpty from "lodash/isEmpty"; import get from "lodash/get"; import { Sizes } from "@/foundation/enums"; import { revealContext } from "@/foundation/reveal"; @@ -14,18 +13,15 @@ import Loader from "@/components/loader/Loader"; import LoginPage from "@/auth/components/LoginPage"; import SideMenu from "@/components/sideMenu/SideMenu"; import TopNavigation from "@/components/topNavigation/TopNavigation"; -import userManager from "@/auth/util/user-manager"; import { Routes, getRouteById } from "@/root/routes"; import { clearError } from "@/api/actions"; import { userFound, clearUser, receiveApiToken, clearApiToken } from "@/auth/actions"; -import { getEpochTime } from "@/util/helpers"; import { getError } from "@/api/selectors"; -import { getIsFetching, getLoggedInUser } from "@/auth/selectors"; +import { getLoggedInUser } from "@/auth/selectors"; import { getLinkUrl, getPageTitle, getShowSearch } from "@/components/topNavigation/selectors"; import { getUserGroups, getUserActiveServiceUnit, getUserServiceUnits } from "@/usersPermissions/selectors"; import { setRedirectUrlToSessionStorage } from "@/util/storage"; import type { ApiError } from "@/api/types"; -import type { ApiToken } from "@/auth/types"; import type { UserGroups, UserServiceUnit, UserServiceUnits } from "@/usersPermissions/types"; import type { RootState } from "@/root/types"; import "@/main.scss"; diff --git a/src/auth/actions.ts b/src/auth/actions.ts index 84c48525..8d50a0fd 100644 --- a/src/auth/actions.ts +++ b/src/auth/actions.ts @@ -1,12 +1,10 @@ import { createAction } from 'redux-actions'; import type { Action } from 'redux'; import type { User } from 'hds-react'; -import type { ClearApiTokenAction, FetchApiTokenAction, ReceiveApiTokenAction, TokenNotFoundAction } from './types'; +import type { ClearApiTokenAction, ReceiveApiTokenAction } from './types'; export const clearApiToken = (): ClearApiTokenAction => createAction('mvj/auth/CLEAR_API_TOKEN')(); -export const fetchApiToken = (accessToken: string): FetchApiTokenAction => createAction('mvj/auth/FETCH_API_TOKEN')(accessToken); export const receiveApiToken = (token: Record): ReceiveApiTokenAction => createAction('mvj/auth/RECEIVE_API_TOKEN')(token); -export const tokenNotFound = (): TokenNotFoundAction => createAction('mvj/auth/TOKEN_NOT_FOUND')(); export const userFound = (user: User): Action => createAction('mvj/auth/USER_FOUND')(user); export const clearUser = (): Action => - createAction('mvj/auth/USER_CLEAR')(null); \ No newline at end of file + createAction('mvj/auth/CLEAR_USER')(null); \ No newline at end of file diff --git a/src/auth/auth.spec.ts b/src/auth/auth.spec.ts index b40b9ce6..71315e71 100644 --- a/src/auth/auth.spec.ts +++ b/src/auth/auth.spec.ts @@ -1,33 +1,36 @@ import { describe, expect, it } from "vitest"; -import { clearApiToken, fetchApiToken, receiveApiToken, tokenNotFound } from "./actions"; -import authReducer from "./reducer"; +import { User } from "oidc-client-ts"; +import { clearApiToken, receiveApiToken, userFound, clearUser } from "./actions"; +import { authReducer, oidcReducer } from "./reducer"; + +const getMockUser = (): User => new User({ + id_token: "id123", + session_state: "state", + access_token: "access321", + refresh_token: "refresh123", + token_type: "id_token", + scope: "openid profile", + profile: { + sub: "", + iss: "", + aud: "", + exp: 1725539282918, + iat: 1725539270573, + }, + expires_at: 1725539282918, + userState: "", + url_state: "localhost:3000", +}); + describe('Auth', () => { describe('Reducer', () => { describe('authReducer', () => { - it('should update isFetching flag to true when fetching api token', () => { - const newState = { - apiToken: {}, - isFetching: true - }; - const state = authReducer({}, fetchApiToken('test')); - expect(state).to.deep.equal(newState); - }); - it('should update isFetching flag to false by notFound', () => { - const newState = { - apiToken: {}, - isFetching: false - }; - let state = authReducer({}, fetchApiToken('test')); - state = authReducer(state, tokenNotFound()); - expect(state).to.deep.equal(newState); - }); it('should update apiToken', () => { const dummyApiToken = { 'foo': 'Lorem ipsum' }; const newState = { apiToken: dummyApiToken, - isFetching: false }; const state = authReducer({}, receiveApiToken(dummyApiToken)); expect(state).to.deep.equal(newState); @@ -38,13 +41,25 @@ describe('Auth', () => { }; const newState = { apiToken: {}, - isFetching: false }; - let state = authReducer({}, fetchApiToken('test')); - state = authReducer(state, receiveApiToken(dummyApiToken)); + let state = authReducer({}, receiveApiToken(dummyApiToken)); state = authReducer(state, clearApiToken()); expect(state).to.deep.equal(newState); }); }); + describe('oidcReducer', () => { + it('should set user', () => { + const mockUser = getMockUser(); + const mockState = Object.assign({}, {user: mockUser,}); + const state = oidcReducer({user: null}, userFound(mockUser)); + expect(state).to.deep.equal(mockState); + }); + }); + it('should clear user', () => { + const mockUser = getMockUser(); + const state = oidcReducer({user: mockUser}, clearUser()); + const nullUserState = {user: null}; + expect(state).to.deep.equal(nullUserState); + }); }); }); \ No newline at end of file diff --git a/src/auth/components/CallbackPage.tsx b/src/auth/components/CallbackPage.tsx index a0393d0c..222e374f 100644 --- a/src/auth/components/CallbackPage.tsx +++ b/src/auth/components/CallbackPage.tsx @@ -1,10 +1,8 @@ import React from "react"; import { withRouter } from "react-router"; -import { CallbackComponent, CallbackComponentProps } from "redux-oidc"; -import { LoginProvider, LoginCallbackHandler, isHandlingLoginCallbackError } from "hds-react"; +import { LoginCallbackHandler } from "hds-react"; import type { OidcClientError, User } from "hds-react"; import { getRedirectUrlFromSessionStorage } from "@/util/storage"; -import userManager from "@/auth/util/user-manager"; import { getRouteById, Routes } from "@/root/routes"; type Props = { diff --git a/src/auth/reducer.ts b/src/auth/reducer.ts index 43ae4fa5..3f8611dc 100644 --- a/src/auth/reducer.ts +++ b/src/auth/reducer.ts @@ -2,20 +2,16 @@ import { handleActions } from "redux-actions"; import { combineReducers } from "redux"; import type { Reducer } from "@/types"; import type { ReceiveApiTokenAction, ReceiveUserAction } from "./types"; -const isFetchingReducer: Reducer = handleActions({ - 'mvj/auth/FETCH_API_TOKEN': () => true, - 'mvj/auth/TOKEN_NOT_FOUND': () => false, - 'mvj/auth/RECEIVE_API_TOKEN': () => false -}, false); + const apiTokenReducer: Reducer = handleActions({ ['mvj/auth/CLEAR_API_TOKEN']: () => {}, ['mvj/auth/RECEIVE_API_TOKEN']: (state: any, { payload }: ReceiveApiTokenAction) => payload }, {}); -export default combineReducers, any>({ + +export const authReducer = combineReducers, any>({ apiToken: apiTokenReducer, - isFetching: isFetchingReducer }); const userReducer: Reducer = handleActions( diff --git a/src/auth/saga.ts b/src/auth/saga.ts deleted file mode 100644 index 9d728b63..00000000 --- a/src/auth/saga.ts +++ /dev/null @@ -1,49 +0,0 @@ -import { all, call, fork, put, takeLatest } from "redux-saga/effects"; -import { tokenNotFound, receiveApiToken } from "./actions"; -import { getEpochTime } from "@/util/helpers"; -import userManager from "@/auth/util/user-manager"; - -function* fetchApiTokenSaga({ - payload: token, - type: any -}): Generator { - try { - const request = new Request(import.meta.env.VITE_OPENID_CONNECT_API_TOKEN_URL || 'https://api.hel.fi/sso/api-tokens/', { - headers: { - 'Authorization': `Bearer ${token}` - } - }); - const response = yield call(fetch, request); - const { - status: statusCode - } = response; - - switch (statusCode) { - case 200: - { - const bodyAsJson = yield call([response, response.json]); - // Add expires_at time to fetch new api token after 9 minutes - bodyAsJson.expires_at = getEpochTime() + 9 * 60; - yield put(receiveApiToken(bodyAsJson)); - break; - } - - default: - { - yield put(tokenNotFound()); - userManager.removeUser(); - break; - } - } - } catch (error) { - console.error(`Failed to fetch API token with error: ${error}`); - yield put(tokenNotFound()); - userManager.removeUser(); - } -} - -export default function* (): Generator { - yield all([fork(function* (): Generator { - yield takeLatest('mvj/auth/FETCH_API_TOKEN', fetchApiTokenSaga); - })]); -} \ No newline at end of file diff --git a/src/auth/selectors.ts b/src/auth/selectors.ts index 8c69f9ce..231890f4 100644 --- a/src/auth/selectors.ts +++ b/src/auth/selectors.ts @@ -3,5 +3,4 @@ import type { ApiToken, AuthState } from "./types"; import type { User } from 'hds-react'; // Helper functions to select state export const getApiToken: Selector = (state: Record): AuthState => state.auth.apiToken[import.meta.env.VITE_OPENID_CONNECT_API_TOKEN_KEY || 'https://api.hel.fi/auth/mvj']; -export const getIsFetching: Selector = (state: Record): AuthState => state.auth.isFetching; export const getLoggedInUser: Selector, void> = (state: Record): User | null => state.oidc.user; \ No newline at end of file diff --git a/src/auth/types.ts b/src/auth/types.ts index abbf2c47..f3575ea0 100644 --- a/src/auth/types.ts +++ b/src/auth/types.ts @@ -2,8 +2,6 @@ import type { Action } from "@/types"; import type { User } from "hds-react"; export type ApiToken = Record | null; export type AuthState = Record | null; -export type FetchApiTokenAction = Action; export type ReceiveApiTokenAction = Action>; export type ClearApiTokenAction = Action; -export type TokenNotFoundAction = Action; export type ReceiveUserAction = Action; \ No newline at end of file diff --git a/src/auth/util/user-manager.ts b/src/auth/util/user-manager.ts deleted file mode 100644 index 2c827571..00000000 --- a/src/auth/util/user-manager.ts +++ /dev/null @@ -1,50 +0,0 @@ -// @ts-ignore: Module '"oidc-client"' has no exported member 'Global' -// oidc-client has exported Global, but it is not in the types, so we ignore the error. -// TODO: migrate to oidc-client-ts, because oidc-client is not maintained anymore -import { Global, Log, UserManager, WebStorageStateStore } from "oidc-client"; -const userManagerConfig = { - authority: import.meta.env.VITE_OPENID_CONNECT_AUTHORITY_URL || 'https://api.hel.fi/sso/openid/', - automaticSilentRenew: true, - client_id: import.meta.env.VITE_OPENID_CONNECT_CLIENT_ID || '', - filterProtocolClaims: true, - loadUserInfo: true, - redirect_uri: `${location.origin}/callback`, - response_type: 'id_token token', - scope: import.meta.env.VITE_OPENID_CONNECT_SCOPE || 'openid profile https://api.hel.fi/auth/mvj', - silent_redirect_uri: `${location.origin}/silent_renew.html`, - userStore: new WebStorageStateStore({ - store: Global.localStorage - }) -}; - -class MvjUserManager extends UserManager { - _signinStart(args, navigator, navigatorParams: any = {}) { - return navigator.prepare(navigatorParams).then(handle => { - Log.debug('UserManager._signinStart: got navigator window handle'); - return this.createSigninRequest(args).then(signinRequest => { - Log.debug('UserManager._signinStart: got signin request'); - - if (!signinRequest.url.match('authorize/')) { - // Add missing / if needed - navigatorParams.url = signinRequest.url.replace('authorize', 'authorize/'); - } else { - navigatorParams.url = signinRequest.url; - } - - navigatorParams.id = signinRequest.state.id; - return handle.navigate(navigatorParams); - }).catch(err => { - if (handle.close) { - Log.debug('UserManager._signinStart: Error after preparing navigator, closing navigator window'); - handle.close(); - } - - throw err; - }); - }); - } - -} - -const userManager = new MvjUserManager(userManagerConfig); -export default userManager; \ No newline at end of file diff --git a/src/index.tsx b/src/index.tsx index 44449dec..1c8c2967 100644 --- a/src/index.tsx +++ b/src/index.tsx @@ -2,11 +2,9 @@ import React from 'react'; import ReactDOM from 'react-dom'; import { Provider } from 'react-redux'; import { ConnectedRouter } from 'connected-react-router'; -import { OidcProvider } from 'redux-oidc'; import { LoginProvider } from 'hds-react'; import configureStore, { history } from '@/root/configureStore'; import routes from '@/root/routes'; -import userManager from '@/auth/util/user-manager'; import { loginProviderProperties } from '@/auth/constants'; import './polyfills'; diff --git a/src/root/configureStore.ts b/src/root/configureStore.ts index ff666a87..3b7012ea 100644 --- a/src/root/configureStore.ts +++ b/src/root/configureStore.ts @@ -4,8 +4,6 @@ import { routerMiddleware as createRouterMiddleware } from "connected-react-rout import createRootReducer from "./createRootReducer"; import createSagaMiddleware from "redux-saga"; import createRootSaga from "./createRootSaga"; -import { loadUser } from "redux-oidc"; -import userManager from "@/auth/util/user-manager"; export const history = createBrowserHistory(); // needed so Typescript doesn't complain about the window object not having the __REDUX_DEVTOOLS_EXTENSION__ property diff --git a/src/root/createRootReducer.ts b/src/root/createRootReducer.ts index 74b55b09..3a925bc2 100644 --- a/src/root/createRootReducer.ts +++ b/src/root/createRootReducer.ts @@ -6,7 +6,7 @@ import apiReducer from "@/api/reducer"; import areaNoteReducer from "@/areaNote/reducer"; import areaSearchReducer from "@/areaSearch/reducer"; import auditLogReducer from "@/auditLog/reducer"; -import authReducer, { oidcReducer } from "@/auth/reducer"; +import { authReducer, oidcReducer } from "@/auth/reducer"; import batchrunReducer from "@/batchrun/reducer"; import billingPeriodReducer from "@/billingPeriods/reducer"; import collectionCourtDecisionReducer from "@/collectionCourtDecision/reducer"; diff --git a/src/root/createRootSaga.ts b/src/root/createRootSaga.ts index 269d154f..39fe6221 100644 --- a/src/root/createRootSaga.ts +++ b/src/root/createRootSaga.ts @@ -1,7 +1,6 @@ import { all, fork } from "redux-saga/effects"; import areaNoteSaga from "@/areaNote/saga"; import auditLogSaga from "@/auditLog/saga"; -import authSaga from "@/auth/saga"; import areaSearchSaga from "@/areaSearch/saga"; import batchrunSaga from "@/batchrun/saga"; import billingPeriodsSaga from "@/billingPeriods/saga"; diff --git a/src/silent_renew.html b/src/silent_renew.html deleted file mode 100644 index 2d174a42..00000000 --- a/src/silent_renew.html +++ /dev/null @@ -1,9 +0,0 @@ - - - - - Silent Renew - - - - diff --git a/src/silent_renew.ts b/src/silent_renew.ts deleted file mode 100644 index e988d85c..00000000 --- a/src/silent_renew.ts +++ /dev/null @@ -1,2 +0,0 @@ -import { processSilentRenew } from "redux-oidc"; -processSilentRenew(); \ No newline at end of file diff --git a/src/util/helpers.tsx b/src/util/helpers.tsx index 098c823c..813a6e5f 100644 --- a/src/util/helpers.tsx +++ b/src/util/helpers.tsx @@ -202,12 +202,6 @@ export const fixedLengthNumber = (value: number | null | undefined, length: numb return value.toString(); }; -/** - * Get current epoch time - * @returns {number} - */ -export const getEpochTime = (): number => Math.round(new Date().getTime() / 1000.0); - /** * Test is value empty or null/undefined * @param {*} value diff --git a/src/util/util.spec.ts b/src/util/util.spec.ts index fcefd3ef..345a982a 100644 --- a/src/util/util.spec.ts +++ b/src/util/util.spec.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from "vitest"; import { isValidDate, getDayMonth, getCurrentYear, sortByStartAndEndDateAsc, sortByStartAndEndDateDesc, isDateRangesCollapsing, splitDateRanges, getSplittedDateRanges, getSplittedDateRangesWithItems } from "./date"; -import { composePageTitle, getSearchQuery, getUrlParams, fixedLengthNumber, getEpochTime, isEmptyValue, formatNumberWithThousandSeparator, formatDecimalNumber, formatNumber, isDecimalNumberStr, convertStrToDecimalNumber, formatDate, formatDateRange, getReferenceNumberLink, findItemById, getLabelOfOption, sortNumberByKeyAsc, sortNumberByKeyDesc, sortStringAsc, sortStringDesc, sortStringByKeyAsc, sortStringByKeyDesc, sortByOptionsAsc, sortByOptionsDesc, addEmptyOption, isFieldRequired, isFieldAllowedToEdit, isFieldAllowedToRead, isMethodAllowed, hasPermissions, getFieldAttributeOptions, getFieldOptions, humanReadableByteCount, hasNumber, findFromOcdString, createPTPPlanReportUrl, createPTPPlotDivisionUrl, getApiResponseCount, getApiResponseMaxPage, getApiResponseResults, isActive, isActiveOrFuture, isArchived } from "./helpers"; +import { composePageTitle, getSearchQuery, getUrlParams, fixedLengthNumber, isEmptyValue, formatNumberWithThousandSeparator, formatDecimalNumber, formatNumber, isDecimalNumberStr, convertStrToDecimalNumber, formatDate, formatDateRange, getReferenceNumberLink, findItemById, getLabelOfOption, sortNumberByKeyAsc, sortNumberByKeyDesc, sortStringAsc, sortStringDesc, sortStringByKeyAsc, sortStringByKeyDesc, sortByOptionsAsc, sortByOptionsDesc, addEmptyOption, isFieldRequired, isFieldAllowedToEdit, isFieldAllowedToRead, isMethodAllowed, hasPermissions, getFieldAttributeOptions, getFieldOptions, humanReadableByteCount, hasNumber, findFromOcdString, createPTPPlanReportUrl, createPTPPlotDivisionUrl, getApiResponseCount, getApiResponseMaxPage, getApiResponseResults, isActive, isActiveOrFuture, isArchived } from "./helpers"; import { getCoordinatesOfGeometry, getCenterFromCoordinates } from "./map"; describe('utils', () => { @@ -542,9 +542,6 @@ describe('utils', () => { expect(fixedLengthNumber(null)).to.deep.equal(''); expect(fixedLengthNumber(1, 4)).to.deep.equal('0001'); }); - it('epoch time should be a number', () => { - expect(getEpochTime()).to.be.a('number'); - }); it('should be empty value', () => { expect(isEmptyValue(undefined)).to.deep.equal(true); expect(isEmptyValue(null)).to.deep.equal(true); diff --git a/yarn.lock b/yarn.lock index f0642c4b..39237c57 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2523,11 +2523,6 @@ balanced-match@^1.0.0: resolved "https://registry.yarnpkg.com/balanced-match/-/balanced-match-1.0.0.tgz#89b4d199ab2bee49de164ea02b89ce462d71b767" integrity sha1-ibTRmasr7kneFk6gK4nORi1xt2c= -base64-js@^1.3.0: - version "1.3.1" - resolved "https://registry.yarnpkg.com/base64-js/-/base64-js-1.3.1.tgz#58ece8cb75dd07e71ed08c736abc5fac4dbf8df1" - integrity sha512-mLQ4i2QO1ytvGWFWmcngKO//JXAQueZvwEKtjgQFM4jIK0kU+ytMfplL8j+n5mspOfjHwoAg+9yhb7BwAHm36g== - binary-extensions@^2.0.0: version "2.2.0" resolved "https://registry.yarnpkg.com/binary-extensions/-/binary-extensions-2.2.0.tgz#75f502eeaf9ffde42fc98829645be4ea76bd9e2d" @@ -2793,11 +2788,6 @@ core-js@^1.0.0: resolved "https://registry.yarnpkg.com/core-js/-/core-js-1.2.7.tgz#652294c14651db28fa93bd2d5ff2983a4f08c636" integrity sha1-ZSKUwUZR2yj6k70tX/KYOk8IxjY= -core-js@^2.6.4: - version "2.6.11" - resolved "https://registry.yarnpkg.com/core-js/-/core-js-2.6.11.tgz#38831469f9922bded8ee21c9dc46985e0399308c" - integrity sha512-5wjnpaT/3dV+XB4borEsnAYQchn00XSgTAWKDkEqv+K8KevjbzmofK6hfJ9TZIlpj2N0xQpazy7PiRQiWHqzWg== - cosmiconfig@^6.0.0: version "6.0.0" resolved "https://registry.yarnpkg.com/cosmiconfig/-/cosmiconfig-6.0.0.tgz#da4fee853c52f6b1e6935f41c1a2fc50bd4a9982" @@ -2857,11 +2847,6 @@ cross-spawn@^7.0.0, cross-spawn@^7.0.2, cross-spawn@^7.0.3: shebang-command "^2.0.0" which "^2.0.1" -crypto-js@^3.1.9-1: - version "3.3.0" - resolved "https://registry.yarnpkg.com/crypto-js/-/crypto-js-3.3.0.tgz#846dd1cce2f68aacfa156c8578f926a609b7976b" - integrity sha512-DIT51nX0dCfKltpRiXV+/TVZq+Qq2NgF4644+K7Ttnla7zEzqc+kjJyiB96BHNyUTBxyjzRcZYpUdZa+QAqi6Q== - crypto-js@^4.2.0: version "4.2.0" resolved "https://registry.yarnpkg.com/crypto-js/-/crypto-js-4.2.0.tgz#4d931639ecdfd12ff80e8186dba6af2c2e856631" @@ -4128,11 +4113,6 @@ ignore@^5.3.1: resolved "https://registry.yarnpkg.com/ignore/-/ignore-5.3.2.tgz#3cd40e729f3643fd87cb04e50bf0eb722bc596f5" integrity sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g== -immutable@>=3.6.0: - version "3.8.2" - resolved "https://registry.yarnpkg.com/immutable/-/immutable-3.8.2.tgz#c2439951455bb39913daf281376f1530e104adf3" - integrity sha1-wkOZUUVbs5kT2vKBN28VMOEErfM= - "immutable@^3.8.1 || ^4.0.0", immutable@^4.0.0: version "4.3.7" resolved "https://registry.yarnpkg.com/immutable/-/immutable-4.3.7.tgz#c70145fc90d89fb02021e65c84eb0226e4e5a381" @@ -5140,16 +5120,6 @@ oidc-client-ts@^2.2.2: crypto-js "^4.2.0" jwt-decode "^3.1.2" -oidc-client@^1.8.2: - version "1.10.1" - resolved "https://registry.yarnpkg.com/oidc-client/-/oidc-client-1.10.1.tgz#fe67ae54924fc1c338062f3fd733be362026192c" - integrity sha512-/QB5Nl7c9GmT9ir1E+OVY3+yZZnuk7Qa9ZEAJqSvDq0bAyAU9KAgeKipTEfKjGdGLTeOLy9FRWuNpULMkfZydQ== - dependencies: - base64-js "^1.3.0" - core-js "^2.6.4" - crypto-js "^3.1.9-1" - uuid "^3.3.2" - once@^1.3.0: version "1.4.0" resolved "https://registry.yarnpkg.com/once/-/once-1.4.0.tgz#583b1aa775961d4b113ac17d9c50baef9dd76bd1" @@ -5790,13 +5760,6 @@ redux-form@^8.2.5: prop-types "^15.6.1" react-is "^16.4.2" -redux-oidc@^3.1.4: - version "3.1.7" - resolved "https://registry.yarnpkg.com/redux-oidc/-/redux-oidc-3.1.7.tgz#04cc1476a813296b05abff794a35b255cb23b6c6" - integrity sha512-Xlo5UQf6MhGPf9it9iHv3pLjkP5NXqCFkmKXd6QE/xpoLfiTVwwQKs2bIVXZw4WroR09p54aLUzbMHw30Tte0g== - optionalDependencies: - immutable ">=3.6.0" - redux-saga@^1.0.5: version "1.1.3" resolved "https://registry.yarnpkg.com/redux-saga/-/redux-saga-1.1.3.tgz#9f3e6aebd3c994bbc0f6901a625f9a42b51d1112" @@ -6894,11 +6857,6 @@ utility-types@^3.11.0: resolved "https://registry.yarnpkg.com/utility-types/-/utility-types-3.11.0.tgz#607c40edb4f258915e901ea7995607fdf319424c" integrity sha512-6Z7Ma2aVEWisaL6TvBCy7P8rm2LQoPv6dJ7ecIaIixHcwfbJ0x7mWdbcwlIM5IGQxPZSFYeqRCqlOOeKoJYMkw== -uuid@^3.3.2: - version "3.4.0" - resolved "https://registry.yarnpkg.com/uuid/-/uuid-3.4.0.tgz#b23e4358afa8a202fe7a100af1f5f883f02007ee" - integrity sha512-HjSDRw6gZE5JMggctHBcjVak08+KEVhSIiDzFnT9S9aegmp85S/bReBVTb4QTFaRNptJ9kuYaNhnbNEOkbKb/A== - uuid@^9.0.0: version "9.0.1" resolved "https://registry.yarnpkg.com/uuid/-/uuid-9.0.1.tgz#e188d4c8853cc722220392c424cd637f32293f30" From f61c6a6bb3dd6ea71bc7e43f87e44e8f6a9a4751 Mon Sep 17 00:00:00 2001 From: Henri Nieminen Date: Fri, 13 Sep 2024 08:11:17 +0300 Subject: [PATCH 04/13] add custom auth hook, add tests --- package.json | 1 + src/app/App.tsx | 224 ++++++++++++------------------ src/auth/actions.ts | 2 +- src/auth/auth.spec.ts | 2 +- src/auth/useAuth.spec.tsx | 221 +++++++++++++++++++++++++++++ src/auth/useAuth.ts | 77 ++++++++++ src/leaseCreateCharge/requests.ts | 2 +- yarn.lock | 22 +++ 8 files changed, 412 insertions(+), 139 deletions(-) create mode 100644 src/auth/useAuth.spec.tsx create mode 100644 src/auth/useAuth.ts diff --git a/package.json b/package.json index d7e52d72..7348f60d 100644 --- a/package.json +++ b/package.json @@ -70,6 +70,7 @@ "@babel/preset-react": "^7.24.7", "@eslint/js": "^9.9.0", "@rollup/plugin-babel": "^6.0.4", + "@testing-library/react-hooks": "^8.0.1", "@types/history": "^5.0.0", "@types/leaflet": "^1.5.23", "@types/leaflet-draw": "^1.0.11", diff --git a/src/app/App.tsx b/src/app/App.tsx index 00b2a23c..9320e74f 100644 --- a/src/app/App.tsx +++ b/src/app/App.tsx @@ -1,5 +1,5 @@ -import React, { useState, useEffect, useCallback } from "react"; -import { connect, useDispatch } from "react-redux"; +import React, { useState } from "react"; +import { connect } from "react-redux"; import ReduxToastr from "react-redux-toastr"; import { withRouter } from "react-router"; import flowRight from "lodash/flowRight"; @@ -10,22 +10,20 @@ import { ActionTypes, AppConsumer, AppProvider } from "@/app/AppContext"; import ApiErrorModal from "@/api/ApiErrorModal"; import ConfirmationModal from "@/components/modal/ConfirmationModal"; import Loader from "@/components/loader/Loader"; +import useAuth from "@/auth/useAuth"; import LoginPage from "@/auth/components/LoginPage"; import SideMenu from "@/components/sideMenu/SideMenu"; import TopNavigation from "@/components/topNavigation/TopNavigation"; import { Routes, getRouteById } from "@/root/routes"; import { clearError } from "@/api/actions"; -import { userFound, clearUser, receiveApiToken, clearApiToken } from "@/auth/actions"; import { getError } from "@/api/selectors"; import { getLoggedInUser } from "@/auth/selectors"; import { getLinkUrl, getPageTitle, getShowSearch } from "@/components/topNavigation/selectors"; import { getUserGroups, getUserActiveServiceUnit, getUserServiceUnits } from "@/usersPermissions/selectors"; -import { setRedirectUrlToSessionStorage } from "@/util/storage"; import type { ApiError } from "@/api/types"; import type { UserGroups, UserServiceUnit, UserServiceUnits } from "@/usersPermissions/types"; import type { RootState } from "@/root/types"; import "@/main.scss"; -import { useApiTokens, useOidcClient, useApiTokensClientTracking, isApiTokensUpdatedSignal, useAuthenticatedUser } from "hds-react"; const url = window.location.toString(); const IS_DEVELOPMENT_URL = url.includes('ninja') || url.includes('localhost'); @@ -33,18 +31,18 @@ type OwnProps = { children: JSX.Element; }; type Props = OwnProps & { - apiError: ApiError; + apiError?: ApiError; clearError: typeof clearError; closeReveal: (...args: Array) => any; history: Record; - linkUrl: string; + linkUrl?: string; location: Record; pageTitle: string; - userActiveServiceUnit: UserServiceUnit; - userServiceUnits: UserServiceUnits; - showSearch: boolean; - user: Record; - userGroups: UserGroups; + userActiveServiceUnit?: UserServiceUnit; + userServiceUnits?: UserServiceUnits; + showSearch?: boolean; + user?: Record; + userGroups?: UserGroups; }; type State = { displaySideMenu: boolean; @@ -53,63 +51,17 @@ type State = { }; const App: React.FC = (props) => { - const [loggedIn, setLoggedIn] = useState(false); const [displaySideMenu, setDisplaySideMenu] = useState(false); const [displayUserGroups, setDisplayUserGroups] = useState(false); - const { login, logout, getUser, isAuthenticated } = useOidcClient(); - const authenticatedUser = useAuthenticatedUser(); - const dispatch = useDispatch(); - const [apiTokensClientSignal, apiTokensClientSignalReset] = useApiTokensClientTracking(); - const { getStoredApiTokens, isRenewing } = useApiTokens(); - - const setLoggedInIfApiTokenExists = useCallback(() => { - // apiToken is required to make requests to the API, therefore we assume that user is "logged in" when they have a token - const [_error, apiToken] = getStoredApiTokens(); - if (apiToken) { - dispatch(receiveApiToken(apiToken)); - setLoggedIn(true); - } - }, [getStoredApiTokens, dispatch]); - - // Handle apiToken fetched or updated, e.g. after login - useEffect(() => { - // apiTokensClientSignal is a required dependency in order to pick up that apiToken was fetched - if (isApiTokensUpdatedSignal(apiTokensClientSignal)) { - setLoggedInIfApiTokenExists(); - apiTokensClientSignalReset(); - } - return apiTokensClientSignalReset; - }, [apiTokensClientSignal, getStoredApiTokens, dispatch]); - - // Handle apiToken already exists in session storage - useEffect(() => { - // isAuthenticated checks only that the user is authenticated, but apiToken is required to make requests to API - if (isAuthenticated()) { - dispatch(userFound(authenticatedUser)); - // Must ensure that apiToken exists before making any requests to API - setLoggedInIfApiTokenExists(); - } - }, [authenticatedUser, dispatch]); - - useEffect(() => { - const { - apiError, - } = props; - if (apiError) { - return; - } - }, [props.apiError]); + const { loggedIn, authenticatedUser, login, logout, isRenewing } = useAuth(); const handleLogin = () => { const { pathname, search } = props.location; - setRedirectUrlToSessionStorage(`${pathname}${search}` || getRouteById(Routes.LEASES)); - login(); + const redirectPath = `${pathname}${search}`; + login(redirectPath); }; const logOut = () => { - setLoggedIn(false); - dispatch(clearUser()); - dispatch(clearApiToken()); logout(); }; @@ -126,7 +78,6 @@ const App: React.FC = (props) => { props.clearError(); }; - const { apiError, children, @@ -139,23 +90,24 @@ const App: React.FC = (props) => { userServiceUnits } = props; - const user = getUser(); const appStyle = IS_DEVELOPMENT_URL ? 'app-dev' : 'app'; if (!loggedIn) { - return
- + return ( +
+ - + - - + + - {location.pathname === getRouteById(Routes.CALLBACK) && children} -
; + {location.pathname === getRouteById(Routes.CALLBACK) && children} +
); } - return + return ( + {({ isConfirmationModalOpen, @@ -177,73 +129,73 @@ const App: React.FC = (props) => { }); }; - return
- - - - - - - - - {displayUserGroups && -
- Käyttäjäryhmät ja palvelukokonaisuudet - {userGroups && userGroups.length > 1 && - userGroups.map((group, index) => { - return ( -

- {group} -

- ); - })} -
- } - -
- -
- {children} -
-
-
; + return (
+ + + + + + + + + {displayUserGroups && +
+ Käyttäjäryhmät ja palvelukokonaisuudet + {userGroups && userGroups.length > 1 && + userGroups.map((group, index) => { + return ( +

+ {group} +

+ ); + })} +
+ } + +
+ +
+ {children} +
+
+
); }}
-
; +
); }; const mapStateToProps = (state: RootState) => { diff --git a/src/auth/actions.ts b/src/auth/actions.ts index 8d50a0fd..f07f9b69 100644 --- a/src/auth/actions.ts +++ b/src/auth/actions.ts @@ -7,4 +7,4 @@ export const receiveApiToken = (token: Record): ReceiveApiTokenActi export const userFound = (user: User): Action => createAction('mvj/auth/USER_FOUND')(user); export const clearUser = (): Action => - createAction('mvj/auth/CLEAR_USER')(null); \ No newline at end of file + createAction('mvj/auth/CLEAR_USER')(); \ No newline at end of file diff --git a/src/auth/auth.spec.ts b/src/auth/auth.spec.ts index 71315e71..48dfdacd 100644 --- a/src/auth/auth.spec.ts +++ b/src/auth/auth.spec.ts @@ -62,4 +62,4 @@ describe('Auth', () => { expect(state).to.deep.equal(nullUserState); }); }); -}); \ No newline at end of file +}); diff --git a/src/auth/useAuth.spec.tsx b/src/auth/useAuth.spec.tsx new file mode 100644 index 00000000..a12d3029 --- /dev/null +++ b/src/auth/useAuth.spec.tsx @@ -0,0 +1,221 @@ +import { describe, expect, it, vi, beforeEach } from 'vitest'; +import { renderHook, act, cleanup } from '@testing-library/react-hooks'; +import React from 'react'; +import { Provider } from 'react-redux'; +import { + useAuthenticatedUser, + isApiTokensUpdatedSignal, + isApiTokensRemovedSignal, + LoginProvider, + LoginProviderProps, + useApiTokens, + useApiTokensClientTracking, + apiTokensClientEvents, + createApiTokensClientEventSignal, +} from 'hds-react'; +import type { User } from 'oidc-client-ts'; +import type { ModuleNamespace } from 'vite/types/hot'; +import type { Store } from 'redux'; +import { clearApiToken, receiveApiToken, userFound, clearUser } from './actions'; +import useAuth from './useAuth'; +import { setRedirectUrlToSessionStorage } from '../util/storage'; +import configureStore from '../root/configureStore'; + +vi.mock('@/index', () => { + return { + store: vi.fn() + } +}); + +vi.mock('@/root/routes', () => { + return { + getRouteById: vi.fn() + } +}); + +vi.mock('@/util/storage', async (importOriginal) => { + const actual: ModuleNamespace = await importOriginal(); + return { + ...actual, + setRedirectUrlToSessionStorage: vi.fn(actual.setRedirectUrlToSessionStorage), + } +}); + +vi.mock('react-redux', async (importOriginal) => { + const actual: ModuleNamespace = await importOriginal(); + return { + ...actual, + Provider: ({ children }) => children, + useDispatch: () => vi.fn(() => actual.useDispatch), + connect: vi.fn().mockReturnValue(vi.fn()), + default: vi.fn().mockReturnValue(vi.fn()), +}}); + +vi.mock('hds-react', async (importOriginal) => { + const actual: ModuleNamespace = await importOriginal(); + return { + ...actual, + useAuthenticatedUser: vi.fn(actual.useAuthenticatedUser), + useApiTokens: vi.fn(() => { + return { + ...actual.useApiTokens, + getStoredApiTokens: vi.fn(actual.useApiTokens.getStoredApiTokens), + } + }), + useApiTokensClientTracking: vi.fn(actual.useApiTokensClientTracking), + useOidcClient: vi.fn(() => { + return { + ...actual.useOidcClient, + login: vi.fn(actual.login), + logout: vi.fn(actual.logout), + } + }), + isApiTokensUpdatedSignal: vi.fn(actual.isApiTokensUpdatedSignal), + isApiTokensRemovedSignal: vi.fn(actual.isApiTokensRemovedSignal), + } +}); + +vi.mock('./actions', async (importOriginal) => { + const actual: ModuleNamespace = await importOriginal() + return { + ...actual, + clearApiToken: vi.fn(actual.clearApiToken), + clearUser: vi.fn(actual.clearUser), + receiveApiToken: vi.fn(actual.receiveApiToken), + userFound: vi.fn(actual.userFound), + } +}); + +describe('useAuth', () => { + let mockStore: Store; + const loginProviderProperties : LoginProviderProps = { + userManagerSettings: { + authority: 'http://localhost/openid/', + client_id: 'mvj-ui', + scope: 'openid profile http://localhost/mvj', + redirect_uri: `${location.origin}/callback`, + }, + apiTokensClientSettings: { url: 'https://localhost/api-tokens/' }, + sessionPollerSettings: { pollIntervalInMs: 300000 } // 300000ms = 5min + }; + + beforeEach(() => { + mockStore = configureStore();//store; + vi.clearAllMocks(); + cleanup(); + }); + + it('should initialize with loggedIn as false', () => { + const { result } = renderHook(() => useAuth(), { + wrapper: ({ children }) => ( + + {children} + ) + }); + expect(clearUser).toHaveBeenCalled(); + expect(clearApiToken).toHaveBeenCalled(); + expect(result.current.loggedIn).toBe(false); + }); + + it('should trigger log in', () => { + const { result } = renderHook(() => useAuth(), { + wrapper: ({ children }) => ( + + {children} + ) + }); + + act(() => { + result.current.login('/redirect-path'); + }); + expect(setRedirectUrlToSessionStorage).toHaveBeenCalledWith('/redirect-path'); + }); + + it('should find user on mount', () => { + const mockAuthenticatedUser = { name: 'John Doe' }; + vi.mocked(useAuthenticatedUser).mockReturnValue(mockAuthenticatedUser as unknown as User); + const { result } = renderHook(() => useAuth(), { + wrapper: ({ children }) => ( + + {children} + ) + }); + + expect(userFound).toBeCalledWith(mockAuthenticatedUser); + }); + + it('should sync state for logged in user', () => { + const mockAuthenticatedUser = { name: 'John Doe' }; + vi.mocked(useAuthenticatedUser).mockReturnValue(mockAuthenticatedUser as unknown as User); + const mockApiTokens = { + getStoredApiTokens: vi.fn().mockReturnValue([null, 'dummyApiToken']), + }; + vi.mocked(useApiTokens).mockReturnValue(mockApiTokens as unknown as ReturnType); + const { result } = renderHook(() => useAuth(), { + wrapper: ({ children }) => ( + + {children} + ) + }); + + expect(userFound).toBeCalledWith(mockAuthenticatedUser); + expect(receiveApiToken).toBeCalledWith('dummyApiToken'); + expect(result.current.loggedIn).toBe(true); + }); + + it('should log out the user', () => { + const { result } = renderHook(() => useAuth(), { + wrapper: ({ children }) => ( + + {children} + ) + }); + + act(() => { + result.current.logout(); + }); + + expect(clearApiToken).toHaveBeenCalled(); + expect(clearUser).toHaveBeenCalled(); + expect(result.current.loggedIn).toBe(false); + }); + + it('should handle API token updates', async () => { + const mockApiTokens = { + getStoredApiTokens: vi.fn().mockReturnValue([null, 'dummyApiToken2']), + }; + vi.mocked(useApiTokens).mockReturnValue(mockApiTokens as unknown as ReturnType); + const apiTokensUpdatedSignal = createApiTokensClientEventSignal({ type: apiTokensClientEvents.API_TOKENS_UPDATED}); + vi.mocked(useApiTokensClientTracking).mockReturnValue([apiTokensUpdatedSignal, () => null, null]); + const { result } = renderHook(() => useAuth(), { + wrapper: ({ children }) => ( + + {children} + ) + }); + + expect(isApiTokensUpdatedSignal).toHaveBeenCalledWith(apiTokensUpdatedSignal); + expect(receiveApiToken).toHaveBeenCalledWith('dummyApiToken2'); + expect(result.current.loggedIn).toBe(true) + }); + + it('should handle API token removal', async () => { + const mockApiTokens = { + getStoredApiTokens: vi.fn().mockReturnValue([null, null]), + }; + vi.mocked(useApiTokens).mockReturnValue(mockApiTokens as unknown as ReturnType); + + const apiTokensRemovedSignal = createApiTokensClientEventSignal({ type: apiTokensClientEvents.API_TOKENS_REMOVED}); + vi.mocked(useApiTokensClientTracking).mockReturnValue([apiTokensRemovedSignal, () => null, null]); + const { result } = renderHook(() => useAuth(), { + wrapper: ({ children }) => ( + + {children} + ) + }); + expect(receiveApiToken).not.toHaveBeenCalled(); + expect(isApiTokensRemovedSignal).toHaveBeenCalledWith(apiTokensRemovedSignal); + expect(clearApiToken).toHaveBeenCalled(); + expect(result.current.loggedIn).toBe(false); + }); +}); diff --git a/src/auth/useAuth.ts b/src/auth/useAuth.ts new file mode 100644 index 00000000..34b8efe4 --- /dev/null +++ b/src/auth/useAuth.ts @@ -0,0 +1,77 @@ +import { useEffect, useCallback, useState } from 'react'; +import { useDispatch } from 'react-redux'; +import { + useApiTokens, + useOidcClient, + useApiTokensClientTracking, + isApiTokensUpdatedSignal, + isApiTokensRemovedSignal, + isApiTokensRenewalStartedSignal, + useAuthenticatedUser +} from 'hds-react'; +import { setRedirectUrlToSessionStorage } from '@/util/storage'; +import { Routes, getRouteById } from '@/root/routes'; +import { clearApiToken, clearUser, userFound, receiveApiToken } from './actions'; + + +const useAuth = () => { + const [loggedIn, setLoggedIn] = useState(false); + const { login: oidcLogin, logout: oidcLogout, isRenewing: oidcIsRenewing } = useOidcClient(); + const authenticatedUser = useAuthenticatedUser(); + const dispatch = useDispatch(); + const [apiTokensClientSignal, apiTokensClientSignalReset] = useApiTokensClientTracking(); + const { getStoredApiTokens } = useApiTokens(); + + const setLoggedInIfApiTokenExists = useCallback(() => { + const [_error, apiToken] = getStoredApiTokens(); + if (apiToken) { + dispatch(receiveApiToken(apiToken)); + setLoggedIn(true); + } + }, [getStoredApiTokens, dispatch]); + + useEffect(() => { + if (authenticatedUser) { + dispatch(userFound(authenticatedUser)); + setLoggedInIfApiTokenExists(); + } else { + dispatch(clearApiToken()); + dispatch(clearUser()); + setLoggedIn(false); + } + }, [authenticatedUser, dispatch, setLoggedInIfApiTokenExists]); + + useEffect(() => { + if (isApiTokensUpdatedSignal(apiTokensClientSignal)) { + setLoggedInIfApiTokenExists(); + } + if (isApiTokensRemovedSignal(apiTokensClientSignal)) { + dispatch(clearApiToken()); + setLoggedIn(false); + } + if (isApiTokensRenewalStartedSignal(apiTokensClientSignal)) { + // Placeholder for future use + } + + return apiTokensClientSignalReset; + }, [apiTokensClientSignal, dispatch]); + + const login = useCallback((redirectPath: string) => { + setRedirectUrlToSessionStorage(redirectPath || getRouteById(Routes.LEASES)); + oidcLogin(); + }, [oidcLogin]); + + const logout = useCallback(() => { + dispatch(clearApiToken()); + dispatch(clearUser()); + setLoggedIn(false); + oidcLogout(); + }, [oidcLogout, dispatch]); + + const isRenewing = useCallback(() => { + oidcIsRenewing + }, [oidcIsRenewing]); + return { loggedIn, authenticatedUser, login, logout, isRenewing, setLoggedInIfApiTokenExists }; +}; + +export default useAuth; diff --git a/src/leaseCreateCharge/requests.ts b/src/leaseCreateCharge/requests.ts index 022b65d9..bbe427d7 100644 --- a/src/leaseCreateCharge/requests.ts +++ b/src/leaseCreateCharge/requests.ts @@ -1,7 +1,7 @@ import callApi from "@/api/callApi"; import createUrl from "@/api/createUrl"; import { store } from "@/index"; -import { getCurrentLease } from '../leases/selectors'; +import { getCurrentLease } from '@/leases/selectors'; export const fetchAttributes = (): Generator => { return callApi(new Request(createUrl(`lease_create_charge/`), { method: 'OPTIONS' diff --git a/yarn.lock b/yarn.lock index 39237c57..ae33a046 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1082,6 +1082,13 @@ dependencies: regenerator-runtime "^0.14.0" +"@babel/runtime@^7.12.5": + version "7.25.6" + resolved "https://registry.yarnpkg.com/@babel/runtime/-/runtime-7.25.6.tgz#9afc3289f7184d8d7f98b099884c26317b9264d2" + integrity sha512-VBj9MYyDb9tuLq7yzqjgzt6Q+IBQLrGZfdjOekyEirZPHxXWoTSGUTMrpsfi58Up73d13NfYLv8HT9vmznjzhQ== + dependencies: + regenerator-runtime "^0.14.0" + "@babel/runtime@^7.7.6": version "7.24.5" resolved "https://registry.yarnpkg.com/@babel/runtime/-/runtime-7.24.5.tgz#230946857c053a36ccc66e1dd03b17dd0c4ed02c" @@ -1798,6 +1805,14 @@ dependencies: tslib "^2.4.0" +"@testing-library/react-hooks@^8.0.1": + version "8.0.1" + resolved "https://registry.yarnpkg.com/@testing-library/react-hooks/-/react-hooks-8.0.1.tgz#0924bbd5b55e0c0c0502d1754657ada66947ca12" + integrity sha512-Aqhl2IVmLt8IovEVarNDFuJDVWVvhnr9/GCU6UUnrYXwgDFF9h2L2o2P9KBni1AST5sT6riAyoukFLyjQUgD/g== + dependencies: + "@babel/runtime" "^7.12.5" + react-error-boundary "^3.1.0" + "@tsconfig/node10@^1.0.7": version "1.0.11" resolved "https://registry.yarnpkg.com/@tsconfig/node10/-/node10-1.0.11.tgz#6ee46400685f130e278128c7b38b7e031ff5b2f2" @@ -5498,6 +5513,13 @@ react-dual-listbox@^2.0.0: nanoid "^2.0.0" prop-types "^15.5.8" +react-error-boundary@^3.1.0: + version "3.1.4" + resolved "https://registry.yarnpkg.com/react-error-boundary/-/react-error-boundary-3.1.4.tgz#255db92b23197108757a888b01e5b729919abde0" + integrity sha512-uM9uPzZJTF6wRQORmSrvOIgt4lJ9MC1sNgEOj2XGsDTRE4kmpWxg7ENK9EWNKJRMAOY9z0MuF4yIfl6gp4sotA== + dependencies: + "@babel/runtime" "^7.12.5" + react-fast-compare@^3.0.1: version "3.2.2" resolved "https://registry.yarnpkg.com/react-fast-compare/-/react-fast-compare-3.2.2.tgz#929a97a532304ce9fee4bcae44234f1ce2c21d49" From 7772e65265593f85482d59a0fd7fe7b474493c9e Mon Sep 17 00:00:00 2001 From: Henri Nieminen Date: Fri, 13 Sep 2024 09:35:16 +0300 Subject: [PATCH 05/13] move user in redux store under auth, fix some tests --- src/auth/auth.spec.ts | 32 ++++++++++++++++---------------- src/auth/reducer.ts | 22 ++++++++-------------- src/auth/selectors.ts | 2 +- src/auth/useAuth.spec.tsx | 11 ++++------- src/root/createRootReducer.ts | 3 +-- 5 files changed, 30 insertions(+), 40 deletions(-) diff --git a/src/auth/auth.spec.ts b/src/auth/auth.spec.ts index 48dfdacd..e12bdf08 100644 --- a/src/auth/auth.spec.ts +++ b/src/auth/auth.spec.ts @@ -1,7 +1,7 @@ import { describe, expect, it } from "vitest"; import { User } from "oidc-client-ts"; import { clearApiToken, receiveApiToken, userFound, clearUser } from "./actions"; -import { authReducer, oidcReducer } from "./reducer"; +import { authReducer } from "./reducer"; const getMockUser = (): User => new User({ id_token: "id123", @@ -25,12 +25,25 @@ const getMockUser = (): User => new User({ describe('Auth', () => { describe('Reducer', () => { describe('authReducer', () => { + it('should set user', () => { + const mockUser = getMockUser(); + const mockState = Object.assign({}, {user: mockUser, apiToken: null}); + const state = authReducer({user: null}, userFound(mockUser)); + expect(state).to.deep.equal(mockState); + }); + it('should clear user', () => { + const mockUser = getMockUser(); + const state = authReducer({user: mockUser, apiToken: null}, clearUser()); + const nullUserState = {user: null, apiToken: null}; + expect(state).to.deep.equal(nullUserState); + }); it('should update apiToken', () => { const dummyApiToken = { 'foo': 'Lorem ipsum' }; const newState = { apiToken: dummyApiToken, + user: null, }; const state = authReducer({}, receiveApiToken(dummyApiToken)); expect(state).to.deep.equal(newState); @@ -40,26 +53,13 @@ describe('Auth', () => { 'foo': 'Lorem ipsum' }; const newState = { - apiToken: {}, + apiToken: null, + user: null, }; let state = authReducer({}, receiveApiToken(dummyApiToken)); state = authReducer(state, clearApiToken()); expect(state).to.deep.equal(newState); }); }); - describe('oidcReducer', () => { - it('should set user', () => { - const mockUser = getMockUser(); - const mockState = Object.assign({}, {user: mockUser,}); - const state = oidcReducer({user: null}, userFound(mockUser)); - expect(state).to.deep.equal(mockState); - }); - }); - it('should clear user', () => { - const mockUser = getMockUser(); - const state = oidcReducer({user: mockUser}, clearUser()); - const nullUserState = {user: null}; - expect(state).to.deep.equal(nullUserState); - }); }); }); diff --git a/src/auth/reducer.ts b/src/auth/reducer.ts index 3f8611dc..280d05e6 100644 --- a/src/auth/reducer.ts +++ b/src/auth/reducer.ts @@ -4,26 +4,20 @@ import type { Reducer } from "@/types"; import type { ReceiveApiTokenAction, ReceiveUserAction } from "./types"; const apiTokenReducer: Reducer = handleActions({ - ['mvj/auth/CLEAR_API_TOKEN']: () => {}, + ['mvj/auth/CLEAR_API_TOKEN']: () => null, ['mvj/auth/RECEIVE_API_TOKEN']: (state: any, { payload }: ReceiveApiTokenAction) => payload -}, {}); +}, null); -export const authReducer = combineReducers, any>({ - apiToken: apiTokenReducer, -}); - -const userReducer: Reducer = handleActions( - { - 'mvj/auth/USER_FOUND': (state: any, { +const userReducer: Reducer = handleActions({ + ['mvj/auth/USER_FOUND']: (state: any, { payload }: ReceiveUserAction) => payload, - 'mvj/auth/CLEAR_USER': () => null, - }, - null -); + ['mvj/auth/CLEAR_USER']: () => null, +}, null); -export const oidcReducer = combineReducers({ +export const authReducer = combineReducers, any>({ + apiToken: apiTokenReducer, user: userReducer, }); diff --git a/src/auth/selectors.ts b/src/auth/selectors.ts index 231890f4..9a9ff428 100644 --- a/src/auth/selectors.ts +++ b/src/auth/selectors.ts @@ -3,4 +3,4 @@ import type { ApiToken, AuthState } from "./types"; import type { User } from 'hds-react'; // Helper functions to select state export const getApiToken: Selector = (state: Record): AuthState => state.auth.apiToken[import.meta.env.VITE_OPENID_CONNECT_API_TOKEN_KEY || 'https://api.hel.fi/auth/mvj']; -export const getLoggedInUser: Selector, void> = (state: Record): User | null => state.oidc.user; \ No newline at end of file +export const getLoggedInUser: Selector, void> = (state: Record): User | null => state.auth.user; \ No newline at end of file diff --git a/src/auth/useAuth.spec.tsx b/src/auth/useAuth.spec.tsx index a12d3029..6718a443 100644 --- a/src/auth/useAuth.spec.tsx +++ b/src/auth/useAuth.spec.tsx @@ -99,10 +99,10 @@ describe('useAuth', () => { sessionPollerSettings: { pollIntervalInMs: 300000 } // 300000ms = 5min }; - beforeEach(() => { - mockStore = configureStore();//store; + beforeEach(async () => { + mockStore = configureStore(); vi.clearAllMocks(); - cleanup(); + await cleanup(); }); it('should initialize with loggedIn as false', () => { @@ -171,10 +171,7 @@ describe('useAuth', () => { ) }); - act(() => { - result.current.logout(); - }); - + result.current.logout(); expect(clearApiToken).toHaveBeenCalled(); expect(clearUser).toHaveBeenCalled(); expect(result.current.loggedIn).toBe(false); diff --git a/src/root/createRootReducer.ts b/src/root/createRootReducer.ts index 3a925bc2..bed40f8f 100644 --- a/src/root/createRootReducer.ts +++ b/src/root/createRootReducer.ts @@ -6,7 +6,7 @@ import apiReducer from "@/api/reducer"; import areaNoteReducer from "@/areaNote/reducer"; import areaSearchReducer from "@/areaSearch/reducer"; import auditLogReducer from "@/auditLog/reducer"; -import { authReducer, oidcReducer } from "@/auth/reducer"; +import { authReducer } from "@/auth/reducer"; import batchrunReducer from "@/batchrun/reducer"; import billingPeriodReducer from "@/billingPeriods/reducer"; import collectionCourtDecisionReducer from "@/collectionCourtDecision/reducer"; @@ -86,7 +86,6 @@ export default ((history: Record): Reducer => combineRed leaseStatisticReport: leaseStatisticReportReducer, leaseType: leaseTypeReducer, lessor: lessorReducer, - oidc: oidcReducer, penaltyInterest: penaltyInterestReducer, previewInvoices: previewInvoicesReducer, rentBasis: rentBasisReducer, From 4c14f01cd065542bc1a3df5161f21becad79f5a1 Mon Sep 17 00:00:00 2001 From: Henri Nieminen Date: Tue, 17 Sep 2024 10:37:28 +0300 Subject: [PATCH 06/13] Configure Keycloak --- .env.example | 12 ++++++++++-- src/auth/constants.ts | 26 +++++++++++++++++++++++++- src/auth/selectors.ts | 10 ++++++++-- 3 files changed, 43 insertions(+), 5 deletions(-) diff --git a/.env.example b/.env.example index 5afea5b9..9da70a9b 100644 --- a/.env.example +++ b/.env.example @@ -2,9 +2,17 @@ VITE_API_URL=https://mvj.dev.hel.ninja/v1 VITE_STORAGE_PREFIX=MVJ PORT=3000 -# The SSO OpenId Connect settings +# Helsinki Tunnistamo (legacy) VITE_OPENID_CONNECT_API_TOKEN_KEY=https://api.hel.fi/auth/mvj VITE_OPENID_CONNECT_API_TOKEN_URL=https://api.hel.fi/sso/api-tokens/ VITE_OPENID_CONNECT_AUTHORITY_URL=https://api.hel.fi/sso/openid/ VITE_OPENID_CONNECT_CLIENT_ID=https://api.hel.fi/auth/mvj -VITE_OPENID_CONNECT_SCOPE=openid profile mvj https://api.hel.fi/auth/mvj \ No newline at end of file +VITE_OPENID_CONNECT_SCOPE=openid profile mvj https://api.hel.fi/auth/mvj +# Helsinki Tunnistus +VITE_TUNNISTUS_OIDC_CLIENT_ID=mvj-admin-ui-dev +VITE_TUNNISTUS_OIDC_AUTHORITY_URL=https://tunnistus.test.hel.ninja/auth/realms/helsinki-tunnistus +VITE_TUNNISTUS_OIDC_SCOPE=openid profile +VITE_TUNNISTUS_OIDC_API_AUDIENCE=mvj-api-dev +VITE_TUNNISTUS_OIDC_API_TOKEN_URL=https://tunnistus.test.hel.ninja/auth/realms/helsinki-tunnistus/protocol/openid-connect/token +# Use legacy Tunnistamo SSO, or Tunnistus SSO? +VITE_USE_TUNNISTAMO_OPENID_CONNECT=true diff --git a/src/auth/constants.ts b/src/auth/constants.ts index f47aca6b..cd2c9b13 100644 --- a/src/auth/constants.ts +++ b/src/auth/constants.ts @@ -1,6 +1,7 @@ import type { LoginProviderProps } from 'hds-react'; -export const loginProviderProperties: LoginProviderProps = { +// Tunnistamo SSO (legacy) +const loginProviderTunnistamoProperties: LoginProviderProps = { userManagerSettings: { authority: import.meta.env.VITE_OPENID_CONNECT_AUTHORITY_URL || 'https://api.hel.fi/sso/openid/', client_id: import.meta.env.VITE_OPENID_CONNECT_CLIENT_ID || '', @@ -10,3 +11,26 @@ export const loginProviderProperties: LoginProviderProps = { apiTokensClientSettings: { url: import.meta.env.VITE_OPENID_CONNECT_API_TOKEN_URL }, sessionPollerSettings: { pollIntervalInMs: 300000 } // 300000ms = 5min }; + +// Tunnistus SSO +const loginProviderTunnistusProperties: LoginProviderProps = { + userManagerSettings: { + authority: import.meta.env.VITE_TUNNISTUS_OIDC_AUTHORITY_URL || 'https://tunnistus.hel.fi/auth/realms/helsinki-tunnistus', + client_id: import.meta.env.VITE_TUNNISTUS_OIDC_CLIENT_ID || '', + scope: import.meta.env.VITE_TUNNISTUS_OIDC_SCOPE || 'openid profile', + redirect_uri: `${location.origin}/callback`, + }, + apiTokensClientSettings: { + url: import.meta.env.VITE_TUNNISTUS_OIDC_API_TOKEN_URL || 'https://tunnistus.hel.fi/auth/realms/helsinki-tunnistus/protocol/openid-connect/token', + queryProps: { + grantType: 'urn:ietf:params:oauth:grant-type:uma-ticket', + permission: '#access', + }, + audiences: [import.meta.env.VITE_TUNNISTUS_OIDC_API_AUDIENCE], + }, + sessionPollerSettings: { pollIntervalInMs: 300000 } // 300000ms = 5min +}; + +export const useTunnistamoOpenIdConnect = import.meta.env.VITE_USE_TUNNISTAMO_OPENID_CONNECT === 'true' || import.meta.env.VITE_USE_TUNNISTAMO_OPENID_CONNECT === true; +// By default use Tunnistus SSO +export const loginProviderProperties = useTunnistamoOpenIdConnect ? loginProviderTunnistamoProperties : loginProviderTunnistusProperties; \ No newline at end of file diff --git a/src/auth/selectors.ts b/src/auth/selectors.ts index 9a9ff428..37d53021 100644 --- a/src/auth/selectors.ts +++ b/src/auth/selectors.ts @@ -1,6 +1,12 @@ import type { Selector } from "@/types"; import type { ApiToken, AuthState } from "./types"; import type { User } from 'hds-react'; +import { useTunnistamoOpenIdConnect } from "@/auth/constants"; // Helper functions to select state -export const getApiToken: Selector = (state: Record): AuthState => state.auth.apiToken[import.meta.env.VITE_OPENID_CONNECT_API_TOKEN_KEY || 'https://api.hel.fi/auth/mvj']; -export const getLoggedInUser: Selector, void> = (state: Record): User | null => state.auth.user; \ No newline at end of file +export const getApiToken: Selector = (state: Record): AuthState => { + if (useTunnistamoOpenIdConnect) { + return state.auth.apiToken[import.meta.env.VITE_OPENID_CONNECT_API_TOKEN_KEY || 'https://api.hel.fi/auth/mvj']; + } + return state.auth.apiToken[import.meta.env.VITE_TUNNISTUS_OIDC_API_AUDIENCE || 'mvj-api']; +}; +export const getLoggedInUser: Selector, void> = (state: Record): User | null => state.auth.user; From d7a73d1147eb933da33ed900754b237b6513a7e5 Mon Sep 17 00:00:00 2001 From: Henri Nieminen Date: Wed, 18 Sep 2024 16:19:21 +0300 Subject: [PATCH 07/13] auth reducer use empty string as default --- src/auth/auth.spec.ts | 14 +++++++------- src/auth/reducer.ts | 8 ++++---- 2 files changed, 11 insertions(+), 11 deletions(-) diff --git a/src/auth/auth.spec.ts b/src/auth/auth.spec.ts index e12bdf08..4fdaca15 100644 --- a/src/auth/auth.spec.ts +++ b/src/auth/auth.spec.ts @@ -27,14 +27,14 @@ describe('Auth', () => { describe('authReducer', () => { it('should set user', () => { const mockUser = getMockUser(); - const mockState = Object.assign({}, {user: mockUser, apiToken: null}); - const state = authReducer({user: null}, userFound(mockUser)); + const mockState = Object.assign({}, {user: mockUser, apiToken: ''}); + const state = authReducer({user: ''}, userFound(mockUser)); expect(state).to.deep.equal(mockState); }); it('should clear user', () => { const mockUser = getMockUser(); - const state = authReducer({user: mockUser, apiToken: null}, clearUser()); - const nullUserState = {user: null, apiToken: null}; + const state = authReducer({user: mockUser, apiToken: ''}, clearUser()); + const nullUserState = {user: '', apiToken: ''}; expect(state).to.deep.equal(nullUserState); }); it('should update apiToken', () => { @@ -43,7 +43,7 @@ describe('Auth', () => { }; const newState = { apiToken: dummyApiToken, - user: null, + user: '', }; const state = authReducer({}, receiveApiToken(dummyApiToken)); expect(state).to.deep.equal(newState); @@ -53,8 +53,8 @@ describe('Auth', () => { 'foo': 'Lorem ipsum' }; const newState = { - apiToken: null, - user: null, + apiToken: '', + user: '', }; let state = authReducer({}, receiveApiToken(dummyApiToken)); state = authReducer(state, clearApiToken()); diff --git a/src/auth/reducer.ts b/src/auth/reducer.ts index 280d05e6..c5ac1674 100644 --- a/src/auth/reducer.ts +++ b/src/auth/reducer.ts @@ -4,18 +4,18 @@ import type { Reducer } from "@/types"; import type { ReceiveApiTokenAction, ReceiveUserAction } from "./types"; const apiTokenReducer: Reducer = handleActions({ - ['mvj/auth/CLEAR_API_TOKEN']: () => null, + ['mvj/auth/CLEAR_API_TOKEN']: () => '', ['mvj/auth/RECEIVE_API_TOKEN']: (state: any, { payload }: ReceiveApiTokenAction) => payload -}, null); +}, ''); const userReducer: Reducer = handleActions({ ['mvj/auth/USER_FOUND']: (state: any, { payload }: ReceiveUserAction) => payload, - ['mvj/auth/CLEAR_USER']: () => null, -}, null); + ['mvj/auth/CLEAR_USER']: () => '', +}, ''); export const authReducer = combineReducers, any>({ apiToken: apiTokenReducer, From 09b82186b816e6b1ceb3cf6be2cfd47eb793529c Mon Sep 17 00:00:00 2001 From: Henri Nieminen Date: Wed, 18 Sep 2024 16:21:26 +0300 Subject: [PATCH 08/13] fix rerender on token renewal apiTokensRemovedSignal is also sent during token update process, not just on removal --- src/auth/useAuth.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/src/auth/useAuth.ts b/src/auth/useAuth.ts index 34b8efe4..66bf28bd 100644 --- a/src/auth/useAuth.ts +++ b/src/auth/useAuth.ts @@ -47,7 +47,6 @@ const useAuth = () => { } if (isApiTokensRemovedSignal(apiTokensClientSignal)) { dispatch(clearApiToken()); - setLoggedIn(false); } if (isApiTokensRenewalStartedSignal(apiTokensClientSignal)) { // Placeholder for future use From 91b68ba5eb5d88ca87f886c58790ffa834b75dbf Mon Sep 17 00:00:00 2001 From: Henri Nieminen Date: Wed, 18 Sep 2024 16:24:26 +0300 Subject: [PATCH 09/13] fix isRenewing in useAuth oidcRenewing was not called, and nothing was returned from the callback --- src/auth/useAuth.ts | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/src/auth/useAuth.ts b/src/auth/useAuth.ts index 66bf28bd..26b7fb54 100644 --- a/src/auth/useAuth.ts +++ b/src/auth/useAuth.ts @@ -19,7 +19,7 @@ const useAuth = () => { const { login: oidcLogin, logout: oidcLogout, isRenewing: oidcIsRenewing } = useOidcClient(); const authenticatedUser = useAuthenticatedUser(); const dispatch = useDispatch(); - const [apiTokensClientSignal, apiTokensClientSignalReset] = useApiTokensClientTracking(); + const [apiTokensClientSignal, apiTokensClientSignalReset, apiTokensClient] = useApiTokensClientTracking(); const { getStoredApiTokens } = useApiTokens(); const setLoggedInIfApiTokenExists = useCallback(() => { @@ -67,9 +67,10 @@ const useAuth = () => { oidcLogout(); }, [oidcLogout, dispatch]); - const isRenewing = useCallback(() => { - oidcIsRenewing - }, [oidcIsRenewing]); + const isRenewing = () => { + return oidcIsRenewing() || apiTokensClient.isRenewing(); + }; + return { loggedIn, authenticatedUser, login, logout, isRenewing, setLoggedInIfApiTokenExists }; }; From fe60dd1ea15c91de6d6456d46572eee624ebdb1d Mon Sep 17 00:00:00 2001 From: Henri Nieminen Date: Thu, 19 Sep 2024 08:34:31 +0300 Subject: [PATCH 10/13] add failsafe for user returning with errored callback url in login --- src/util/storage.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/util/storage.ts b/src/util/storage.ts index 9501ce9e..9d1e940c 100644 --- a/src/util/storage.ts +++ b/src/util/storage.ts @@ -132,5 +132,8 @@ export const getRedirectUrlFromSessionStorage = (): any => { * @param {string} url */ export const setRedirectUrlToSessionStorage = (url: string) => { - setSessionStorageItem('redirectURL', url); + // avoid setting redirectUrl to callback url, which could happen if there was an error during login + // and user returns to the callback url with error, and then tries to log in again + const redirectUrl = url?.startsWith('/callback') ? '/' : url; + setSessionStorageItem('redirectURL', redirectUrl); }; \ No newline at end of file From 2bb10de1c8208d68135e754b6e60a2a73cc7a463 Mon Sep 17 00:00:00 2001 From: Henri Nieminen Date: Thu, 19 Sep 2024 10:56:11 +0300 Subject: [PATCH 11/13] refactor redirect logic after login --- src/auth/useAuth.ts | 12 +++++++++++- src/util/storage.ts | 5 +---- 2 files changed, 12 insertions(+), 5 deletions(-) diff --git a/src/auth/useAuth.ts b/src/auth/useAuth.ts index 26b7fb54..14f269f0 100644 --- a/src/auth/useAuth.ts +++ b/src/auth/useAuth.ts @@ -55,8 +55,18 @@ const useAuth = () => { return apiTokensClientSignalReset; }, [apiTokensClientSignal, dispatch]); + const determineRedirectPath = (redirectPath: string): string => { + if (!redirectPath || redirectPath.startsWith('/callback')) { + return getRouteById(Routes.LEASES); + } + return redirectPath; + } + const login = useCallback((redirectPath: string) => { - setRedirectUrlToSessionStorage(redirectPath || getRouteById(Routes.LEASES)); + // avoid setting redirectPath to `/callback`, which could happen if there was an error during login + // and user returns to the callback url with error, and then tries to log in again + const finalRedirectPath = determineRedirectPath(redirectPath); + setRedirectUrlToSessionStorage(finalRedirectPath); oidcLogin(); }, [oidcLogin]); diff --git a/src/util/storage.ts b/src/util/storage.ts index 9d1e940c..9501ce9e 100644 --- a/src/util/storage.ts +++ b/src/util/storage.ts @@ -132,8 +132,5 @@ export const getRedirectUrlFromSessionStorage = (): any => { * @param {string} url */ export const setRedirectUrlToSessionStorage = (url: string) => { - // avoid setting redirectUrl to callback url, which could happen if there was an error during login - // and user returns to the callback url with error, and then tries to log in again - const redirectUrl = url?.startsWith('/callback') ? '/' : url; - setSessionStorageItem('redirectURL', redirectUrl); + setSessionStorageItem('redirectURL', url); }; \ No newline at end of file From d665904ad764e7982adaca534b71fbc5a254e102 Mon Sep 17 00:00:00 2001 From: Henri Nieminen Date: Thu, 19 Sep 2024 11:26:38 +0300 Subject: [PATCH 12/13] discard known HANDLING_LOGIN_CALLBACK error in hds-react --- src/auth/components/CallbackPage.tsx | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/src/auth/components/CallbackPage.tsx b/src/auth/components/CallbackPage.tsx index 222e374f..180146b0 100644 --- a/src/auth/components/CallbackPage.tsx +++ b/src/auth/components/CallbackPage.tsx @@ -15,6 +15,11 @@ const CallbackPage = (props: Props) => { history.push(getRedirectUrlFromSessionStorage() || getRouteById(Routes.LEASES)); }; const onError = (error: OidcClientError) => { + // "HANDLING_LOGIN_CALLBACK cannot be handled by a callback" is a known error in HDS + // https://hds.hel.fi/components/login/api/#logincallbackhandler + if (error.message === "Current state (HANDLING_LOGIN_CALLBACK) cannot be handled by a callback") { + return; + } console.error("Login Callback Error:", error); }; From 7a6710e21981bff8c8f5590f91fd71c74c849faf Mon Sep 17 00:00:00 2001 From: Henri Nieminen Date: Wed, 25 Sep 2024 13:28:21 +0300 Subject: [PATCH 13/13] refactor sso provider selection avoid using boolean values in envvars as yaml, ansible, .env evaluate them differently --- .env.example | 3 ++- src/auth/constants.ts | 9 +++++++-- src/auth/selectors.ts | 7 ++----- 3 files changed, 11 insertions(+), 8 deletions(-) diff --git a/.env.example b/.env.example index 9da70a9b..eee3ee84 100644 --- a/.env.example +++ b/.env.example @@ -15,4 +15,5 @@ VITE_TUNNISTUS_OIDC_SCOPE=openid profile VITE_TUNNISTUS_OIDC_API_AUDIENCE=mvj-api-dev VITE_TUNNISTUS_OIDC_API_TOKEN_URL=https://tunnistus.test.hel.ninja/auth/realms/helsinki-tunnistus/protocol/openid-connect/token # Use legacy Tunnistamo SSO, or Tunnistus SSO? -VITE_USE_TUNNISTAMO_OPENID_CONNECT=true +# Options: "tunnistamo", "tunnistus" +VITE_OIDC_PROVIDER=tunnistamo diff --git a/src/auth/constants.ts b/src/auth/constants.ts index cd2c9b13..019e05d3 100644 --- a/src/auth/constants.ts +++ b/src/auth/constants.ts @@ -1,5 +1,7 @@ import type { LoginProviderProps } from 'hds-react'; +type OidcProviderName = 'tunnistamo' | 'tunnistus'; + // Tunnistamo SSO (legacy) const loginProviderTunnistamoProperties: LoginProviderProps = { userManagerSettings: { @@ -31,6 +33,9 @@ const loginProviderTunnistusProperties: LoginProviderProps = { sessionPollerSettings: { pollIntervalInMs: 300000 } // 300000ms = 5min }; -export const useTunnistamoOpenIdConnect = import.meta.env.VITE_USE_TUNNISTAMO_OPENID_CONNECT === 'true' || import.meta.env.VITE_USE_TUNNISTAMO_OPENID_CONNECT === true; +export const oidcProviderName: OidcProviderName = import.meta.env.VITE_OIDC_PROVIDER || 'tunnistus'; // By default use Tunnistus SSO -export const loginProviderProperties = useTunnistamoOpenIdConnect ? loginProviderTunnistamoProperties : loginProviderTunnistusProperties; \ No newline at end of file +export const loginProviderProperties = oidcProviderName === 'tunnistamo' ? loginProviderTunnistamoProperties : loginProviderTunnistusProperties; +const tunnistamoApiTokenKeyName: string = import.meta.env.VITE_OPENID_CONNECT_API_TOKEN_KEY || 'https://api.hel.fi/auth/mvj'; +const tunnistusApiTokenKeyName: string = import.meta.env.VITE_TUNNISTUS_OIDC_API_AUDIENCE || 'mvj-api'; +export const apiTokenKeyName = oidcProviderName === 'tunnistamo' ? tunnistamoApiTokenKeyName : tunnistusApiTokenKeyName; \ No newline at end of file diff --git a/src/auth/selectors.ts b/src/auth/selectors.ts index 37d53021..90c7aca6 100644 --- a/src/auth/selectors.ts +++ b/src/auth/selectors.ts @@ -1,12 +1,9 @@ import type { Selector } from "@/types"; import type { ApiToken, AuthState } from "./types"; import type { User } from 'hds-react'; -import { useTunnistamoOpenIdConnect } from "@/auth/constants"; +import { apiTokenKeyName } from "@/auth/constants"; // Helper functions to select state export const getApiToken: Selector = (state: Record): AuthState => { - if (useTunnistamoOpenIdConnect) { - return state.auth.apiToken[import.meta.env.VITE_OPENID_CONNECT_API_TOKEN_KEY || 'https://api.hel.fi/auth/mvj']; - } - return state.auth.apiToken[import.meta.env.VITE_TUNNISTUS_OIDC_API_AUDIENCE || 'mvj-api']; + return state.auth.apiToken[apiTokenKeyName]; }; export const getLoggedInUser: Selector, void> = (state: Record): User | null => state.auth.user;