From 5096b4e6cb593ce7f07421f059178b66ed39af33 Mon Sep 17 00:00:00 2001 From: Remy van der Wereld Date: Wed, 4 Dec 2024 12:42:27 +0100 Subject: [PATCH 1/4] 131931 - Refactor authentication handling by removing Keycloak integration and adding OIDC ENTRA-ID --- .env.acceptance | 12 ++ .env.development | 15 +- index.html | 4 +- package-lock.json | 28 +++ package.json | 2 + src/App.tsx | 63 +++++-- .../auth/KeycloakValues/KeycloadValues.tsx | 15 -- .../auth/KeycloakValues/hooks/useValues.ts | 16 -- .../components/auth/OidcValues/OidcValues.tsx | 16 ++ .../ChangeHousingCorporation.tsx | 2 +- .../TableActions/DownloadDocument.tsx | 7 +- .../layouts/DefaultLayout/DefaultLayout.tsx | 3 +- .../components/shared/Modal/ConfirmModal.tsx | 2 +- .../shared/UserInfo/UserDisplay.tsx | 31 ++-- .../components/shared/UserInfo/UserInfo.tsx | 25 +-- .../LoadingScreen.tsx} | 5 +- .../shared/loading/LoadingScreenAmsterdam.tsx | 53 ++++++ .../shared/loading/LoadingScreenBasic.tsx | 40 +++++ .../SpinnerButton.tsx | 2 +- .../SpinnerWrapper.tsx | 2 +- src/app/components/shared/loading/index.ts | 5 + .../shared/navigation/DefaultNavigation.tsx | 5 +- src/app/pages/auth/AuthPage.tsx | 7 +- src/app/pages/cases/details/DetailsPage.tsx | 4 +- src/app/routing/components/PageTitle.tsx | 2 +- src/app/routing/components/ProtectedPage.tsx | 5 +- .../state/auth/keycloak/KeycloakProvider.tsx | 57 ------ .../keycloak/KeycloakTokenParsedExtended.ts | 9 - .../auth/keycloak/initializedCallback.ts | 18 -- .../state/auth/keycloak/isLocalDevelopment.ts | 3 - src/app/state/auth/keycloak/keycloak.mock.ts | 8 - src/app/state/auth/keycloak/keycloak.ts | 11 -- src/app/state/auth/keycloak/options.ts | 4 - src/app/state/auth/keycloak/settings.ts | 11 -- src/app/state/auth/keycloak/useKeycloak.ts | 8 - .../state/auth/{keycloak => oidc}/README.md | 5 +- src/app/state/auth/oidc/oidcConfig.ts | 17 ++ src/app/state/auth/oidc/useDecodedToken.ts | 20 +++ src/app/state/rest/hooks/useApiCache.ts | 4 +- .../state/rest/hooks/useApiRequest.test.tsx | 166 ------------------ .../state/rest/hooks/useProtectedRequest.ts | 13 +- src/index.tsx | 6 +- vite.config.mts | 4 +- 43 files changed, 326 insertions(+), 409 deletions(-) delete mode 100644 src/app/components/auth/KeycloakValues/KeycloadValues.tsx delete mode 100644 src/app/components/auth/KeycloakValues/hooks/useValues.ts create mode 100644 src/app/components/auth/OidcValues/OidcValues.tsx rename src/app/components/shared/{PageSpinner/PageSpinner.tsx => loading/LoadingScreen.tsx} (82%) create mode 100644 src/app/components/shared/loading/LoadingScreenAmsterdam.tsx create mode 100644 src/app/components/shared/loading/LoadingScreenBasic.tsx rename src/app/components/shared/{SpinnerButton => loading}/SpinnerButton.tsx (90%) rename src/app/components/shared/{SpinnerWrapper => loading}/SpinnerWrapper.tsx (87%) create mode 100644 src/app/components/shared/loading/index.ts delete mode 100644 src/app/state/auth/keycloak/KeycloakProvider.tsx delete mode 100644 src/app/state/auth/keycloak/KeycloakTokenParsedExtended.ts delete mode 100644 src/app/state/auth/keycloak/initializedCallback.ts delete mode 100644 src/app/state/auth/keycloak/isLocalDevelopment.ts delete mode 100644 src/app/state/auth/keycloak/keycloak.mock.ts delete mode 100644 src/app/state/auth/keycloak/keycloak.ts delete mode 100644 src/app/state/auth/keycloak/options.ts delete mode 100644 src/app/state/auth/keycloak/settings.ts delete mode 100644 src/app/state/auth/keycloak/useKeycloak.ts rename src/app/state/auth/{keycloak => oidc}/README.md (87%) create mode 100644 src/app/state/auth/oidc/oidcConfig.ts create mode 100644 src/app/state/auth/oidc/useDecodedToken.ts delete mode 100644 src/app/state/rest/hooks/useApiRequest.test.tsx diff --git a/.env.acceptance b/.env.acceptance index f0bb06a96..7ada9bed0 100644 --- a/.env.acceptance +++ b/.env.acceptance @@ -13,3 +13,15 @@ REACT_APP_HOST_TON=https://ton.woon-a.azure.amsterdam.nl/ REACT_APP_AUTH_URL=https://acc.iam.amsterdam.nl/auth/ REACT_APP_KEYCLOAK_REALM=datapunt-ad-acc REACT_APP_KEYCLOAK_CLIENT_ID=wonen-zaaksysteem-frontend + +############################# Vite ############################# + +# General +VITE_APP_TITLE="Amsterdamse Zaak Administratie" +VITE_APP_TITLE_SHORT="AZA" +VITE_APP_ENV_SHORT=ACC + +# ENTRA-ID +VITE_OIDC_CLIENT_ID=14c4257b-bcd1-4850-889e-7156c9efe2ec +VITE_OIDC_REDIRECT_URL=http://localhost:2999 + diff --git a/.env.development b/.env.development index e801499d8..c80e6d36e 100644 --- a/.env.development +++ b/.env.development @@ -1,6 +1,3 @@ -# NOTE: Variables need to be prefixed using REACT_APP_: -# https://create-react-app.dev/docs/adding-custom-environment-variables/ - # Commented variables are only to be used as examples # Add any local changes to `.env.development.local` so they won't be committed @@ -23,5 +20,13 @@ REACT_APP_HOST_TON=https://acc.ton.amsterdam.nl/ # To bypass Keycloak locally # REACT_APP_API_TOKEN={ generate token at http://localhost:8080/api/v1/swagger/#/oidc-authenticate/oidc_authenticate_create } -# Translations -REACT_APP_PAGE_TITLE="Amsterdamse Zaak Administratie | Gemeente Amsterdam" \ No newline at end of file +############################# Vite ############################# + +# General +VITE_APP_TITLE="Amsterdamse Zaak Administratie" +VITE_APP_TITLE_SHORT="AZA" +VITE_APP_ENV_SHORT=LOCAL + +# ENTRA-ID +VITE_OIDC_CLIENT_ID=14c4257b-bcd1-4850-889e-7156c9efe2ec +VITE_OIDC_REDIRECT_URL=http://localhost:2999 \ No newline at end of file diff --git a/index.html b/index.html index f907a7237..62f6f6a74 100644 --- a/index.html +++ b/index.html @@ -7,10 +7,10 @@ - + - %REACT_APP_PAGE_TITLE% + %VITE_APP_TITLE_SHORT% diff --git a/package-lock.json b/package-lock.json index 69a1b033f..62c0f440f 100644 --- a/package-lock.json +++ b/package-lock.json @@ -34,6 +34,7 @@ "final-form": "^4.20.10", "final-form-arrays": "^3.0.2", "immer": "^10.1.1", + "jwt-decode": "^4.0.0", "keycloak-js": "^25.0.6", "lodash.debounce": "^4.0.8", "lodash.isempty": "^4.4.0", @@ -43,6 +44,7 @@ "react-dom": "^17.0.2", "react-final-form": "^6.5.9", "react-final-form-arrays": "^3.1.4", + "react-oidc-context": "^3.2.0", "react-router-dom": "^6.28.0", "resize-observer-polyfill": "^1.5.1", "styled-components": "^5.3.11", @@ -13128,6 +13130,19 @@ "license": "ISC", "optional": true }, + "node_modules/oidc-client-ts": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/oidc-client-ts/-/oidc-client-ts-3.1.0.tgz", + "integrity": "sha512-IDopEXjiwjkmJLYZo6BTlvwOtnlSniWZkKZoXforC/oLZHC9wkIxd25Kwtmo5yKFMMVcsp3JY6bhcNJqdYk8+g==", + "license": "Apache-2.0", + "peer": true, + "dependencies": { + "jwt-decode": "^4.0.0" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/once": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", @@ -13811,6 +13826,19 @@ "integrity": "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==", "license": "MIT" }, + "node_modules/react-oidc-context": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/react-oidc-context/-/react-oidc-context-3.2.0.tgz", + "integrity": "sha512-ZLaCRLWV84Cn9pFdsatmblqxLMv0np69GWVXq9RWGqAjppdOGXNIbIxWMByIio0oSCVUwdeqwYRnJme0tjqd8A==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "oidc-client-ts": "^3.1.0", + "react": ">=16.8.0" + } + }, "node_modules/react-refresh": { "version": "0.14.2", "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.14.2.tgz", diff --git a/package.json b/package.json index 24339e192..4cd156d08 100644 --- a/package.json +++ b/package.json @@ -51,6 +51,7 @@ "final-form": "^4.20.10", "final-form-arrays": "^3.0.2", "immer": "^10.1.1", + "jwt-decode": "^4.0.0", "keycloak-js": "^25.0.6", "lodash.debounce": "^4.0.8", "lodash.isempty": "^4.4.0", @@ -60,6 +61,7 @@ "react-dom": "^17.0.2", "react-final-form": "^6.5.9", "react-final-form-arrays": "^3.1.4", + "react-oidc-context": "^3.2.0", "react-router-dom": "^6.28.0", "resize-observer-polyfill": "^1.5.1", "styled-components": "^5.3.11", diff --git a/src/App.tsx b/src/App.tsx index b17402d08..0f311658b 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,25 +1,54 @@ -import React from "react" +import React, { useEffect, useState } from "react" import { ThemeProvider, GlobalStyle } from "@amsterdam/asc-ui" import { BrowserRouter } from "react-router-dom" -import KeycloakProvider from "app/state/auth/keycloak/KeycloakProvider" -import initializedCallback from "app/state/auth/keycloak/initializedCallback" +import { hasAuthParams, useAuth } from "react-oidc-context" import Router from "app/routing/components/Router" import FlashMessageProvider from "app/state/flashMessages/FlashMessageProvider" import ApiProvider from "app/state/rest/provider/ApiProvider" import ValueProvider from "app/state/context/ValueProvider" -import isLocalDevelopment from "app/state/auth/keycloak/isLocalDevelopment" import PageTitle from "app/routing/components/PageTitle" +// import { env } from "app/config/env" +import { LoadingScreenBasic, FullScreenWrapper } from "app/components/shared/loading" +const App = () => { + const auth = useAuth() + const [hasTriedSignin, setHasTriedSignin] = useState(false) -const App = () => ( - - - - - + useEffect(() => { + if ( + !hasAuthParams() && + !auth.isAuthenticated && + !auth.activeNavigator && + !auth.isLoading && + !hasTriedSignin + ) { + // TODO: Redirect to the current URL after login + // Redirect uri must be change to enable this: http://localhost:2999/* ? + // auth.signinRedirect({ + // redirect_uri: `${ env.VITE_OIDC_REDIRECT_URL }${ window.location.pathname }` + // }) + auth.signinRedirect() + setHasTriedSignin(true) + } + }, [auth, hasTriedSignin]) + + if (auth.isLoading) { + return + } + + if (auth.error) { + return Oops... {auth.error.message} + } + + if (!auth.isAuthenticated) { + return Sorry, het is niet gelukt om in te loggen. + } + + return ( + + + + @@ -29,9 +58,9 @@ const App = () => ( - - - -) + + + ) +} export default App diff --git a/src/app/components/auth/KeycloakValues/KeycloadValues.tsx b/src/app/components/auth/KeycloakValues/KeycloadValues.tsx deleted file mode 100644 index 1ad384be8..000000000 --- a/src/app/components/auth/KeycloakValues/KeycloadValues.tsx +++ /dev/null @@ -1,15 +0,0 @@ -import type KeycloakTokenParsedExtended from "app/state/auth/keycloak/KeycloakTokenParsedExtended" -import useKeycloak from "app/state/auth/keycloak/useKeycloak" -import { DefinitionList } from "@amsterdam/wonen-ui" -import useValues from "./hooks/useValues" - -const KeycloakValues: React.FC = () => { - - const keycloak = useKeycloak() - const tokenParsed = keycloak.tokenParsed as KeycloakTokenParsedExtended - const values = useValues(keycloak, tokenParsed) - - return -} - -export default KeycloakValues \ No newline at end of file diff --git a/src/app/components/auth/KeycloakValues/hooks/useValues.ts b/src/app/components/auth/KeycloakValues/hooks/useValues.ts deleted file mode 100644 index f3b075b5d..000000000 --- a/src/app/components/auth/KeycloakValues/hooks/useValues.ts +++ /dev/null @@ -1,16 +0,0 @@ -import type KeycloakTokenParsedExtended from "app/state/auth/keycloak/KeycloakTokenParsedExtended" -import { KeycloakInstance } from "keycloak-js" - -export default (keycloak?: KeycloakInstance, token?: KeycloakTokenParsedExtended) => { - if (keycloak === undefined || token === undefined) return - - const values = [ - ["Naam", token.name], - ["E-mail", token.email], - ["Gebruikersnaam", token.preferred_username], - ["Keycloak groepen", keycloak.realmAccess?.roles.join(", ") ?? "-"], - ["aud", token.aud] - ] - - return Object.fromEntries(values) -} \ No newline at end of file diff --git a/src/app/components/auth/OidcValues/OidcValues.tsx b/src/app/components/auth/OidcValues/OidcValues.tsx new file mode 100644 index 000000000..650e0ad7c --- /dev/null +++ b/src/app/components/auth/OidcValues/OidcValues.tsx @@ -0,0 +1,16 @@ +import { DefinitionList } from "@amsterdam/wonen-ui" +import { useDecodedToken } from "app/state/auth/oidc/useDecodedToken" + + +const OidcValues: React.FC = () => { + const decodedToken = useDecodedToken() + const values = decodedToken ? { + "Voornaam": decodedToken?.given_name, + "Achternaam": decodedToken?.family_name, + "E-mail": decodedToken?.unique_name + } : {} + + return +} + +export default OidcValues \ No newline at end of file diff --git a/src/app/components/case/CaseDetails/ChangeHousingCorporation/ChangeHousingCorporation.tsx b/src/app/components/case/CaseDetails/ChangeHousingCorporation/ChangeHousingCorporation.tsx index af1b4ae31..74f55830d 100644 --- a/src/app/components/case/CaseDetails/ChangeHousingCorporation/ChangeHousingCorporation.tsx +++ b/src/app/components/case/CaseDetails/ChangeHousingCorporation/ChangeHousingCorporation.tsx @@ -4,7 +4,7 @@ import { useCorporations, useAddresses, useCase } from "app/state/rest" import ChangeableItem from "../ChangeableItem/ChangeableItem" import Modal, { ModalBlock } from "app/components/shared/Modal/Modal" import ChangeHousingCorporationForm from "./ChangeHousingCorporationForm" -import SpinnerWrapper from "app/components/shared/SpinnerWrapper/SpinnerWrapper" +import { SpinnerWrapper } from "app/components/shared/loading" type Props = { housingCorporationId?: Components.Schemas.HousingCorporation["id"] | null diff --git a/src/app/components/case/Documents/DocumentsTable/TableActions/DownloadDocument.tsx b/src/app/components/case/Documents/DocumentsTable/TableActions/DownloadDocument.tsx index ba025db5e..ad42f71e0 100644 --- a/src/app/components/case/Documents/DocumentsTable/TableActions/DownloadDocument.tsx +++ b/src/app/components/case/Documents/DocumentsTable/TableActions/DownloadDocument.tsx @@ -2,7 +2,7 @@ import { Button, Spinner } from "@amsterdam/asc-ui" import { Download } from "@amsterdam/asc-assets" import { makeApiUrl } from "app/state/rest/hooks/utils/apiUrl" import { useState } from "react" -import useKeycloak from "app/state/auth/keycloak/useKeycloak" +import { useAuth } from "react-oidc-context" type Props = { record: any @@ -12,14 +12,15 @@ type Props = { const DownloadDocument: React.FC = ({ record, size = 20 }) => { const [loading, setLoading] = useState(false) const apiUrl = makeApiUrl("documents", record.id, "download") - const keycloak = useKeycloak() + const auth = useAuth() const downloadFile = async () => { setLoading(true) + const token = auth.user?.access_token fetch(apiUrl, { method: "GET", headers: { - "Authorization": `Bearer ${ keycloak.token }` + "Authorization": `Bearer ${ token }` } }) .then((response) => { diff --git a/src/app/components/layouts/DefaultLayout/DefaultLayout.tsx b/src/app/components/layouts/DefaultLayout/DefaultLayout.tsx index aab38f26f..c6e028792 100644 --- a/src/app/components/layouts/DefaultLayout/DefaultLayout.tsx +++ b/src/app/components/layouts/DefaultLayout/DefaultLayout.tsx @@ -7,6 +7,7 @@ import FlashMessages from "app/components/layouts/FlashMessages/FlashMessages" import UserInfo from "app/components/shared/UserInfo/UserInfo" import SkipLinks from "app/components/shared/SkipLinks/SkipLinks" import BreadCrumbsWrap from "app/components/shared/BreadCrumbs/BreadCrumbsWrap" +import { env } from "app/config/env" type Props = { showSearchButton?: boolean @@ -43,7 +44,7 @@ const DefaultLayout: React.FC = ({ showSearchButton = true, children }) =
diff --git a/src/app/components/shared/Modal/ConfirmModal.tsx b/src/app/components/shared/Modal/ConfirmModal.tsx index 64b893b4e..48b007bf5 100644 --- a/src/app/components/shared/Modal/ConfirmModal.tsx +++ b/src/app/components/shared/Modal/ConfirmModal.tsx @@ -3,7 +3,7 @@ import styled from "styled-components" import { Button, Paragraph } from "@amsterdam/asc-ui" import Modal, { ModalBlock } from "./Modal" -import SpinnerButton from "app/components/shared/SpinnerButton/SpinnerButton" +import { SpinnerButton } from "app/components/shared/loading" export type Props = { title: string diff --git a/src/app/components/shared/UserInfo/UserDisplay.tsx b/src/app/components/shared/UserInfo/UserDisplay.tsx index e0104b29b..19f0b6a29 100644 --- a/src/app/components/shared/UserInfo/UserDisplay.tsx +++ b/src/app/components/shared/UserInfo/UserDisplay.tsx @@ -7,7 +7,7 @@ type Props = { onClick: () => void } -const Div = styled.div` +const UserWrapper = styled.div` padding: 12px 0 0 16px; vertical-align: middle; height: 54px; @@ -39,20 +39,25 @@ const StyledMenuButton = styled(MenuButton)` padding: 12px 16px 9px; ` -const UserDisplay: React.FC = ({ name, onClick }) => +const UserDisplay: React.FC = ({ name, onClick }) => ( <> - { name && -
- - { name } -
- } + {name && ( + + + + + {name} + + )} } + tabIndex={0} + onClick={onClick} + iconLeft={} title="Uitloggen" - iconSize={ 24 } - >Uitloggen + iconSize={24} + > + Uitloggen + +) export default UserDisplay diff --git a/src/app/components/shared/UserInfo/UserInfo.tsx b/src/app/components/shared/UserInfo/UserInfo.tsx index 24f6a4928..b965f7b30 100644 --- a/src/app/components/shared/UserInfo/UserInfo.tsx +++ b/src/app/components/shared/UserInfo/UserInfo.tsx @@ -1,20 +1,23 @@ - -import type KeycloakTokenParsedExtended from "app/state/auth/keycloak/KeycloakTokenParsedExtended" - -import useKeycloak from "app/state/auth/keycloak/useKeycloak" +import { useAuth } from "react-oidc-context" import UserDisplay from "./UserDisplay" +import { useDecodedToken } from "app/state/auth/oidc/useDecodedToken" type Props = { showAsListItem?: boolean -} +}; const UserInfo: React.FC = ({ showAsListItem = false }) => { - const { tokenParsed, logout } = useKeycloak() - const name = (tokenParsed as KeycloakTokenParsedExtended)?.name - const userDisplay = + const auth = useAuth() + const decodedToken = useDecodedToken() + + const userDisplay = ( + + ) - return showAsListItem ? -
  • { userDisplay }
  • : - { userDisplay } + return showAsListItem ? ( +
  • {userDisplay}
  • + ) : ( + {userDisplay} + ) } export default UserInfo diff --git a/src/app/components/shared/PageSpinner/PageSpinner.tsx b/src/app/components/shared/loading/LoadingScreen.tsx similarity index 82% rename from src/app/components/shared/PageSpinner/PageSpinner.tsx rename to src/app/components/shared/loading/LoadingScreen.tsx index 1781175dc..7840fe015 100644 --- a/src/app/components/shared/PageSpinner/PageSpinner.tsx +++ b/src/app/components/shared/loading/LoadingScreen.tsx @@ -9,7 +9,8 @@ const Wrap = styled.div` justify-content: center; height: 400px; ` -const PageSpinner: React.FC = () => ( + +export const LoadingScreen: React.FC = () => ( @@ -17,4 +18,4 @@ const PageSpinner: React.FC = () => ( ) -export default PageSpinner \ No newline at end of file +export default LoadingScreen diff --git a/src/app/components/shared/loading/LoadingScreenAmsterdam.tsx b/src/app/components/shared/loading/LoadingScreenAmsterdam.tsx new file mode 100644 index 000000000..156fd4cef --- /dev/null +++ b/src/app/components/shared/loading/LoadingScreenAmsterdam.tsx @@ -0,0 +1,53 @@ +import React from "react" +import styled, { keyframes } from "styled-components" + +const CenterWrapper = styled.div` + display: flex; + justify-content: center; + align-items: center; + flex-direction: column; + height: 100vh; + width: 100vw; +` + +const spin = keyframes` + 100% { + transform: rotate(calc(var(--s, 1) * 1turn)); + } +` + +const Cross = styled.div<{ duration: number, delay: number }>` + width: 100px; + aspect-ratio: 1; + padding: 10px; + box-sizing: border-box; + display: grid; + background: #fff; + filter: blur(4px) contrast(10); + mix-blend-mode: darken; + + &::before, + &::after { + content: ""; + grid-area: 1 / 1; + margin: 30px 0; + border-radius: 100px; + background: #ec0000; + animation: ${ spin } ${ ({ duration }) => duration }s infinite linear; + animation-delay: ${ ({ delay }) => delay }s; + } + + &::after { + --s: -1; + } +` + +export const LoadingScreenAmsterdam: React.FC = () => ( + + + + + +) + +export default LoadingScreenAmsterdam diff --git a/src/app/components/shared/loading/LoadingScreenBasic.tsx b/src/app/components/shared/loading/LoadingScreenBasic.tsx new file mode 100644 index 000000000..831b2a4a6 --- /dev/null +++ b/src/app/components/shared/loading/LoadingScreenBasic.tsx @@ -0,0 +1,40 @@ +import styled, { keyframes } from "styled-components" + +export const FullScreenWrapper = styled.div` + display: flex; + justify-content: center; + align-items: center; + height: 100vh; + width: 100vw; +` + +const spin = keyframes` + 100% { + transform: rotate(1turn); + } +` + +export const BasicSpinner = styled.div<{ color?: string, size?: number }>` + width: ${ ({ size }) => size ?? 50 }px; + aspect-ratio: 1; + border-radius: 50%; + background: + radial-gradient( + farthest-side, + ${ ({ color }) => color ?? "#ffa516" } 94%, + #0000 + ) + top/8px 8px no-repeat, + conic-gradient(#0000 30%, ${ ({ color }) => color ?? "#ffa516" }); + -webkit-mask: radial-gradient(farthest-side, #0000 calc(100% - 8px), #000 0); + animation: ${ spin } 1s infinite linear; +` + +export const LoadingScreenBasic: React.FC<{ + color?: string + size?: number +}> = ({ color = "#ec0000", size = 60 }) => ( + + + +) diff --git a/src/app/components/shared/SpinnerButton/SpinnerButton.tsx b/src/app/components/shared/loading/SpinnerButton.tsx similarity index 90% rename from src/app/components/shared/SpinnerButton/SpinnerButton.tsx rename to src/app/components/shared/loading/SpinnerButton.tsx index 6bcc339a7..5d1021cb0 100644 --- a/src/app/components/shared/SpinnerButton/SpinnerButton.tsx +++ b/src/app/components/shared/loading/SpinnerButton.tsx @@ -7,7 +7,7 @@ type Props = Omit, "onClick"> & { onClick: () => Promise } -const SpinnerButton: React.FC = ({ onClick, ...restProps }) => { +export const SpinnerButton: React.FC = ({ onClick, ...restProps }) => { const isMounted = useIsMounted() const [isSpinning, setIsSpinning] = useState(false) diff --git a/src/app/components/shared/SpinnerWrapper/SpinnerWrapper.tsx b/src/app/components/shared/loading/SpinnerWrapper.tsx similarity index 87% rename from src/app/components/shared/SpinnerWrapper/SpinnerWrapper.tsx rename to src/app/components/shared/loading/SpinnerWrapper.tsx index 78be37a85..4f6832994 100644 --- a/src/app/components/shared/SpinnerWrapper/SpinnerWrapper.tsx +++ b/src/app/components/shared/loading/SpinnerWrapper.tsx @@ -20,7 +20,7 @@ const SpinnerContainer = styled.div` ` -const SpinnerWrapper: React.FC = ({ spinning = true, children }) => ( +export const SpinnerWrapper: React.FC = ({ spinning = true, children }) => (
    { spinning && ( diff --git a/src/app/components/shared/loading/index.ts b/src/app/components/shared/loading/index.ts new file mode 100644 index 000000000..1b50b9146 --- /dev/null +++ b/src/app/components/shared/loading/index.ts @@ -0,0 +1,5 @@ +export * from "./LoadingScreenBasic" +export * from "./LoadingScreen" +export * from "./LoadingScreenAmsterdam" +export * from "./SpinnerButton" +export * from "./SpinnerWrapper" diff --git a/src/app/components/shared/navigation/DefaultNavigation.tsx b/src/app/components/shared/navigation/DefaultNavigation.tsx index b9b0647c9..6d79a188a 100644 --- a/src/app/components/shared/navigation/DefaultNavigation.tsx +++ b/src/app/components/shared/navigation/DefaultNavigation.tsx @@ -1,8 +1,8 @@ +import { useAuth } from "react-oidc-context" import { MenuInline, Button, MenuToggle, Hidden } from "@amsterdam/asc-ui" import styled from "styled-components" import ButtonLink from "app/components/shared/ButtonLink/ButtonLink" import to from "app/routing/utils/to" -import useKeycloak from "app/state/auth/keycloak/useKeycloak" import { Search, Help } from "app/components/shared/Icons" import MenuItems from "app/components/shared/navigation/MenuItems" import UserInfo from "../UserInfo/UserInfo" @@ -16,7 +16,8 @@ const IconButton = styled(Button)` ` const DefaultNavigation: React.FC = ({ showSearchButton }) => { - const { token } = useKeycloak() + const auth = useAuth() + const token = auth.user?.access_token if (!token) return null diff --git a/src/app/pages/auth/AuthPage.tsx b/src/app/pages/auth/AuthPage.tsx index 6b0e402df..da8ed9ba9 100644 --- a/src/app/pages/auth/AuthPage.tsx +++ b/src/app/pages/auth/AuthPage.tsx @@ -1,15 +1,14 @@ import { Heading } from "@amsterdam/asc-ui" - import DefaultLayout from "app/components/layouts/DefaultLayout/DefaultLayout" import NotAuthorizedAlert from "app/components/auth/NotAuthorizedAlert/NotAuthorizedAlert" -import KeycloakValues from "app/components/auth/KeycloakValues/KeycloadValues" +import OidcValues from "app/components/auth/OidcValues/OidcValues" const AuthPage: React.FC = () => ( - Keycloak gebruiker + Microsoft Entra-ID gebruiker - + ) diff --git a/src/app/pages/cases/details/DetailsPage.tsx b/src/app/pages/cases/details/DetailsPage.tsx index 43a27c936..cc3ca6b8b 100644 --- a/src/app/pages/cases/details/DetailsPage.tsx +++ b/src/app/pages/cases/details/DetailsPage.tsx @@ -13,7 +13,7 @@ import DetailHeaderByCaseId from "app/components/shared/DetailHeader/DetailHeade import { Column } from "app/components/layouts/Grid" import CaseStatus from "app/components/case/CaseStatus/CaseStatus" import useExistingCase from "./hooks/useExistingCase" -import PageSpinner from "app/components/shared/PageSpinner/PageSpinner" +import { LoadingScreen } from "app/components/shared/loading" import CaseNuisanceAlert from "app/components/case/CaseNuisanceAlert/CaseNuisanceAlert" import useHasPermission, { SENSITIVE_CASE_PERMISSION } from "app/state/rest/custom/usePermissions/useHasPermission" import NotAuthorizedPage from "app/pages/auth/NotAuthorizedPage" @@ -41,7 +41,7 @@ const DetailsPage: React.FC = () => { const [isDocumentsTabActive, setIsDocumentsTabActive] = useState(false) if (showSpinner) { - return + return } if (exists && !isAuthorized) { return diff --git a/src/app/routing/components/PageTitle.tsx b/src/app/routing/components/PageTitle.tsx index b5a1df728..b1750a904 100644 --- a/src/app/routing/components/PageTitle.tsx +++ b/src/app/routing/components/PageTitle.tsx @@ -3,7 +3,7 @@ import find from "../utils/find" import routes from "app/routing/routes" import { env } from "app/config/env" -const PAGE_TITLE = env.REACT_APP_PAGE_TITLE ?? "" +const PAGE_TITLE = env.VITE_APP_TITLE_SHORT ?? "" const setPageTitle = () => { const route = find(routes, window.location.pathname) diff --git a/src/app/routing/components/ProtectedPage.tsx b/src/app/routing/components/ProtectedPage.tsx index 19ee5fa8b..6d28bdf58 100644 --- a/src/app/routing/components/ProtectedPage.tsx +++ b/src/app/routing/components/ProtectedPage.tsx @@ -1,4 +1,4 @@ -import useKeycloak from "app/state/auth/keycloak/useKeycloak" +import { useAuth } from "react-oidc-context" import AuthorizedPage from "./AuthorizedPage" type Props = { @@ -10,7 +10,8 @@ type Props = { * The user needs to be logged on to visit this route */ const ProtectedPage: React.FC = (props) => { - const { token } = useKeycloak() + const auth = useAuth() + const token = auth.user?.access_token if (token === undefined) return null diff --git a/src/app/state/auth/keycloak/KeycloakProvider.tsx b/src/app/state/auth/keycloak/KeycloakProvider.tsx deleted file mode 100644 index 8214546e0..000000000 --- a/src/app/state/auth/keycloak/KeycloakProvider.tsx +++ /dev/null @@ -1,57 +0,0 @@ -import { createContext, useState, useEffect } from "react" -import Keycloak from "keycloak-js" -import { keycloak } from "./keycloak" -import options from "./options" - -export type Context = { - isInitialized: boolean - isAuthenticated: boolean - keycloak: Keycloak -} -export const KeycloakContext = createContext(undefined) - -type Props = { - shouldInitialize?: boolean - initializedCallback?: (keycloak: Keycloak, isAuthenticated: boolean) => Promise -} - -const KeycloakProvider: React.FC = ({ - shouldInitialize = true, initializedCallback, children -}) => { - const [isInitialized, setIsInitialized] = useState(false) - const [isAuthenticated, setIsAuthenticated] = useState(false) - - useEffect(() => { - if (shouldInitialize === false) { - return - } - if (isAuthenticated && isInitialized) { - return - } - (async () => { - try { - const isAuthenticated = await keycloak.init(options) - setIsInitialized(true) - setIsAuthenticated(isAuthenticated) - if (initializedCallback !== undefined) await initializedCallback(keycloak, isAuthenticated) - } catch (err) { - console.error("Keycloak failed to initialize") - console.error(err) - } - })() - // eslint-disable-next-line - }, [initializedCallback, shouldInitialize]) - - const value = { - isInitialized, - isAuthenticated, - keycloak - } - - return ( - - { children } - - ) -} -export default KeycloakProvider diff --git a/src/app/state/auth/keycloak/KeycloakTokenParsedExtended.ts b/src/app/state/auth/keycloak/KeycloakTokenParsedExtended.ts deleted file mode 100644 index 21f3097b8..000000000 --- a/src/app/state/auth/keycloak/KeycloakTokenParsedExtended.ts +++ /dev/null @@ -1,9 +0,0 @@ -import { KeycloakTokenParsed } from "keycloak-js" -type KeycloakTokenParsedExtended = - KeycloakTokenParsed & - { - name: string - email: string - preferred_username: string - } -export default KeycloakTokenParsedExtended diff --git a/src/app/state/auth/keycloak/initializedCallback.ts b/src/app/state/auth/keycloak/initializedCallback.ts deleted file mode 100644 index 1f68bc7ed..000000000 --- a/src/app/state/auth/keycloak/initializedCallback.ts +++ /dev/null @@ -1,18 +0,0 @@ -import Keyloack from "keycloak-js" -import { makeApiUrl } from "app/state/rest/hooks/utils/apiUrl" -import createAuthHeaders from "app/state/rest/hooks/utils/createAuthHeaders" - -export default async (keycloak: Keyloack, isAuthenticated: boolean) => { - if (isAuthenticated === false) return - const response = await fetch(makeApiUrl("is-authorized"), { - headers: { - ...createAuthHeaders(keycloak.token ?? ""), - "Content-Type": "application/json" - } - }) - const { is_authorized } = await response.json() - if (is_authorized === false) { - // This is not working outside the routing wrapper. - // navigateTo("/auth") - } -} diff --git a/src/app/state/auth/keycloak/isLocalDevelopment.ts b/src/app/state/auth/keycloak/isLocalDevelopment.ts deleted file mode 100644 index f263c49a3..000000000 --- a/src/app/state/auth/keycloak/isLocalDevelopment.ts +++ /dev/null @@ -1,3 +0,0 @@ -import { env } from "app/config/env" - -export default env.NODE_ENV === "development" && env.REACT_APP_API_TOKEN !== undefined diff --git a/src/app/state/auth/keycloak/keycloak.mock.ts b/src/app/state/auth/keycloak/keycloak.mock.ts deleted file mode 100644 index 30ce25e0e..000000000 --- a/src/app/state/auth/keycloak/keycloak.mock.ts +++ /dev/null @@ -1,8 +0,0 @@ -import { env } from "app/config/env" - -export default { - init: async () => {}, - updateToken: async () => {}, - logout: () => {}, - token: env.REACT_APP_API_TOKEN ?? "" -} diff --git a/src/app/state/auth/keycloak/keycloak.ts b/src/app/state/auth/keycloak/keycloak.ts deleted file mode 100644 index 9d55f3833..000000000 --- a/src/app/state/auth/keycloak/keycloak.ts +++ /dev/null @@ -1,11 +0,0 @@ -import settings from "./settings" -import Keycloak from "keycloak-js" -import keycloakMock from "./keycloak.mock" -import isLocalDevelopment from "./isLocalDevelopment" -import { env } from "app/config/env" - -export const keycloak = env.NODE_ENV !== "test" && isLocalDevelopment === false ? new (Keycloak as any)(settings) : keycloakMock - -if (env.NODE_ENV === "development") { - (window as any).keycloak = keycloak -} diff --git a/src/app/state/auth/keycloak/options.ts b/src/app/state/auth/keycloak/options.ts deleted file mode 100644 index 5e606a92f..000000000 --- a/src/app/state/auth/keycloak/options.ts +++ /dev/null @@ -1,4 +0,0 @@ -export default { - onLoad: "login-required", - checkLoginIframe: false -} diff --git a/src/app/state/auth/keycloak/settings.ts b/src/app/state/auth/keycloak/settings.ts deleted file mode 100644 index edaf3473a..000000000 --- a/src/app/state/auth/keycloak/settings.ts +++ /dev/null @@ -1,11 +0,0 @@ -import { env } from "app/config/env" - -export default { - "url": env.REACT_APP_AUTH_URL ?? "https://iam.amsterdam.nl/auth/", - "realm": env.REACT_APP_KEYCLOAK_REALM ?? "", - "ssl-required": "external", - "resource": env.REACT_APP_KEYCLOAK_CLIENT_ID ?? "wonen-woon-o-azure", - "public-client": true, - "confidential-port": 0, - "clientId": env.REACT_APP_KEYCLOAK_CLIENT_ID ?? "wonen-woon-o-azure" -} diff --git a/src/app/state/auth/keycloak/useKeycloak.ts b/src/app/state/auth/keycloak/useKeycloak.ts deleted file mode 100644 index 486c41638..000000000 --- a/src/app/state/auth/keycloak/useKeycloak.ts +++ /dev/null @@ -1,8 +0,0 @@ -import { useContext } from "react" -import { KeycloakContext } from "./KeycloakProvider" - -export default () => { - const context = useContext(KeycloakContext) - if (context === undefined) throw new Error("KeycloakContext was not set") - return context.keycloak -} diff --git a/src/app/state/auth/keycloak/README.md b/src/app/state/auth/oidc/README.md similarity index 87% rename from src/app/state/auth/keycloak/README.md rename to src/app/state/auth/oidc/README.md index 757188519..62240075c 100644 --- a/src/app/state/auth/keycloak/README.md +++ b/src/app/state/auth/oidc/README.md @@ -1,9 +1,10 @@ -# Keycloak, React, TypeScript +# Entra-ID, React, TypeScript + + ## Implement - Add `` to [App.tsx](https://github.com/Amsterdam/zaken-frontend/blob/main/src/App.tsx) - Optionally add a `initializedCallback` function -- Use `useKeycloak` hook in your components ## Local development - Make sure to set `LOCAL_DEVELOPMENT_AUTHENTICATION=True` in `zaken-backend` diff --git a/src/app/state/auth/oidc/oidcConfig.ts b/src/app/state/auth/oidc/oidcConfig.ts new file mode 100644 index 000000000..50c24f85e --- /dev/null +++ b/src/app/state/auth/oidc/oidcConfig.ts @@ -0,0 +1,17 @@ +import { env } from "app/config/env" + + +export const oidcConfig = { + authority: "https://login.microsoftonline.com/72fca1b1-2c2e-4376-a445-294d80196804", + client_id: `${ env.VITE_OIDC_CLIENT_ID }`, + redirect_uri: `${ env.VITE_OIDC_REDIRECT_URL }`, + response_type: "code", + scope: "openid", + post_logout_redirect_uri: `${ env.VITE_OIDC_REDIRECT_URL }`, + metadata: { + issuer: "https://login.microsoftonline.com/72fca1b1-2c2e-4376-a445-294d80196804/v2.0", + authorization_endpoint: "https://login.microsoftonline.com/72fca1b1-2c2e-4376-a445-294d80196804/oauth2/v2.0/authorize", + token_endpoint: "https://login.microsoftonline.com/72fca1b1-2c2e-4376-a445-294d80196804/oauth2/v2.0/token", + end_session_endpoint: "https://login.microsoftonline.com/common/oauth2/v2.0/logout" + } +} diff --git a/src/app/state/auth/oidc/useDecodedToken.ts b/src/app/state/auth/oidc/useDecodedToken.ts new file mode 100644 index 000000000..3994c48c0 --- /dev/null +++ b/src/app/state/auth/oidc/useDecodedToken.ts @@ -0,0 +1,20 @@ +import { useAuth } from "react-oidc-context" +import { jwtDecode } from "jwt-decode" + +export type DecodedToken = { + given_name: string // firstname + family_name: string // lastname + name: string // lastname, firstname + unique_name: string // email + [key: string]: number | string | string[] +} + +export const useDecodedToken = (): DecodedToken | undefined => { + const auth = useAuth() + const token = auth.user?.access_token + + if (!token) return + + const decoded = jwtDecode(token) + return decoded +} diff --git a/src/app/state/rest/hooks/useApiCache.ts b/src/app/state/rest/hooks/useApiCache.ts index c2034d8c6..aff1f753c 100644 --- a/src/app/state/rest/hooks/useApiCache.ts +++ b/src/app/state/rest/hooks/useApiCache.ts @@ -52,13 +52,13 @@ const reducer = (state: State, action: Action) => { .reduce((acc, [key, val]) => ({ ...acc, [key]: { valid: false, value: val.value, errors: [] } - }), {}) + }), {} as State) } } } export const useApiCache = () => { - const [ cache, dispatch ] = useReducer(reducer, {}) + const [ cache, dispatch ] = useReducer(reducer, {} as State) const getCacheItem = useCallback((key: string) => cache[key], [ cache ]) const setCacheItem = useCallback((key: string, value: any) => dispatch({ type: "SET_ITEM", key, value }), [ dispatch ]) diff --git a/src/app/state/rest/hooks/useApiRequest.test.tsx b/src/app/state/rest/hooks/useApiRequest.test.tsx deleted file mode 100644 index ace786dd3..000000000 --- a/src/app/state/rest/hooks/useApiRequest.test.tsx +++ /dev/null @@ -1,166 +0,0 @@ - -import nock from "nock" -import { BrowserRouter } from "react-router-dom" -import { renderHook, act } from "@testing-library/react-hooks" -import useApiRequest from "./useApiRequest" -import ApiProvider from "../provider/ApiProvider" -import KeycloakProvider from "app/state/auth/keycloak/KeycloakProvider" - -type Pet = { - name: string - type: string -} - -const Wrapper: React.FC = ({ children }) => ( - - - - { children } - - - -) - -describe("useApiRequest", () => { - it("should perform a GET request on mount", async () => { - const usePet = () => useApiRequest({ url: "http://localhost/pet", groupName: "cases" }) - - // Define nock scope: - const scope = nock("http://localhost") - .get("/pet") - .reply(200, { name: "Fifi", type: "dog" }) - - const { result, waitForNextUpdate } = renderHook(usePet, { wrapper: Wrapper }) - - // Busy... no results yet. - expect(result.current[1].isBusy).toEqual(true) - expect(result.current[0]).toEqual(undefined) - - // // Make API respond: - // await act(() => waitForNextUpdate()) - // await act(() => waitForNextUpdate()) - // Make API respond: - await act(async () => { - await waitForNextUpdate() - }) - await act(async () => { - await waitForNextUpdate() - }) - - // not busy anymore... results are in! - expect(result.current[1].isBusy).toEqual(false) - expect(result.current[0]).toEqual({ name: "Fifi", type: "dog" }) - - expect(scope.isDone()).toEqual(true) // <- all scoped endpoints are called - }) - - it("should NOT perform duplicate requests", async () => { - const usePet = () => useApiRequest({ url: "http://localhost/pet", groupName: "cases" }) - - const scope = nock("http://localhost") - .get("/pet") - .once() // <- Make nock return ony once! (Will throw error when called twice) - .reply(200, { name: "Fifi", type: "dog" }) - - const useTwoHooks = () => ({ - first: usePet(), - second: usePet() - }) - - const { result, waitForNextUpdate } = renderHook(useTwoHooks, { wrapper: Wrapper }) - - // Busy... - expect(result.current.first[1].isBusy).toEqual(true) - expect(result.current.second[1].isBusy).toEqual(true) - // no results yet.... - expect(result.current.first[0]).toEqual(undefined) - expect(result.current.second[0]).toEqual(undefined) - - await act(() => waitForNextUpdate()) - await act(() => waitForNextUpdate()) - - // not busy anymore - expect(result.current.first[1].isBusy).toEqual(false) - expect(result.current.second[1].isBusy).toEqual(false) - // Results are in! - expect(result.current.first[0]).toEqual({ name: "Fifi", type: "dog" }) - expect(result.current.second[0]).toEqual({ name: "Fifi", type: "dog" }) - - expect(scope.isDone()).toEqual(true) // <- all scoped endpoints are called - }) - - test.each([ - [ "POST", - (scope: nock.Scope) => scope.post("/pet").reply(200), - (hook: any) => hook[1].execPost({ name: "popo" }) - ], - [ "PUT", - (scope: nock.Scope) => scope.put("/pet").reply(200), - (hook: any) => hook[1].execPut({ name: "popo" }) - ], - [ "PATCH", - (scope: nock.Scope) => scope.patch("/pet").reply(200), - (hook: any) => hook[1].execPatch({ name: "popo" }) - ], - [ "DELETE", - (scope: nock.Scope) => scope.delete("/pet").reply(200), - (hook: any) => hook[1].execDelete() - ] - ])("should re-execute a GET after a %s", async (method, prepareScope, exec) => { - const usePet = () => useApiRequest({ url: "http://localhost/pet", groupName: "cases" }) - - const scope = nock("http://localhost") - .get("/pet") - .reply(200, { name: "Fifi", type: "dog" }) - .get("/pet") - .reply(200, { name: "Popo", type: "dog" }) - - prepareScope(scope) - - const onSuccess = jest.fn() - const { result, waitForNextUpdate } = renderHook(usePet, { wrapper: Wrapper }) - await act(() => waitForNextUpdate()) - await act(() => waitForNextUpdate()) - - // On mount, "Fifi" should be fetched - expect(result.current[0]).toEqual({ name: "Fifi", type: "dog" }) - - await act(async () => { - await exec(result.current).then(onSuccess) - return waitForNextUpdate() - }) - - // Cache was cleared. Data should be undefined now: - expect(result.current[0]).toEqual(undefined) - - // New fetch should happen - await act(() => waitForNextUpdate()) - expect(result.current[0]).toEqual({ name: "Popo", type: "dog" }) - - expect(onSuccess).toHaveBeenCalled() - expect(scope.isDone()).toEqual(true) // <- all scoped endpoints are called - }) - - it("should call the error handler when a error occurs", async () => { - const handleError = jest.fn() - const usePet = () => useApiRequest({ url: "http://localhost/pet", groupName: "cases", handleError }) - - const scope = nock("http://localhost") - .get("/pet") - .reply(500, { detail: "S.O.S." }) - - const { waitForNextUpdate } = renderHook(usePet, { wrapper: Wrapper }) - await act(() => waitForNextUpdate()) - await act(() => waitForNextUpdate()) - - expect(handleError).toHaveBeenCalledWith(expect.objectContaining({ - message: "Request failed with status code 500", - response: expect.objectContaining({ - status: 500, - data: { detail: "S.O.S." } - }) - })) - - expect(scope.isDone()).toEqual(true) // <- all scoped endpoints are called - }) -}) diff --git a/src/app/state/rest/hooks/useProtectedRequest.ts b/src/app/state/rest/hooks/useProtectedRequest.ts index c0b2b639f..3bbcd4e83 100644 --- a/src/app/state/rest/hooks/useProtectedRequest.ts +++ b/src/app/state/rest/hooks/useProtectedRequest.ts @@ -1,13 +1,12 @@ import { useCallback } from "react" - -import useKeycloak from "app/state/auth/keycloak/useKeycloak" +import { useAuth } from "react-oidc-context" import useRequest, { Method } from "./useRequest" import useNavigation from "app/routing/useNavigation" import { RequestError } from "./useRequestWrapper" export default () => { - const keycloak = useKeycloak() + const auth = useAuth() const request = useRequest() const { navigateTo } = useNavigation() @@ -15,9 +14,9 @@ export default () => { async (method: Method, url: string, data?: unknown, additionalHeaders = {}) => { try { // Update the access token when it expires in less than 30 seconds - await keycloak.updateToken(30) + const token = auth.user?.access_token const headers = { - Authorization: `Bearer ${ keycloak.token }`, + Authorization: `Bearer ${ token }`, ...additionalHeaders } const response = await request( @@ -29,12 +28,12 @@ export default () => { return response } catch (error) { switch ((error as RequestError)?.response?.status) { - case 401: keycloak.logout(); break + // case 401: auth.signoutRedirect(); break case 403: navigateTo("/auth"); break } if (error !== undefined) throw error } }, - [keycloak, request, navigateTo] + [auth.user?.access_token, request, navigateTo] ) } diff --git a/src/index.tsx b/src/index.tsx index 66cb78881..76b167ffe 100644 --- a/src/index.tsx +++ b/src/index.tsx @@ -1,14 +1,18 @@ import { StrictMode } from "react" import ReactDOM from "react-dom" +import { AuthProvider } from "react-oidc-context" import App from "./App" import * as serviceWorker from "./serviceWorker" import packageInfo from "../package.json" import { env } from "app/config/env" +import { oidcConfig } from "app/state/auth/oidc/oidcConfig" ReactDOM.render( - + + + , document.getElementById("root") ) diff --git a/vite.config.mts b/vite.config.mts index 05941d6f9..ea9dcb365 100644 --- a/vite.config.mts +++ b/vite.config.mts @@ -28,7 +28,7 @@ export default defineConfig(({ mode }) => { function setEnv(mode: string) { Object.assign( process.env, - loadEnv(mode, ".", ["REACT_APP_", "NODE_ENV", "PUBLIC_URL"]) + loadEnv(mode, ".", ["REACT_APP_", "NODE_ENV", "PUBLIC_URL", "VITE_"]) ) process.env.NODE_ENV ||= mode const { homepage } = JSON.parse(readFileSync("package.json", "utf-8")) @@ -48,7 +48,7 @@ function envPlugin(): Plugin { return { name: "env-plugin", config(_, { mode }) { - const env = loadEnv(mode, ".", ["REACT_APP_", "NODE_ENV", "PUBLIC_URL"]) + const env = loadEnv(mode, ".", ["REACT_APP_", "NODE_ENV", "PUBLIC_URL", "VITE_"]) return { define: Object.fromEntries( Object.entries(env).map(([key, value]) => [ From f7db72e78cad1549bf9fe1984344479bf65298da Mon Sep 17 00:00:00 2001 From: Remy van der Wereld Date: Wed, 4 Dec 2024 21:26:12 +0100 Subject: [PATCH 2/4] Update authentication to use id_token instead of access_token and enhance OIDC scope --- src/app/components/auth/OidcValues/OidcValues.tsx | 1 + .../DocumentsTable/TableActions/DownloadDocument.tsx | 2 +- src/app/components/shared/navigation/DefaultNavigation.tsx | 2 +- src/app/routing/components/ProtectedPage.tsx | 2 +- src/app/state/auth/oidc/oidcConfig.ts | 2 +- src/app/state/rest/hooks/useProtectedRequest.ts | 4 ++-- 6 files changed, 7 insertions(+), 6 deletions(-) diff --git a/src/app/components/auth/OidcValues/OidcValues.tsx b/src/app/components/auth/OidcValues/OidcValues.tsx index 650e0ad7c..1f625b67e 100644 --- a/src/app/components/auth/OidcValues/OidcValues.tsx +++ b/src/app/components/auth/OidcValues/OidcValues.tsx @@ -4,6 +4,7 @@ import { useDecodedToken } from "app/state/auth/oidc/useDecodedToken" const OidcValues: React.FC = () => { const decodedToken = useDecodedToken() + const values = decodedToken ? { "Voornaam": decodedToken?.given_name, "Achternaam": decodedToken?.family_name, diff --git a/src/app/components/case/Documents/DocumentsTable/TableActions/DownloadDocument.tsx b/src/app/components/case/Documents/DocumentsTable/TableActions/DownloadDocument.tsx index ad42f71e0..2359cc476 100644 --- a/src/app/components/case/Documents/DocumentsTable/TableActions/DownloadDocument.tsx +++ b/src/app/components/case/Documents/DocumentsTable/TableActions/DownloadDocument.tsx @@ -16,7 +16,7 @@ const DownloadDocument: React.FC = ({ record, size = 20 }) => { const downloadFile = async () => { setLoading(true) - const token = auth.user?.access_token + const token = auth.user?.id_token fetch(apiUrl, { method: "GET", headers: { diff --git a/src/app/components/shared/navigation/DefaultNavigation.tsx b/src/app/components/shared/navigation/DefaultNavigation.tsx index 6d79a188a..8471762ec 100644 --- a/src/app/components/shared/navigation/DefaultNavigation.tsx +++ b/src/app/components/shared/navigation/DefaultNavigation.tsx @@ -17,7 +17,7 @@ const IconButton = styled(Button)` const DefaultNavigation: React.FC = ({ showSearchButton }) => { const auth = useAuth() - const token = auth.user?.access_token + const token = auth.user?.id_token if (!token) return null diff --git a/src/app/routing/components/ProtectedPage.tsx b/src/app/routing/components/ProtectedPage.tsx index 6d28bdf58..f185efb72 100644 --- a/src/app/routing/components/ProtectedPage.tsx +++ b/src/app/routing/components/ProtectedPage.tsx @@ -11,7 +11,7 @@ type Props = { */ const ProtectedPage: React.FC = (props) => { const auth = useAuth() - const token = auth.user?.access_token + const token = auth.user?.id_token if (token === undefined) return null diff --git a/src/app/state/auth/oidc/oidcConfig.ts b/src/app/state/auth/oidc/oidcConfig.ts index 50c24f85e..77298e248 100644 --- a/src/app/state/auth/oidc/oidcConfig.ts +++ b/src/app/state/auth/oidc/oidcConfig.ts @@ -6,7 +6,7 @@ export const oidcConfig = { client_id: `${ env.VITE_OIDC_CLIENT_ID }`, redirect_uri: `${ env.VITE_OIDC_REDIRECT_URL }`, response_type: "code", - scope: "openid", + scope: "openid email", post_logout_redirect_uri: `${ env.VITE_OIDC_REDIRECT_URL }`, metadata: { issuer: "https://login.microsoftonline.com/72fca1b1-2c2e-4376-a445-294d80196804/v2.0", diff --git a/src/app/state/rest/hooks/useProtectedRequest.ts b/src/app/state/rest/hooks/useProtectedRequest.ts index 3bbcd4e83..08d948dd8 100644 --- a/src/app/state/rest/hooks/useProtectedRequest.ts +++ b/src/app/state/rest/hooks/useProtectedRequest.ts @@ -14,7 +14,7 @@ export default () => { async (method: Method, url: string, data?: unknown, additionalHeaders = {}) => { try { // Update the access token when it expires in less than 30 seconds - const token = auth.user?.access_token + const token = auth.user?.id_token const headers = { Authorization: `Bearer ${ token }`, ...additionalHeaders @@ -34,6 +34,6 @@ export default () => { if (error !== undefined) throw error } }, - [auth.user?.access_token, request, navigateTo] + [auth.user?.id_token, request, navigateTo] ) } From d8d96611d2670d87c6cd48c5d8d289fd365cb34d Mon Sep 17 00:00:00 2001 From: Nino Date: Mon, 16 Dec 2024 12:27:47 +0100 Subject: [PATCH 3/4] change to access_token --- src/app/components/shared/navigation/DefaultNavigation.tsx | 2 +- src/app/routing/components/ProtectedPage.tsx | 2 +- src/app/state/auth/oidc/oidcConfig.ts | 2 +- src/app/state/rest/hooks/useProtectedRequest.ts | 4 ++-- 4 files changed, 5 insertions(+), 5 deletions(-) diff --git a/src/app/components/shared/navigation/DefaultNavigation.tsx b/src/app/components/shared/navigation/DefaultNavigation.tsx index 8471762ec..6d79a188a 100644 --- a/src/app/components/shared/navigation/DefaultNavigation.tsx +++ b/src/app/components/shared/navigation/DefaultNavigation.tsx @@ -17,7 +17,7 @@ const IconButton = styled(Button)` const DefaultNavigation: React.FC = ({ showSearchButton }) => { const auth = useAuth() - const token = auth.user?.id_token + const token = auth.user?.access_token if (!token) return null diff --git a/src/app/routing/components/ProtectedPage.tsx b/src/app/routing/components/ProtectedPage.tsx index f185efb72..6d28bdf58 100644 --- a/src/app/routing/components/ProtectedPage.tsx +++ b/src/app/routing/components/ProtectedPage.tsx @@ -11,7 +11,7 @@ type Props = { */ const ProtectedPage: React.FC = (props) => { const auth = useAuth() - const token = auth.user?.id_token + const token = auth.user?.access_token if (token === undefined) return null diff --git a/src/app/state/auth/oidc/oidcConfig.ts b/src/app/state/auth/oidc/oidcConfig.ts index 77298e248..8e6bfd10b 100644 --- a/src/app/state/auth/oidc/oidcConfig.ts +++ b/src/app/state/auth/oidc/oidcConfig.ts @@ -6,7 +6,7 @@ export const oidcConfig = { client_id: `${ env.VITE_OIDC_CLIENT_ID }`, redirect_uri: `${ env.VITE_OIDC_REDIRECT_URL }`, response_type: "code", - scope: "openid email", + scope: `openid email api://${ env.VITE_OIDC_CLIENT_ID }/user_impersonation`, post_logout_redirect_uri: `${ env.VITE_OIDC_REDIRECT_URL }`, metadata: { issuer: "https://login.microsoftonline.com/72fca1b1-2c2e-4376-a445-294d80196804/v2.0", diff --git a/src/app/state/rest/hooks/useProtectedRequest.ts b/src/app/state/rest/hooks/useProtectedRequest.ts index 08d948dd8..3bbcd4e83 100644 --- a/src/app/state/rest/hooks/useProtectedRequest.ts +++ b/src/app/state/rest/hooks/useProtectedRequest.ts @@ -14,7 +14,7 @@ export default () => { async (method: Method, url: string, data?: unknown, additionalHeaders = {}) => { try { // Update the access token when it expires in less than 30 seconds - const token = auth.user?.id_token + const token = auth.user?.access_token const headers = { Authorization: `Bearer ${ token }`, ...additionalHeaders @@ -34,6 +34,6 @@ export default () => { if (error !== undefined) throw error } }, - [auth.user?.id_token, request, navigateTo] + [auth.user?.access_token, request, navigateTo] ) } From c7bc5e268a2906c8cad84e273b34097ae5277d96 Mon Sep 17 00:00:00 2001 From: Remy van der Wereld Date: Tue, 17 Dec 2024 16:16:18 +0100 Subject: [PATCH 4/4] Add OIDC OBO scope and updated residents --- .env.acceptance | 2 +- .env.development | 3 +- .../ResidentsOverview/ResidentsOverview.tsx | 3 +- src/app/state/auth/oidc/oidcConfig.ts | 26 +++++++--- src/app/state/rest/addresses.ts | 11 ---- src/app/state/rest/index.ts | 34 +++++++------ src/app/state/rest/residents.ts | 50 +++++++++++++++++++ src/app/state/rest/tokenOboService.ts | 44 ++++++++++++++++ vite.config.mts | 2 +- 9 files changed, 138 insertions(+), 37 deletions(-) create mode 100644 src/app/state/rest/residents.ts create mode 100644 src/app/state/rest/tokenOboService.ts diff --git a/.env.acceptance b/.env.acceptance index 7ada9bed0..52c48d655 100644 --- a/.env.acceptance +++ b/.env.acceptance @@ -24,4 +24,4 @@ VITE_APP_ENV_SHORT=ACC # ENTRA-ID VITE_OIDC_CLIENT_ID=14c4257b-bcd1-4850-889e-7156c9efe2ec VITE_OIDC_REDIRECT_URL=http://localhost:2999 - +VITE_OIDC_OBO_SCOPE_BRP=api://c53de0e2-ae05-43aa-9852-31adb9ad7489/wonen \ No newline at end of file diff --git a/.env.development b/.env.development index c80e6d36e..1ee8f2ce9 100644 --- a/.env.development +++ b/.env.development @@ -29,4 +29,5 @@ VITE_APP_ENV_SHORT=LOCAL # ENTRA-ID VITE_OIDC_CLIENT_ID=14c4257b-bcd1-4850-889e-7156c9efe2ec -VITE_OIDC_REDIRECT_URL=http://localhost:2999 \ No newline at end of file +VITE_OIDC_REDIRECT_URL=http://localhost:2999 +VITE_OIDC_OBO_SCOPE_BRP=api://c53de0e2-ae05-43aa-9852-31adb9ad7489/wonen \ No newline at end of file diff --git a/src/app/components/addresses/ResidentsOverview/ResidentsOverview.tsx b/src/app/components/addresses/ResidentsOverview/ResidentsOverview.tsx index 4f02795bc..11d04222f 100644 --- a/src/app/components/addresses/ResidentsOverview/ResidentsOverview.tsx +++ b/src/app/components/addresses/ResidentsOverview/ResidentsOverview.tsx @@ -9,12 +9,13 @@ type Props = { const ResidentsOverview: React.FC = ({ bagId }) => { const [data, { isBusy }] = useResidents(bagId) + const dataSource = (data || []) as Components.Schemas.Residents if (isBusy) { return } return ( - + ) } diff --git a/src/app/state/auth/oidc/oidcConfig.ts b/src/app/state/auth/oidc/oidcConfig.ts index 8e6bfd10b..26d863a22 100644 --- a/src/app/state/auth/oidc/oidcConfig.ts +++ b/src/app/state/auth/oidc/oidcConfig.ts @@ -1,17 +1,31 @@ import { env } from "app/config/env" +/* + ** You must provide an implementation of onSigninCallback to oidcConfig to remove the payload from the URL upon successful login. + ** Otherwise if you refresh the page and the payload is still there, signinSilent - which handles renewing your token - won't work. + */ + +export const onSigninCallback = () => { + window.history.replaceState({}, document.title, window.location.pathname) +} export const oidcConfig = { - authority: "https://login.microsoftonline.com/72fca1b1-2c2e-4376-a445-294d80196804", + authority: + "https://login.microsoftonline.com/72fca1b1-2c2e-4376-a445-294d80196804", client_id: `${ env.VITE_OIDC_CLIENT_ID }`, redirect_uri: `${ env.VITE_OIDC_REDIRECT_URL }`, response_type: "code", scope: `openid email api://${ env.VITE_OIDC_CLIENT_ID }/user_impersonation`, post_logout_redirect_uri: `${ env.VITE_OIDC_REDIRECT_URL }`, metadata: { - issuer: "https://login.microsoftonline.com/72fca1b1-2c2e-4376-a445-294d80196804/v2.0", - authorization_endpoint: "https://login.microsoftonline.com/72fca1b1-2c2e-4376-a445-294d80196804/oauth2/v2.0/authorize", - token_endpoint: "https://login.microsoftonline.com/72fca1b1-2c2e-4376-a445-294d80196804/oauth2/v2.0/token", - end_session_endpoint: "https://login.microsoftonline.com/common/oauth2/v2.0/logout" - } + issuer: + "https://login.microsoftonline.com/72fca1b1-2c2e-4376-a445-294d80196804/v2.0", + authorization_endpoint: + "https://login.microsoftonline.com/72fca1b1-2c2e-4376-a445-294d80196804/oauth2/v2.0/authorize", + token_endpoint: + "https://login.microsoftonline.com/72fca1b1-2c2e-4376-a445-294d80196804/oauth2/v2.0/token", + end_session_endpoint: + "https://login.microsoftonline.com/common/oauth2/v2.0/logout" + }, + onSigninCallback } diff --git a/src/app/state/rest/addresses.ts b/src/app/state/rest/addresses.ts index ee7c35148..5e738d88b 100644 --- a/src/app/state/rest/addresses.ts +++ b/src/app/state/rest/addresses.ts @@ -52,17 +52,6 @@ export const useMeldingen = (bagId: string) => { }) } -export const useResidents = (bagId: Components.Schemas.Address["bag_id"], options?: Options) => { - const handleError = useErrorHandler() - return useApiRequest({ - ...options, - url: makeApiUrl("addresses", bagId, "residents"), - groupName: "addresses", - handleError, - isProtected: true - }) -} - export const useCorporations = (options?: Options) => { const handleError = useErrorHandler() return useApiRequest({ diff --git a/src/app/state/rest/index.ts b/src/app/state/rest/index.ts index c89c3e4c3..1a84905a8 100644 --- a/src/app/state/rest/index.ts +++ b/src/app/state/rest/index.ts @@ -1,19 +1,19 @@ export type ApiGroup = | "addresses" | "auth" - | "users" | "case" | "cases" | "dataPunt" | "fines" + | "housingCorporations" + | "listings" + | "permissions" | "permits" - | "themes" - | "supportContacts" | "roles" - | "permissions" - | "listings" + | "supportContacts" | "task" - | "housingCorporations" + | "themes" + | "users" export type Options = { keepUsingInvalidCache?: boolean @@ -23,19 +23,21 @@ export type Options = { export * from "./addresses" export * from "./auth" -export * from "./users" -export * from "./cases" -export * from "./tasks" +export * from "./bagPdok" +export * from "./benkAgg" export * from "./case" +export * from "./cases" export * from "./dataPunt" export * from "./fines" export * from "./help" -export * from "./schedules" -export * from "./themes" -export * from "./processes" -export * from "./roles" -export * from "./permissions" export * from "./listing" +export * from "./permissions" +export * from "./processes" export * from "./reasons" -export * from "./bagPdok" -export * from "./benkAgg" +export * from "./residents" +export * from "./roles" +export * from "./schedules" +export * from "./tasks" +export * from "./themes" +export * from "./tokenOboService" +export * from "./users" diff --git a/src/app/state/rest/residents.ts b/src/app/state/rest/residents.ts new file mode 100644 index 000000000..607220e4c --- /dev/null +++ b/src/app/state/rest/residents.ts @@ -0,0 +1,50 @@ +import { useCallback, useEffect, useState } from "react" +import axios from "axios" +import { useAuth } from "react-oidc-context" +import { makeApiUrl } from "./hooks/utils/apiUrl" +import { useOboToken } from "./tokenOboService" +import { env } from "app/config/env" + +export const useResidents = (bagId: Components.Schemas.Address["bag_id"]) => { + const auth = useAuth() + const [isBusy, setIsBusy] = useState(false) + const { fetchOboToken } = useOboToken(env.VITE_OIDC_OBO_SCOPE_BRP) + const [data, setData] = useState( + undefined + ) + + const url = makeApiUrl("addresses", bagId, "residents") + + const fetchResidents = useCallback( + (oboToken: string) => { + setIsBusy(true) + axios + .post( + url, + { + obo_access_token: oboToken + }, + { + headers: { + Authorization: `Bearer ${ auth.user?.access_token }` + } + } + ) + .then((response) => { + setData(response.data) + }) + .finally(() => { + setIsBusy(false) + }) + }, + [url, auth.user?.access_token] + ) + + useEffect(() => { + fetchOboToken().then((oboToken) => { + fetchResidents(oboToken) + }) + }, [fetchOboToken, fetchResidents]) + + return [ data, { isBusy }] as const +} diff --git a/src/app/state/rest/tokenOboService.ts b/src/app/state/rest/tokenOboService.ts new file mode 100644 index 000000000..654831aa8 --- /dev/null +++ b/src/app/state/rest/tokenOboService.ts @@ -0,0 +1,44 @@ +import { useCallback, useMemo, useState } from "react" +import axios from "axios" +import { useAuth } from "react-oidc-context" +import { oidcConfig } from "app/state/auth/oidc/oidcConfig" + +const url = oidcConfig.metadata.token_endpoint +const headers = { + "Content-Type": "application/x-www-form-urlencoded" +} + +export const useOboToken = (scope: string) => { + const auth = useAuth() + const [loading, setLoading] = useState(false) + const [accessToken, setAccessToken] = useState(null) + + const data = useMemo( + () => ({ + grant_type: "refresh_token", + client_id: oidcConfig.client_id, + refresh_token: auth.user?.refresh_token, + scope + }), + [auth.user?.refresh_token, scope] + ) + + const fetchOboToken = useCallback(() => { + setLoading(true) + return axios + .post(url, data, { headers }) + .then((response) => { + const accessToken = response.data.access_token + setAccessToken(accessToken) + return accessToken + }) + .catch((error) => { + throw error + }) + .finally(() => { + setLoading(false) + }) + }, [data]) + + return { fetchOboToken, loading, accessToken } +} diff --git a/vite.config.mts b/vite.config.mts index ea9dcb365..f4bd0cccd 100644 --- a/vite.config.mts +++ b/vite.config.mts @@ -168,7 +168,7 @@ function importPrefixPlugin(): Plugin { // Migration guide: Follow the guide below, you may need to rename your environment variable to a name that begins with VITE_ instead of REACT_APP_ // https://vitejs.dev/guide/env-and-mode.html#html-env-replacement function htmlPlugin(mode: string): Plugin { - const env = loadEnv(mode, ".", ["REACT_APP_", "NODE_ENV", "PUBLIC_URL"]) + const env = loadEnv(mode, ".", ["REACT_APP_", "NODE_ENV", "PUBLIC_URL", "VITE_"]) return { name: "html-plugin", transformIndexHtml: {