diff --git a/package-lock.json b/package-lock.json index 10ea2534..806da455 100644 --- a/package-lock.json +++ b/package-lock.json @@ -65,11 +65,12 @@ "isomorphic-dompurify": "0.24.0", "ky": "^1.7.2", "next": "^14.1.0", + "next-auth": "^4.24.7", "react": "^18.3.1", "react-dom": "^18.3.1", "react-dropzone": "^14.2.3", "react-gtm-module": "2.0.11", - "react-idle-timer": "^5.6.2", + "react-idle-timer": "^5.7.2", "react-window": "1.8.9", "uuid": "8.3.2", "validate.js": "^0.13.1" @@ -4848,6 +4849,15 @@ "node": ">=12.4.0" } }, + "node_modules/@panva/hkdf": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@panva/hkdf/-/hkdf-1.2.1.tgz", + "integrity": "sha512-6oclG6Y3PiDFcoyk8srjLfVKyMfVCKJ27JwNPViuXziFpmdz+MZnZN/aKY0JGXgYuO/VghU0jcOAZgWXZ1Dmrw==", + "peer": true, + "funding": { + "url": "https://github.com/sponsors/panva" + } + }, "node_modules/@pkgjs/parseargs": { "version": "0.11.0", "resolved": "https://registry.npmjs.org/@pkgjs/parseargs/-/parseargs-0.11.0.tgz", @@ -18724,6 +18734,15 @@ "url": "https://github.com/chalk/supports-color?sponsor=1" } }, + "node_modules/jose": { + "version": "4.15.9", + "resolved": "https://registry.npmjs.org/jose/-/jose-4.15.9.tgz", + "integrity": "sha512-1vUQX+IdDMVPj4k8kOxgUqlcK518yluMuGZwqlr44FS1ppZB/5GWh4rZG89erpOBOJjU/OBsnCVFfapsRz6nEA==", + "peer": true, + "funding": { + "url": "https://github.com/sponsors/panva" + } + }, "node_modules/js-tokens": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", @@ -20750,6 +20769,47 @@ } } }, + "node_modules/next-auth": { + "version": "4.24.8", + "resolved": "https://registry.npmjs.org/next-auth/-/next-auth-4.24.8.tgz", + "integrity": "sha512-SLt3+8UCtklsotnz2p+nB4aN3IHNmpsQFAZ24VLxGotWGzSxkBh192zxNhm/J5wgkcrDWVp0bwqvW0HksK/Lcw==", + "peer": true, + "dependencies": { + "@babel/runtime": "^7.20.13", + "@panva/hkdf": "^1.0.2", + "cookie": "^0.5.0", + "jose": "^4.15.5", + "oauth": "^0.9.15", + "openid-client": "^5.4.0", + "preact": "^10.6.3", + "preact-render-to-string": "^5.1.19", + "uuid": "^8.3.2" + }, + "peerDependencies": { + "@auth/core": "0.34.2", + "next": "^12.2.5 || ^13 || ^14", + "nodemailer": "^6.6.5", + "react": "^17.0.2 || ^18", + "react-dom": "^17.0.2 || ^18" + }, + "peerDependenciesMeta": { + "@auth/core": { + "optional": true + }, + "nodemailer": { + "optional": true + } + } + }, + "node_modules/next-auth/node_modules/cookie": { + "version": "0.5.0", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.5.0.tgz", + "integrity": "sha512-YZ3GUyn/o8gfKJlnlX7g7xq4gyO6OSuhGPKaaGssGB2qgDUS0gPgtTvoyZLTt9Ab6dC4hfc9dV5arkvc/OCmrw==", + "peer": true, + "engines": { + "node": ">= 0.6" + } + }, "node_modules/next/node_modules/postcss": { "version": "8.4.31", "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.31.tgz", @@ -21236,6 +21296,12 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/oauth": { + "version": "0.9.15", + "resolved": "https://registry.npmjs.org/oauth/-/oauth-0.9.15.tgz", + "integrity": "sha512-a5ERWK1kh38ExDEfoO6qUHJb32rd7aYmPHuyCu3Fta/cnICvYmgd2uhuKXvPD+PXB+gCEYYEaQdIRAjCOwAKNA==", + "peer": true + }, "node_modules/object-assign": { "version": "4.1.1", "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", @@ -21244,6 +21310,15 @@ "node": ">=0.10.0" } }, + "node_modules/object-hash": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/object-hash/-/object-hash-2.2.0.tgz", + "integrity": "sha512-gScRMn0bS5fH+IuwyIFgnh9zBdo4DV+6GhygmWM9HyNJSgS0hScp1f5vjtm7oIIOiT9trXrShAkLFSc2IqKNgw==", + "peer": true, + "engines": { + "node": ">= 6" + } + }, "node_modules/object-inspect": { "version": "1.13.1", "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.1.tgz", @@ -21371,6 +21446,15 @@ "integrity": "sha512-FlDryZAahJmEF3VR3w1KogSEdWX3WhA5GPakFx4J81kEAiHyLMpdLLElS8n8dfNadMgAne/MywcvmogzscVt4g==", "dev": true }, + "node_modules/oidc-token-hash": { + "version": "5.0.3", + "resolved": "https://registry.npmjs.org/oidc-token-hash/-/oidc-token-hash-5.0.3.tgz", + "integrity": "sha512-IF4PcGgzAr6XXSff26Sk/+P4KZFJVuHAJZj3wgO3vX2bMdNVp/QXTP3P7CEm9V1IdG8lDLY3HhiqpsE/nOwpPw==", + "peer": true, + "engines": { + "node": "^10.13.0 || >=12.0.0" + } + }, "node_modules/on-finished": { "version": "2.4.1", "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz", @@ -21433,6 +21517,39 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/openid-client": { + "version": "5.7.0", + "resolved": "https://registry.npmjs.org/openid-client/-/openid-client-5.7.0.tgz", + "integrity": "sha512-4GCCGZt1i2kTHpwvaC/sCpTpQqDnBzDzuJcJMbH+y1Q5qI8U8RBvoSh28svarXszZHR5BAMXbJPX1PGPRE3VOA==", + "peer": true, + "dependencies": { + "jose": "^4.15.9", + "lru-cache": "^6.0.0", + "object-hash": "^2.2.0", + "oidc-token-hash": "^5.0.3" + }, + "funding": { + "url": "https://github.com/sponsors/panva" + } + }, + "node_modules/openid-client/node_modules/lru-cache": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-6.0.0.tgz", + "integrity": "sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==", + "peer": true, + "dependencies": { + "yallist": "^4.0.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/openid-client/node_modules/yallist": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz", + "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==", + "peer": true + }, "node_modules/optionator": { "version": "0.9.3", "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.3.tgz", @@ -21980,6 +22097,34 @@ "integrity": "sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==", "dev": true }, + "node_modules/preact": { + "version": "10.24.3", + "resolved": "https://registry.npmjs.org/preact/-/preact-10.24.3.tgz", + "integrity": "sha512-Z2dPnBnMUfyQfSQ+GBdsGa16hz35YmLmtTLhM169uW944hYL6xzTYkJjC07j+Wosz733pMWx0fgON3JNw1jJQA==", + "peer": true, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/preact" + } + }, + "node_modules/preact-render-to-string": { + "version": "5.2.6", + "resolved": "https://registry.npmjs.org/preact-render-to-string/-/preact-render-to-string-5.2.6.tgz", + "integrity": "sha512-JyhErpYOvBV1hEPwIxc/fHWXPfnEGdRKxc8gFdAZ7XV4tlzyzG847XAyEZqoDnynP88akM4eaHcSOzNcLWFguw==", + "peer": true, + "dependencies": { + "pretty-format": "^3.8.0" + }, + "peerDependencies": { + "preact": ">=10" + } + }, + "node_modules/preact-render-to-string/node_modules/pretty-format": { + "version": "3.8.0", + "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-3.8.0.tgz", + "integrity": "sha512-WuxUnVtlWL1OfZFQFuqvnvs6MiAGk9UNsBostyBOB0Is9wb5uRESevA6rnl/rkksXaGX3GzZhPup5d6Vp1nFew==", + "peer": true + }, "node_modules/prebuild-install": { "version": "7.1.1", "resolved": "https://registry.npmjs.org/prebuild-install/-/prebuild-install-7.1.1.tgz", @@ -22625,9 +22770,9 @@ "peer": true }, "node_modules/react-idle-timer": { - "version": "5.6.2", - "resolved": "https://registry.npmjs.org/react-idle-timer/-/react-idle-timer-5.6.2.tgz", - "integrity": "sha512-X7zjDv7duCopQ4v3X2Gun8QunvYplPWkvW2y7suDSREu1vQRQ0mr1ESv325QoJuvSIE5QCSbLaJlrbbooNaUNg==", + "version": "5.7.2", + "resolved": "https://registry.npmjs.org/react-idle-timer/-/react-idle-timer-5.7.2.tgz", + "integrity": "sha512-+BaPfc7XEUU5JFkwZCx6fO1bLVK+RBlFH+iY4X34urvIzZiZINP6v2orePx3E6pAztJGE7t4DzvL7if2SL/0GQ==", "peer": true, "peerDependencies": { "react": ">=16", @@ -29665,6 +29810,12 @@ "integrity": "sha512-nn5ozdjYQpUCZlWGuxcJY/KpxkWQs4DcbMCmKojjyrYDEAGy4Ce19NN4v5MduafTwJlbKc99UA8YhSVqq9yPZA==", "dev": true }, + "@panva/hkdf": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@panva/hkdf/-/hkdf-1.2.1.tgz", + "integrity": "sha512-6oclG6Y3PiDFcoyk8srjLfVKyMfVCKJ27JwNPViuXziFpmdz+MZnZN/aKY0JGXgYuO/VghU0jcOAZgWXZ1Dmrw==", + "peer": true + }, "@pkgjs/parseargs": { "version": "0.11.0", "resolved": "https://registry.npmjs.org/@pkgjs/parseargs/-/parseargs-0.11.0.tgz", @@ -39830,6 +39981,12 @@ } } }, + "jose": { + "version": "4.15.9", + "resolved": "https://registry.npmjs.org/jose/-/jose-4.15.9.tgz", + "integrity": "sha512-1vUQX+IdDMVPj4k8kOxgUqlcK518yluMuGZwqlr44FS1ppZB/5GWh4rZG89erpOBOJjU/OBsnCVFfapsRz6nEA==", + "peer": true + }, "js-tokens": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", @@ -41265,6 +41422,31 @@ } } }, + "next-auth": { + "version": "4.24.8", + "resolved": "https://registry.npmjs.org/next-auth/-/next-auth-4.24.8.tgz", + "integrity": "sha512-SLt3+8UCtklsotnz2p+nB4aN3IHNmpsQFAZ24VLxGotWGzSxkBh192zxNhm/J5wgkcrDWVp0bwqvW0HksK/Lcw==", + "peer": true, + "requires": { + "@babel/runtime": "^7.20.13", + "@panva/hkdf": "^1.0.2", + "cookie": "^0.5.0", + "jose": "^4.15.5", + "oauth": "^0.9.15", + "openid-client": "^5.4.0", + "preact": "^10.6.3", + "preact-render-to-string": "^5.1.19", + "uuid": "^8.3.2" + }, + "dependencies": { + "cookie": { + "version": "0.5.0", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.5.0.tgz", + "integrity": "sha512-YZ3GUyn/o8gfKJlnlX7g7xq4gyO6OSuhGPKaaGssGB2qgDUS0gPgtTvoyZLTt9Ab6dC4hfc9dV5arkvc/OCmrw==", + "peer": true + } + } + }, "no-case": { "version": "3.0.4", "resolved": "https://registry.npmjs.org/no-case/-/no-case-3.0.4.tgz", @@ -41603,11 +41785,23 @@ } } }, + "oauth": { + "version": "0.9.15", + "resolved": "https://registry.npmjs.org/oauth/-/oauth-0.9.15.tgz", + "integrity": "sha512-a5ERWK1kh38ExDEfoO6qUHJb32rd7aYmPHuyCu3Fta/cnICvYmgd2uhuKXvPD+PXB+gCEYYEaQdIRAjCOwAKNA==", + "peer": true + }, "object-assign": { "version": "4.1.1", "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==" }, + "object-hash": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/object-hash/-/object-hash-2.2.0.tgz", + "integrity": "sha512-gScRMn0bS5fH+IuwyIFgnh9zBdo4DV+6GhygmWM9HyNJSgS0hScp1f5vjtm7oIIOiT9trXrShAkLFSc2IqKNgw==", + "peer": true + }, "object-inspect": { "version": "1.13.1", "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.1.tgz", @@ -41699,6 +41893,12 @@ "integrity": "sha512-FlDryZAahJmEF3VR3w1KogSEdWX3WhA5GPakFx4J81kEAiHyLMpdLLElS8n8dfNadMgAne/MywcvmogzscVt4g==", "dev": true }, + "oidc-token-hash": { + "version": "5.0.3", + "resolved": "https://registry.npmjs.org/oidc-token-hash/-/oidc-token-hash-5.0.3.tgz", + "integrity": "sha512-IF4PcGgzAr6XXSff26Sk/+P4KZFJVuHAJZj3wgO3vX2bMdNVp/QXTP3P7CEm9V1IdG8lDLY3HhiqpsE/nOwpPw==", + "peer": true + }, "on-finished": { "version": "2.4.1", "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz", @@ -41743,6 +41943,35 @@ "is-wsl": "^2.2.0" } }, + "openid-client": { + "version": "5.7.0", + "resolved": "https://registry.npmjs.org/openid-client/-/openid-client-5.7.0.tgz", + "integrity": "sha512-4GCCGZt1i2kTHpwvaC/sCpTpQqDnBzDzuJcJMbH+y1Q5qI8U8RBvoSh28svarXszZHR5BAMXbJPX1PGPRE3VOA==", + "peer": true, + "requires": { + "jose": "^4.15.9", + "lru-cache": "^6.0.0", + "object-hash": "^2.2.0", + "oidc-token-hash": "^5.0.3" + }, + "dependencies": { + "lru-cache": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-6.0.0.tgz", + "integrity": "sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==", + "peer": true, + "requires": { + "yallist": "^4.0.0" + } + }, + "yallist": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz", + "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==", + "peer": true + } + } + }, "optionator": { "version": "0.9.3", "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.3.tgz", @@ -42143,6 +42372,29 @@ "integrity": "sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==", "dev": true }, + "preact": { + "version": "10.24.3", + "resolved": "https://registry.npmjs.org/preact/-/preact-10.24.3.tgz", + "integrity": "sha512-Z2dPnBnMUfyQfSQ+GBdsGa16hz35YmLmtTLhM169uW944hYL6xzTYkJjC07j+Wosz733pMWx0fgON3JNw1jJQA==", + "peer": true + }, + "preact-render-to-string": { + "version": "5.2.6", + "resolved": "https://registry.npmjs.org/preact-render-to-string/-/preact-render-to-string-5.2.6.tgz", + "integrity": "sha512-JyhErpYOvBV1hEPwIxc/fHWXPfnEGdRKxc8gFdAZ7XV4tlzyzG847XAyEZqoDnynP88akM4eaHcSOzNcLWFguw==", + "peer": true, + "requires": { + "pretty-format": "^3.8.0" + }, + "dependencies": { + "pretty-format": { + "version": "3.8.0", + "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-3.8.0.tgz", + "integrity": "sha512-WuxUnVtlWL1OfZFQFuqvnvs6MiAGk9UNsBostyBOB0Is9wb5uRESevA6rnl/rkksXaGX3GzZhPup5d6Vp1nFew==", + "peer": true + } + } + }, "prebuild-install": { "version": "7.1.1", "resolved": "https://registry.npmjs.org/prebuild-install/-/prebuild-install-7.1.1.tgz", @@ -42657,9 +42909,9 @@ "peer": true }, "react-idle-timer": { - "version": "5.6.2", - "resolved": "https://registry.npmjs.org/react-idle-timer/-/react-idle-timer-5.6.2.tgz", - "integrity": "sha512-X7zjDv7duCopQ4v3X2Gun8QunvYplPWkvW2y7suDSREu1vQRQ0mr1ESv325QoJuvSIE5QCSbLaJlrbbooNaUNg==", + "version": "5.7.2", + "resolved": "https://registry.npmjs.org/react-idle-timer/-/react-idle-timer-5.7.2.tgz", + "integrity": "sha512-+BaPfc7XEUU5JFkwZCx6fO1bLVK+RBlFH+iY4X34urvIzZiZINP6v2orePx3E6pAztJGE7t4DzvL7if2SL/0GQ==", "peer": true, "requires": {} }, diff --git a/package.json b/package.json index 832341c2..ed078222 100644 --- a/package.json +++ b/package.json @@ -75,11 +75,12 @@ "isomorphic-dompurify": "0.24.0", "ky": "^1.7.2", "next": "^14.1.0", + "next-auth": "^4.24.7", "react": "^18.3.1", "react-dom": "^18.3.1", "react-dropzone": "^14.2.3", "react-gtm-module": "2.0.11", - "react-idle-timer": "^5.6.2", + "react-idle-timer": "^5.7.2", "react-window": "1.8.9", "uuid": "8.3.2", "validate.js": "^0.13.1" diff --git a/src/components/Authentication/components/SessionController/SessionController.tsx b/src/components/Authentication/components/SessionController/SessionController.tsx new file mode 100644 index 00000000..195f6818 --- /dev/null +++ b/src/components/Authentication/components/SessionController/SessionController.tsx @@ -0,0 +1,19 @@ +import { useSession } from "next-auth/react"; +import React, { Fragment, useEffect } from "react"; +import { updateAuthentication } from "../../../../providers/authentication/authentication/dispatch"; +import { useAuthentication } from "../../../../providers/authentication/authentication/hook"; +import { SessionControllerProps } from "./types"; +import { mapAuthentication } from "./utils"; + +export function SessionController({ + children, +}: SessionControllerProps): JSX.Element { + const { authenticationDispatch } = useAuthentication(); + const session = useSession(); + + useEffect(() => { + authenticationDispatch?.(updateAuthentication(mapAuthentication(session))); + }, [authenticationDispatch, session]); + + return {children}; +} diff --git a/src/components/Authentication/components/SessionController/types.ts b/src/components/Authentication/components/SessionController/types.ts new file mode 100644 index 00000000..bba62c5b --- /dev/null +++ b/src/components/Authentication/components/SessionController/types.ts @@ -0,0 +1,5 @@ +import { ReactNode } from "react"; + +export interface SessionControllerProps { + children: ReactNode | ReactNode[]; +} diff --git a/src/components/Authentication/components/SessionController/utils.ts b/src/components/Authentication/components/SessionController/utils.ts new file mode 100644 index 00000000..2743bec0 --- /dev/null +++ b/src/components/Authentication/components/SessionController/utils.ts @@ -0,0 +1,56 @@ +import { Session } from "next-auth"; +import { SessionContextValue } from "next-auth/react"; +import { + AUTHENTICATION_STATUS, + UpdateAuthenticationPayload, + UserProfile, +} from "../../../../providers/authentication/authentication/types"; + +/** + * Returns the authentication profile and status from the session context. + * @param session - Session context value. + * @returns authentication profile and status. + */ +export function mapAuthentication( + session: SessionContextValue +): UpdateAuthenticationPayload { + return { + profile: mapProfile(session.data), + status: mapAuthenticationStatus(session.status), + }; +} + +/** + * Maps the session data to a user profile. + * @param sessionData - Session data. + * @returns user profile. + */ +function mapProfile(sessionData: Session | null): UserProfile | undefined { + if (!sessionData) return; + const { user } = sessionData; + if (!user) return; + const { email, image, name } = user; + return { + email: email || "", + image: image || "", + name: name || "", + }; +} + +/** + * Returns the authentication status based on the session status. + * @param status - Session status. + * @returns authentication status. + */ +function mapAuthenticationStatus( + status: SessionContextValue["status"] +): AUTHENTICATION_STATUS { + switch (status) { + case "authenticated": + return AUTHENTICATION_STATUS.SETTLED; + case "loading": + return AUTHENTICATION_STATUS.PENDING; + default: + return AUTHENTICATION_STATUS.SETTLED; + } +} diff --git a/src/components/ComponentCreator/ComponentCreator.tsx b/src/components/ComponentCreator/ComponentCreator.tsx index 703f2325..ca8012c3 100644 --- a/src/components/ComponentCreator/ComponentCreator.tsx +++ b/src/components/ComponentCreator/ComponentCreator.tsx @@ -1,10 +1,10 @@ import React from "react"; import { ComponentsConfig, ViewContext } from "../../config/entities"; -import { useAuthentication } from "../../hooks/useAuthentication/useAuthentication"; import { useConfig } from "../../hooks/useConfig"; import { useExploreState } from "../../hooks/useExploreState"; import { useFileManifestState } from "../../hooks/useFileManifestState"; import { useSystemStatus } from "../../hooks/useSystemStatus"; +import { useAuth } from "../../providers/authentication/auth/hook"; export interface ComponentCreatorProps { components: ComponentsConfig; @@ -27,7 +27,9 @@ export const ComponentCreator = ({ response, viewContext, }: ComponentCreatorProps): JSX.Element => { - const { authenticationStatus, isAuthenticated } = useAuthentication(); + const { + authState: { isAuthenticated }, + } = useAuth(); const { config, entityConfig } = useConfig(); const { exploreState } = useExploreState(); const { fileManifestState } = useFileManifestState(); @@ -53,7 +55,6 @@ export const ComponentCreator = ({ ? c.viewBuilder(response, { ...viewContext, authState: { - authenticationStatus, isAuthenticated, }, entityConfig, diff --git a/src/components/Export/components/ExportToTerra/components/TerraSetUpForm/components/FormStep/components/AcceptTerraTOS/acceptTerraTOS.tsx b/src/components/Export/components/ExportToTerra/components/TerraSetUpForm/components/FormStep/components/AcceptTerraTOS/acceptTerraTOS.tsx index c0528a4e..2527c607 100644 --- a/src/components/Export/components/ExportToTerra/components/TerraSetUpForm/components/FormStep/components/AcceptTerraTOS/acceptTerraTOS.tsx +++ b/src/components/Export/components/ExportToTerra/components/TerraSetUpForm/components/FormStep/components/AcceptTerraTOS/acceptTerraTOS.tsx @@ -1,8 +1,8 @@ import React, { ReactNode } from "react"; -import { LoginStatus } from "../../../../../../../../../../hooks/useAuthentication/common/entities"; -import { useAuthentication } from "../../../../../../../../../../hooks/useAuthentication/useAuthentication"; -import { TerraResponse } from "../../../../../../../../../../hooks/useAuthentication/useFetchTerraProfile"; import { useConfig } from "../../../../../../../../../../hooks/useConfig"; +import { useTerraProfile } from "../../../../../../../../../../providers/authentication/terra/hook"; +import { LoginStatus } from "../../../../../../../../../../providers/authentication/terra/hooks/common/entities"; +import { TerraResponse } from "../../../../../../../../../../providers/authentication/terra/hooks/useFetchTerraProfile"; import { ButtonPrimary } from "../../../../../../../../../common/Button/components/ButtonPrimary/buttonPrimary"; import { ANCHOR_TARGET, @@ -23,7 +23,7 @@ export const AcceptTerraTOS = ({ }: AcceptTerraTOSProps): JSX.Element | null => { const { config } = useConfig(); const { exportToTerraUrl } = config; - const { terraProfileLoginStatus } = useAuthentication(); + const { terraProfileLoginStatus } = useTerraProfile(); const isTOSAccepted = isTermsOfServiceAccepted(terraProfileLoginStatus); const onOpenTerra = (): void => { diff --git a/src/components/Export/components/ExportToTerra/components/TerraSetUpForm/components/NIHAccountExpiryWarning/nihAccountExpiryWarning.tsx b/src/components/Export/components/ExportToTerra/components/TerraSetUpForm/components/NIHAccountExpiryWarning/nihAccountExpiryWarning.tsx index aa674d40..25224fee 100644 --- a/src/components/Export/components/ExportToTerra/components/TerraSetUpForm/components/NIHAccountExpiryWarning/nihAccountExpiryWarning.tsx +++ b/src/components/Export/components/ExportToTerra/components/TerraSetUpForm/components/NIHAccountExpiryWarning/nihAccountExpiryWarning.tsx @@ -2,7 +2,7 @@ import React from "react"; import { expireTimeInSeconds, useAuthenticationNIHExpiry, -} from "../../../../../../../../hooks/useAuthentication/useAuthenticationNIHExpiry"; +} from "../../../../../../../../hooks/authentication/terra/useAuthenticationNIHExpiry"; import { FluidAlert } from "../../../../../../../common/Alert/alert.styles"; import { Link } from "../../../../../../../Links/components/Link/link"; diff --git a/src/components/Export/components/ExportToTerra/components/TerraSetUpForm/terraSetUpForm.tsx b/src/components/Export/components/ExportToTerra/components/TerraSetUpForm/terraSetUpForm.tsx index 22b95cc1..9a5aa050 100644 --- a/src/components/Export/components/ExportToTerra/components/TerraSetUpForm/terraSetUpForm.tsx +++ b/src/components/Export/components/ExportToTerra/components/TerraSetUpForm/terraSetUpForm.tsx @@ -4,7 +4,9 @@ import { ONBOARDING_STEP, OnboardingStatus, useAuthenticationForm, -} from "../../../../../../hooks/useAuthentication/useAuthenticationForm"; +} from "../../../../../../hooks/authentication/terra/useAuthenticationForm"; +import { useAuth } from "../../../../../../providers/authentication/auth/hook"; +import { AUTH_STATUS } from "../../../../../../providers/authentication/auth/types"; import { TEXT_BODY_400_2_LINES } from "../../../../../../theme/common/typography"; import { FluidPaper, @@ -17,11 +19,12 @@ import { CreateTerraAccount } from "./components/FormStep/components/CreateTerra import { Section, SectionContent } from "./terraSetUpForm.styles"; export const TerraSetUpForm = (): JSX.Element | null => { - const { isComplete, isReady, onboardingStatusByStep } = - useAuthenticationForm(); - - if (!isReady) return null; - + const { + authState: { isAuthenticated, status }, + } = useAuth(); + const { isComplete, onboardingStatusByStep } = useAuthenticationForm(); + if (!isAuthenticated) return null; + if (status === AUTH_STATUS.PENDING) return null; return isComplete ? null : ( diff --git a/src/components/Layout/components/Header/components/Content/components/Actions/components/Authentication/authentication.tsx b/src/components/Layout/components/Header/components/Content/components/Actions/components/Authentication/authentication.tsx index fe31fb6e..453b595d 100644 --- a/src/components/Layout/components/Header/components/Content/components/Actions/components/Authentication/authentication.tsx +++ b/src/components/Layout/components/Header/components/Content/components/Actions/components/Authentication/authentication.tsx @@ -3,10 +3,13 @@ import { ButtonProps as MButtonProps, IconButton as MIconButton, IconButtonProps as MIconButtonProps, + Skeleton, } from "@mui/material"; -import { useRouter } from "next/router"; -import React, { ElementType, useCallback } from "react"; -import { useAuthentication } from "../../../../../../../../../../hooks/useAuthentication/useAuthentication"; +import Router from "next/router"; +import React, { ElementType } from "react"; +import { useProfile } from "../../../../../../../../../../hooks/authentication/profile/useProfile"; +import { ROUTE } from "../../../../../../../../../../routes/constants"; +import { isNavigationLinkSelected } from "../../../Navigation/common/utils"; import { AuthenticationMenu } from "./components/AuthenticationMenu/authenticationMenu"; import { StyledButton } from "./components/Button/button.styles"; @@ -21,39 +24,38 @@ export const Authentication = ({ Button, closeMenu, }: AuthenticationProps): JSX.Element | null => { - const { isAuthenticated, requestAuthentication, userProfile } = - useAuthentication(); - const router = useRouter(); - const onLogout = useCallback((): void => { - location.href = router.basePath; - }, [router]); - + const { isLoading, profile } = useProfile(); if (!authenticationEnabled) return null; - + if (isLoading) return ; + if (profile) return ; return ( - <> - {isAuthenticated && userProfile ? ( - - ) : ( - + ))} diff --git a/src/components/Login/types.ts b/src/components/Login/types.ts new file mode 100644 index 00000000..f2053034 --- /dev/null +++ b/src/components/Login/types.ts @@ -0,0 +1,11 @@ +import { ClientSafeProvider } from "next-auth/react"; +import { ReactNode } from "react"; +import { OAuthProvider } from "../../config/entities"; + +export interface Props

{ + providers?: ClientSafeProvider[] | OAuthProvider

[]; + termsOfService?: ReactNode; + text?: ReactNode; + title: string; + warning?: ReactNode; +} diff --git a/src/components/common/Banner/components/SessionTimeout/sessionTimeout.tsx b/src/components/common/Banner/components/SessionTimeout/sessionTimeout.tsx index 3b3dee65..d56ffb05 100644 --- a/src/components/common/Banner/components/SessionTimeout/sessionTimeout.tsx +++ b/src/components/common/Banner/components/SessionTimeout/sessionTimeout.tsx @@ -1,6 +1,6 @@ import { AlertProps as MAlertProps } from "@mui/material"; import React, { Fragment, ReactNode } from "react"; -import { useSessionTimeout } from "../../../../../hooks/useSessionTimeout"; +import { useSessionTimeout } from "../../../../../hooks/authentication/session/useSessionTimeout"; import { Banner } from "./sessionTimeout.styles"; export interface SessionTimeoutProps extends Omit { diff --git a/src/components/common/Button/components/LoginButton/loginButton.styles.ts b/src/components/common/Button/components/LoginButton/loginButton.styles.ts deleted file mode 100644 index c25c0c95..00000000 --- a/src/components/common/Button/components/LoginButton/loginButton.styles.ts +++ /dev/null @@ -1,13 +0,0 @@ -import styled from "@emotion/styled"; -import { ButtonSecondary } from "../ButtonSecondary/buttonSecondary"; - -export const LoginButton = styled(ButtonSecondary)` - .MuiButton-endIcon { - margin-right: -6px; - } -`; - -export const LoginButtonText = styled.span` - flex: 1; - text-align: left; -`; diff --git a/src/components/common/Button/components/LoginButton/loginButton.tsx b/src/components/common/Button/components/LoginButton/loginButton.tsx deleted file mode 100644 index 107f46c6..00000000 --- a/src/components/common/Button/components/LoginButton/loginButton.tsx +++ /dev/null @@ -1,16 +0,0 @@ -import React from "react"; -import { ButtonProps } from "../../button"; -import { LoginButton as Button, LoginButtonText } from "./loginButton.styles"; - -export type LoginButtonProps = Exclude; - -export const LoginButton = ({ - children, - ...props -}: LoginButtonProps): JSX.Element => { - return ( - - ); -}; diff --git a/src/components/common/CustomIcon/common/entities.ts b/src/components/common/CustomIcon/common/entities.ts index 6d7f66a8..9df8b75c 100644 --- a/src/components/common/CustomIcon/common/entities.ts +++ b/src/components/common/CustomIcon/common/entities.ts @@ -1,6 +1,3 @@ import { SvgIconProps } from "@mui/material"; -export interface CustomSVGIconProps extends SvgIconProps { - fontSize?: SvgIconProps["fontSize"]; - viewBox?: string; -} +export type CustomSVGIconProps = SvgIconProps; diff --git a/src/components/common/CustomIcon/components/GoogleIcon/googleIcon.tsx b/src/components/common/CustomIcon/components/GoogleIcon/googleIcon.tsx index 966515c2..c635398c 100644 --- a/src/components/common/CustomIcon/components/GoogleIcon/googleIcon.tsx +++ b/src/components/common/CustomIcon/components/GoogleIcon/googleIcon.tsx @@ -1,6 +1,5 @@ -import { SvgIcon } from "@mui/material"; +import { SvgIcon, SvgIconProps } from "@mui/material"; import React from "react"; -import { CustomSVGIconProps } from "../../common/entities"; /** * Custom Google logo icon. @@ -9,8 +8,8 @@ import { CustomSVGIconProps } from "../../common/entities"; export const GoogleIcon = ({ fontSize = "xsmall", viewBox = "0 0 20 20", - ...props /* Spread props to allow for Mui SvgIconProps specific prop overrides e.g. "htmlColor". */ -}: CustomSVGIconProps): JSX.Element => { + ...props +}: SvgIconProps): JSX.Element => { return ( { + anchorEl: E | null; onClose: () => void; onDisableScrollLock: () => void; onEnableScrollLock: () => void; - onOpen: (event: MouseEvent) => void; - onToggleOpen: (event: MouseEvent) => void; + onOpen: (event: MouseEvent) => void; + onToggleOpen: (event: MouseEvent) => void; open: boolean; } @@ -15,8 +17,10 @@ export interface UseMenu { * Menu functionality for menu dropdown, with menu position. * @returns menu functionality. */ -export const useMenu = (): UseMenu => { - const [anchorEl, setAnchorEl] = useState(null); +export const useMenu = < + E extends MPopperProps["anchorEl"] = MPopperProps["anchorEl"] +>(): UseMenu => { + const [anchorEl, setAnchorEl] = useState(null); const open = useMemo(() => Boolean(anchorEl), [anchorEl]); // Closes menu. @@ -35,13 +39,13 @@ export const useMenu = (): UseMenu => { }, []); // Opens menu. - const onOpen = useCallback((event: MouseEvent): void => { + const onOpen = useCallback((event: MouseEvent): void => { setAnchorEl(event.currentTarget); }, []); // Toggles menu open/close. const onToggleOpen = useCallback( - (event: MouseEvent): void => { + (event: MouseEvent): void => { if (open) { setAnchorEl(null); } else { diff --git a/src/config/entities.ts b/src/config/entities.ts index 0f55d952..62657144 100644 --- a/src/config/entities.ts +++ b/src/config/entities.ts @@ -6,7 +6,9 @@ import { HeroTitle } from "../components/common/Title/title"; import { FooterProps } from "../components/Layout/components/Footer/footer"; import { HeaderProps } from "../components/Layout/components/Header/header"; import { ExploreMode } from "../hooks/useExploreMode"; -import { AuthContextProps } from "../providers/authentication"; +import { AuthState } from "../providers/authentication/auth/types"; +import { UserProfile } from "../providers/authentication/authentication/types"; +import { ProviderId } from "../providers/authentication/common/types"; import { ExploreState } from "../providers/exploreState"; import { FileManifestState } from "../providers/fileManifestState"; import { SystemStatus, SystemStatusResponse } from "../providers/systemStatus"; @@ -24,14 +26,19 @@ export interface AnalyticsConfig { * Interface to define the authentication configuration for a given site. */ export interface AuthenticationConfig { - googleGISAuthConfig?: GoogleGISAuthConfig; + providers?: OAuthProvider[]; + services?: AuthService[]; termsOfService?: ReactNode; - terraAuthConfig?: TerraAuthConfig; text?: ReactNode; title: string; warning?: ReactNode; } +export interface AuthService { + endpoint: Record; + id: string; +} + /** * Interface to define the set of components that will be used for the back page. */ @@ -206,15 +213,6 @@ export type GetIdFunction = (detail: T) => string; */ export type GetTitleFunction = (detail?: T) => string | undefined; -/** - * Google GIS authentication configuration. - */ -export interface GoogleGISAuthConfig { - clientId: string; - googleProfileEndpoint: string; - scope: string; -} - /** * Grid track configuration. */ @@ -269,12 +267,15 @@ export interface ListViewConfig { subTitleHero?: ComponentsConfig; } -/** - * Interface to define the authentication login notice component. - */ -export interface LoginNotice { - conditionsUrl: string; - privacyUrl: string; +// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Use of `any` is intentional to allow for flexibility in the model. +export interface OAuthProvider

{ + authorization: { params: { scope: string } }; + clientId: string; + icon: ReactNode; + id: ProviderId; + name: string; + profile: (profile: P) => UserProfile; + userinfo: string; } /** @@ -414,12 +415,6 @@ export interface TabConfig { tabName?: string; // Used by the entity view to generate a title for the component; when label is not typed string. } -export interface TerraAuthConfig { - termsOfServiceEndpoint: string; - terraNIHProfileEndpoint?: string; - terraProfileEndpoint: string; -} - /** * Theme options function. * Defines theme options, and provides a reference to the specified theme. @@ -430,7 +425,7 @@ export type ThemeOptionsFn = (theme: Theme) => ThemeOptions; * View context. */ export interface ViewContext { - authState: Pick; + authState: Pick; cellContext?: CellContext; entityConfig: EntityConfig; exploreState: ExploreState; diff --git a/src/hooks/authentication/auth/useAuthReducer.ts b/src/hooks/authentication/auth/useAuthReducer.ts new file mode 100644 index 00000000..8212b71f --- /dev/null +++ b/src/hooks/authentication/auth/useAuthReducer.ts @@ -0,0 +1,16 @@ +import { useReducer } from "react"; +import { DEFAULT_AUTH_STATE } from "../../../providers/authentication/auth/constants"; +import { authReducer } from "../../../providers/authentication/auth/reducer"; +import { AuthContextProps } from "../../../providers/authentication/auth/types"; +import { initializer } from "../../../providers/authentication/common/utils"; + +export const useAuthReducer = ( + initialState = DEFAULT_AUTH_STATE +): Omit => { + const [authState, authDispatch] = useReducer( + authReducer, + initialState, + initializer + ); + return { authDispatch, authState }; +}; diff --git a/src/hooks/authentication/authentication/useAuthenticationReducer.ts b/src/hooks/authentication/authentication/useAuthenticationReducer.ts new file mode 100644 index 00000000..5cd770b0 --- /dev/null +++ b/src/hooks/authentication/authentication/useAuthenticationReducer.ts @@ -0,0 +1,16 @@ +import { useReducer } from "react"; +import { DEFAULT_AUTHENTICATION_STATE } from "../../../providers/authentication/authentication/constants"; +import { authenticationReducer } from "../../../providers/authentication/authentication/reducer"; +import { AuthenticationContextProps } from "../../../providers/authentication/authentication/types"; +import { initializer } from "../../../providers/authentication/common/utils"; + +export const useAuthenticationReducer = ( + initialState = DEFAULT_AUTHENTICATION_STATE +): AuthenticationContextProps => { + const [authenticationState, authenticationDispatch] = useReducer( + authenticationReducer, + initialState, + initializer + ); + return { authenticationDispatch, authenticationState }; +}; diff --git a/src/hooks/authentication/config/useAuthenticationConfig.ts b/src/hooks/authentication/config/useAuthenticationConfig.ts new file mode 100644 index 00000000..b2a16346 --- /dev/null +++ b/src/hooks/authentication/config/useAuthenticationConfig.ts @@ -0,0 +1,13 @@ +import { AuthenticationConfig } from "../../../config/entities"; +import { useConfig } from "../../useConfig"; + +/** + * Returns the authentication configuration. + * @returns authentication configuration. + */ +export const useAuthenticationConfig = (): AuthenticationConfig | undefined => { + const { + config: { authentication }, + } = useConfig(); + return authentication; +}; diff --git a/src/hooks/authentication/credentials/useCredentialsReducer.ts b/src/hooks/authentication/credentials/useCredentialsReducer.ts new file mode 100644 index 00000000..4018f243 --- /dev/null +++ b/src/hooks/authentication/credentials/useCredentialsReducer.ts @@ -0,0 +1,13 @@ +import { useReducer } from "react"; +import { DEFAULT_CREDENTIALS_STATE } from "../../../providers/authentication/credentials/constants"; +import { credentialsReducer } from "../../../providers/authentication/credentials/reducer"; +import { CredentialsContextProps } from "../../../providers/authentication/credentials/types"; + +export const useCredentialsReducer = (): CredentialsContextProps => { + const [credentialsState, credentialsDispatch] = useReducer( + credentialsReducer, + undefined, + () => DEFAULT_CREDENTIALS_STATE + ); + return { credentialsDispatch, credentialsState }; +}; diff --git a/src/hooks/authentication/profile/types.ts b/src/hooks/authentication/profile/types.ts new file mode 100644 index 00000000..5595432c --- /dev/null +++ b/src/hooks/authentication/profile/types.ts @@ -0,0 +1,9 @@ +import { + Profile, + UserProfile, +} from "../../../providers/authentication/authentication/types"; + +export interface UseProfile { + isLoading: boolean; + profile: Profile; +} diff --git a/src/hooks/authentication/profile/useProfile.ts b/src/hooks/authentication/profile/useProfile.ts new file mode 100644 index 00000000..e28f7d76 --- /dev/null +++ b/src/hooks/authentication/profile/useProfile.ts @@ -0,0 +1,21 @@ +import { useAuth } from "../../../providers/authentication/auth/hook"; +import { AUTH_STATUS } from "../../../providers/authentication/auth/types"; +import { useAuthentication } from "../../../providers/authentication/authentication/hook"; +import { UseProfile } from "./types"; + +/** + * Profile hook - returns user profile. + * @returns user profile. + */ +export const useProfile = (): UseProfile => { + const { + authState: { status }, + } = useAuth(); + const { + authenticationState: { profile }, + } = useAuthentication(); + return { + isLoading: status === AUTH_STATUS.PENDING, + profile: profile, + }; +}; diff --git a/src/hooks/authentication/providers/types.ts b/src/hooks/authentication/providers/types.ts new file mode 100644 index 00000000..e86cf2cb --- /dev/null +++ b/src/hooks/authentication/providers/types.ts @@ -0,0 +1,7 @@ +import { OAuthProvider } from "../../../config/entities"; +import { ProviderId } from "../../../providers/authentication/common/types"; + +export interface UseProviders { + findProvider: (providerId: ProviderId) => OAuthProvider | undefined; + providers: OAuthProvider[] | undefined; +} diff --git a/src/hooks/authentication/providers/useProviders.ts b/src/hooks/authentication/providers/useProviders.ts new file mode 100644 index 00000000..44e82930 --- /dev/null +++ b/src/hooks/authentication/providers/useProviders.ts @@ -0,0 +1,23 @@ +import { useCallback } from "react"; +import { ProviderId } from "../../../providers/authentication/common/types"; +import { useConfig } from "../../useConfig"; +import { UseProviders } from "./types"; + +/** + * Hook to facilitate the retrieval of authentication providers. + * @returns authentication providers and a function to find a provider by id. + */ + +export const useProviders = (): UseProviders => { + const { config } = useConfig(); + const providers = config.authentication?.providers; + + const findProvider = useCallback( + (providerId: ProviderId) => { + return providers?.find(({ id }) => id === providerId); + }, + [providers] + ); + + return { findProvider, providers }; +}; diff --git a/src/hooks/authentication/session/useSessionActive.ts b/src/hooks/authentication/session/useSessionActive.ts new file mode 100644 index 00000000..cb14c6c4 --- /dev/null +++ b/src/hooks/authentication/session/useSessionActive.ts @@ -0,0 +1,39 @@ +import Router from "next/router"; +import { useEffect } from "react"; +import { escapeRegExp } from "../../../common/utils"; +import { + AUTH_STATUS, + AuthState, +} from "../../../providers/authentication/auth/types"; +import { ROUTE } from "../../../routes/constants"; +import { useRouteHistory } from "../../useRouteHistory"; +import { INACTIVITY_PARAM } from "./useSessionTimeout"; + +export const useSessionActive = (authState: AuthState): void => { + const { status } = authState; + const { callbackUrl } = useRouteHistory(2); + useEffect(() => { + if (status !== AUTH_STATUS.SETTLED) return; + Router.push(callbackUrl(transformRoute)); + }, [callbackUrl, status]); +}; + +/** + * Finds the most recent route in the history that is not the login route and removes the inactivity timeout query parameter. + * The inactivity timeout query parameter is appended to a URL to indicate that a session has expired. This function iterates + * through the history of routes in reverse order, skipping any routes that lead to the login page, and returns the first route + * that isn't the login route with the inactivity timeout parameter removed. + * @param routes - An array of routes representing the navigation history, in order of navigation. + * @returns The most recent valid route without the inactivity timeout parameter, or `undefined` if no such route is found. + */ +export function transformRoute(routes: string[]): string | undefined { + for (const route of routes) { + if (route === ROUTE.LOGIN) { + continue; + } + return route?.replace( + new RegExp(`\\?${escapeRegExp(INACTIVITY_PARAM)}(?:$|[=&].*)`), + "" + ); + } +} diff --git a/src/hooks/authentication/session/useSessionAuth.ts b/src/hooks/authentication/session/useSessionAuth.ts new file mode 100644 index 00000000..6e16a91d --- /dev/null +++ b/src/hooks/authentication/session/useSessionAuth.ts @@ -0,0 +1,61 @@ +import { useEffect } from "react"; +import { updateAuthState } from "../../../providers/authentication/auth/dispatch"; +import { + AUTH_STATUS, + AuthContextProps, + UpdateAuthStatePayload, +} from "../../../providers/authentication/auth/types"; +import { + AUTHENTICATION_STATUS, + AuthenticationContextProps, +} from "../../../providers/authentication/authentication/types"; + +export const useSessionAuth = ({ + authenticationReducer, + authReducer, +}: { + authenticationReducer: AuthenticationContextProps; + authReducer: Omit; +}): void => { + const { authDispatch } = authReducer; + const { + authenticationState: { profile, status }, + } = authenticationReducer; + const isAuthenticated = !!profile; + + useEffect(() => { + authDispatch?.( + updateAuthState(getSession(isAuthenticated, getSessionStatus(status))) + ); + }, [authDispatch, isAuthenticated, status]); +}; + +/** + * Returns the auth session based on the authentication state. + * @param isAuthenticated - Authentication status. + * @param status - Auth status. + * @returns auth state payload. + */ +function getSession( + isAuthenticated: boolean, + status: AUTH_STATUS +): UpdateAuthStatePayload { + switch (status) { + case AUTH_STATUS.PENDING: + return { isAuthenticated: false, status }; + case AUTH_STATUS.SETTLED: + return { isAuthenticated, status }; + default: + return { isAuthenticated: false, status }; + } +} + +/** + * Returns the session status based on the authentication status. + * @param status - Authentication status. + * @returns session status. + */ +function getSessionStatus(status: AUTHENTICATION_STATUS): AUTH_STATUS { + if (status === AUTHENTICATION_STATUS.PENDING) return AUTH_STATUS.PENDING; + return AUTH_STATUS.SETTLED; +} diff --git a/src/hooks/authentication/session/useSessionCallbackUrl.ts b/src/hooks/authentication/session/useSessionCallbackUrl.ts new file mode 100644 index 00000000..91f686ec --- /dev/null +++ b/src/hooks/authentication/session/useSessionCallbackUrl.ts @@ -0,0 +1,25 @@ +import { useMemo } from "react"; +import { useRouteRoot } from "../../useRouteRoot"; +import { INACTIVITY_PARAM } from "./useSessionTimeout"; + +export interface UseSessionCallbackUrl { + callbackUrl: string | undefined; +} + +export const useSessionCallbackUrl = (): UseSessionCallbackUrl => { + const route = useRouteRoot(); + const callbackUrl = useMemo(() => getUrl(route), [route]); + return { callbackUrl }; +}; + +/** + * Returns the URL with the inactivity query parameter set to true. + * @param url - URL. + * @returns URL. + */ +function getUrl(url: string): string | undefined { + if (typeof window === "undefined") return; + const urlObj = new URL(url, window.location.origin); + urlObj.searchParams.set(INACTIVITY_PARAM, "true"); + return urlObj.href; +} diff --git a/src/hooks/authentication/session/useSessionIdleTimer.ts b/src/hooks/authentication/session/useSessionIdleTimer.ts new file mode 100644 index 00000000..6d566175 --- /dev/null +++ b/src/hooks/authentication/session/useSessionIdleTimer.ts @@ -0,0 +1,10 @@ +import { useIdleTimer } from "react-idle-timer"; +import { IIdleTimerProps } from "react-idle-timer/dist/types/IIdleTimerProps"; + +/** + * Sets a session timeout that triggers when the user has been idle for the specified duration. + * @param idleTimerProps - The parameters for the session timeout. + */ +export const useSessionIdleTimer = (idleTimerProps: IIdleTimerProps): void => { + useIdleTimer(idleTimerProps); +}; diff --git a/src/hooks/useSessionTimeout.ts b/src/hooks/authentication/session/useSessionTimeout.ts similarity index 91% rename from src/hooks/useSessionTimeout.ts rename to src/hooks/authentication/session/useSessionTimeout.ts index b87d97a3..db1f75f3 100644 --- a/src/hooks/useSessionTimeout.ts +++ b/src/hooks/authentication/session/useSessionTimeout.ts @@ -1,7 +1,7 @@ import Router from "next/router"; import { useCallback, useEffect, useState } from "react"; -import { useConfig } from "./useConfig"; -import { useLocation } from "./useLocation"; +import { useConfig } from "../../useConfig"; +import { useLocation } from "../../useLocation"; export const INACTIVITY_PARAM = "inactivityTimeout"; diff --git a/src/hooks/useAuthentication/useAuthenticationForm.ts b/src/hooks/authentication/terra/useAuthenticationForm.ts similarity index 84% rename from src/hooks/useAuthentication/useAuthenticationForm.ts rename to src/hooks/authentication/terra/useAuthenticationForm.ts index 5b267691..0f1677c0 100644 --- a/src/hooks/useAuthentication/useAuthenticationForm.ts +++ b/src/hooks/authentication/terra/useAuthenticationForm.ts @@ -1,10 +1,9 @@ -import { AuthContextProps } from "../../providers/authentication"; +import { useTerraProfile } from "../../../providers/authentication/terra/hook"; import { - AUTHENTICATION_STATUS, LoginResponse, LoginStatus, -} from "./common/entities"; -import { useAuthentication } from "./useAuthentication"; +} from "../../../providers/authentication/terra/hooks/common/entities"; +import { TerraProfileContextProps } from "../../../providers/authentication/terra/types"; export interface OnboardingStatus { active: boolean; @@ -19,7 +18,6 @@ export enum ONBOARDING_STEP { interface UseAuthenticationForm { isComplete: boolean; - isReady: boolean; onboardingStatusByStep: Map; } @@ -28,16 +26,13 @@ interface UseAuthenticationForm { * @returns onboarding steps and corresponding status. */ export const useAuthenticationForm = (): UseAuthenticationForm => { - const authentication = useAuthentication(); - const isReady = - authentication.authenticationStatus === AUTHENTICATION_STATUS.COMPLETED; + const terraProfile = useTerraProfile(); const loginStatuses = - concatLoginStatuses(authentication).filter(filterLoginStatus); + concatLoginStatuses(terraProfile).filter(filterLoginStatus); const onboardingStatusByStep = getOnboardingStatusByStep(loginStatuses); const isComplete = isAuthenticationComplete(onboardingStatusByStep); return { isComplete, - isReady, onboardingStatusByStep, }; }; @@ -60,17 +55,17 @@ function isAuthenticationComplete( /** * Returns all login statuses, ordered by onboarding step. - * @param authentication - Authentication. + * @param terraProfile - Terra profile. * @returns login statuses. */ function concatLoginStatuses( - authentication: AuthContextProps + terraProfile: TerraProfileContextProps ): LoginStatus[] { const { terraNIHProfileLoginStatus, terraProfileLoginStatus, terraTOSLoginStatus, - } = authentication; + } = terraProfile; return [ terraProfileLoginStatus, terraTOSLoginStatus, diff --git a/src/hooks/useAuthentication/useAuthenticationNIHExpiry.ts b/src/hooks/authentication/terra/useAuthenticationNIHExpiry.ts similarity index 88% rename from src/hooks/useAuthentication/useAuthenticationNIHExpiry.ts rename to src/hooks/authentication/terra/useAuthenticationNIHExpiry.ts index bc15323b..6685d902 100644 --- a/src/hooks/useAuthentication/useAuthenticationNIHExpiry.ts +++ b/src/hooks/authentication/terra/useAuthenticationNIHExpiry.ts @@ -1,5 +1,5 @@ -import { REQUEST_STATUS } from "./common/entities"; -import { useAuthentication } from "./useAuthentication"; +import { useTerraProfile } from "../../../providers/authentication/terra/hook"; +import { REQUEST_STATUS } from "../../../providers/authentication/terra/hooks/common/entities"; const WARNING_WINDOW_SECONDS = 60 * 60 * 24 * 5; // 5 days. @@ -15,8 +15,7 @@ interface UseAuthenticationNIHExpiry { * @returns NIH expiry status. */ export const useAuthenticationNIHExpiry = (): UseAuthenticationNIHExpiry => { - const authentication = useAuthentication(); - const { terraNIHProfileLoginStatus } = authentication; + const { terraNIHProfileLoginStatus } = useTerraProfile(); const { requestStatus, response } = terraNIHProfileLoginStatus; const { linkExpireTime } = response || {}; const isReady = requestStatus === REQUEST_STATUS.COMPLETED; diff --git a/src/hooks/authentication/token/types.ts b/src/hooks/authentication/token/types.ts new file mode 100644 index 00000000..f0ce5d7d --- /dev/null +++ b/src/hooks/authentication/token/types.ts @@ -0,0 +1,5 @@ +import { Credentials } from "../../../providers/authentication/credentials/types"; + +export interface UseToken { + token: Credentials; +} diff --git a/src/hooks/authentication/token/useToken.ts b/src/hooks/authentication/token/useToken.ts new file mode 100644 index 00000000..143bbbe7 --- /dev/null +++ b/src/hooks/authentication/token/useToken.ts @@ -0,0 +1,9 @@ +import { useCredentials } from "../../../providers/authentication/credentials/hook"; +import { UseToken } from "./types"; + +export const useToken = (): UseToken => { + const { + credentialsState: { credentials: token }, + } = useCredentials(); + return { token }; +}; diff --git a/src/hooks/authentication/token/useTokenReducer.ts b/src/hooks/authentication/token/useTokenReducer.ts new file mode 100644 index 00000000..3015d84f --- /dev/null +++ b/src/hooks/authentication/token/useTokenReducer.ts @@ -0,0 +1,19 @@ +import { useReducer } from "react"; +import { DEFAULT_TOKEN_STATE } from "../../../providers/authentication/token/constants"; +import { tokenReducer } from "../../../providers/authentication/token/reducer"; +import { TokenContextProps } from "../../../providers/authentication/token/types"; + +/** + * Token reducer: Manages the internal state of the token within the OAuth provider. + * This reducer handles the token locally until certain conditions are met. + * For releasing the token to the rest of the app, use the credentials reducer. + */ + +export const useTokenReducer = (): TokenContextProps => { + const [tokenState, tokenDispatch] = useReducer( + tokenReducer, + undefined, + () => DEFAULT_TOKEN_STATE + ); + return { tokenDispatch, tokenState }; +}; diff --git a/src/hooks/useAuthentication/useAuthentication.tsx b/src/hooks/useAuthentication/useAuthentication.tsx deleted file mode 100644 index bf6ce354..00000000 --- a/src/hooks/useAuthentication/useAuthentication.tsx +++ /dev/null @@ -1,10 +0,0 @@ -import { useContext } from "react"; -import { AuthContext, AuthContextProps } from "../../providers/authentication"; - -/** - * Returns authentication context. - * @returns authentication context. - */ -export const useAuthentication = (): AuthContextProps => { - return useContext(AuthContext); -}; diff --git a/src/hooks/useAuthentication/useAuthenticationComplete.ts b/src/hooks/useAuthentication/useAuthenticationComplete.ts deleted file mode 100644 index 2d5ce57a..00000000 --- a/src/hooks/useAuthentication/useAuthenticationComplete.ts +++ /dev/null @@ -1,66 +0,0 @@ -import Router, { useRouter } from "next/router"; -import { useEffect, useMemo, useRef } from "react"; -import { escapeRegExp } from "../../common/utils"; -import { ROUTE_LOGIN } from "../../providers/authentication"; -import { INACTIVITY_PARAM } from "../useSessionTimeout"; -import { AUTHENTICATION_STATUS } from "./common/entities"; - -/** - * Handles the completion of the authentication process. - * @param authenticationStatus - Authentication status. - */ -export const useAuthenticationComplete = ( - authenticationStatus: AUTHENTICATION_STATUS -): void => { - const { asPath } = useRouter(); - const routeHistoryRef = useRef(initRouteHistory(asPath)); - - // Maintain a history of routes that have been visited prior to authentication. - routeHistoryRef.current = useMemo( - () => updateRouteHistory(routeHistoryRef.current, asPath), - [asPath] - ); - - // Redirect to the previous route after authentication is completed. - useEffect(() => { - if (authenticationStatus === AUTHENTICATION_STATUS.COMPLETED) { - Router.push(routeHistoryRef.current); - } - }, [authenticationStatus]); -}; - -/** - * Initializes route history with the current path. - * Returns base path if current path is the login route. - * @param path - current browser path. - * @returns path to be used as the initial route history. - */ -function initRouteHistory(path: string): string { - return path === ROUTE_LOGIN ? "/" : removeInactivityTimeoutQueryParam(path); -} - -/** - * Removes the inactivity timeout query parameter from the path. - * the inactivity timeout parameter is used to indicate that the session has timed out; remove the parameter to - * clear the session timeout banner after the user logs in again. - * @param path - Path. - * @returns path without the inactivity timeout query parameter. - */ -function removeInactivityTimeoutQueryParam(path: string): string { - const regex = new RegExp(`\\?${escapeRegExp(INACTIVITY_PARAM)}(?:$|[=&].*)`); - return path.replace(regex, ""); -} - -/** - * Updates route history with the current path, unless the current path is the LoginView page. - * @param prevPath - route history path. - * @param path - current browser path. - * @returns updated path to be used as the route history. - */ -function updateRouteHistory(prevPath: string, path: string): string { - let currentPath = prevPath; - if (path !== ROUTE_LOGIN) { - currentPath = path; - } - return removeInactivityTimeoutQueryParam(currentPath); -} diff --git a/src/hooks/useAuthentication/useAuthenticationStatus.ts b/src/hooks/useAuthentication/useAuthenticationStatus.ts deleted file mode 100644 index cfb07ffc..00000000 --- a/src/hooks/useAuthentication/useAuthenticationStatus.ts +++ /dev/null @@ -1,49 +0,0 @@ -import { - AUTHENTICATION_STATUS, - LoginResponse, - LoginStatus, - REQUEST_STATUS, -} from "./common/entities"; -import { GoogleResponse } from "./useFetchGoogleProfile"; -import { TerraNIHResponse } from "./useFetchTerraNIHProfile"; -import { TerraResponse } from "./useFetchTerraProfile"; -import { TerraTermsOfServiceResponse } from "./useFetchTerraTermsOfService"; - -/** - * Handles the status of the authentication process. - * @param userProfileLoginStatus - User profile login status. - * @param terraProfileLoginStatus - Terra profile login status. - * @param terraTOSLoginStatus - Terra terms of service login status. - * @param terraNIHProfileLoginStatus - Terra NIH profile login status. - * @returns authentication status. - */ -export const useAuthenticationStatus = ( - userProfileLoginStatus: LoginStatus, - terraProfileLoginStatus: LoginStatus, - terraTOSLoginStatus: LoginStatus, - terraNIHProfileLoginStatus: LoginStatus -): AUTHENTICATION_STATUS => { - return getAuthenticationStatus([ - terraNIHProfileLoginStatus, - terraProfileLoginStatus, - terraTOSLoginStatus, - userProfileLoginStatus, - ]); -}; - -/** - * Returns the authentication status ("INCOMPLETE" or "COMPLETE"). - * @param loginStatuses - Login statuses. - * @returns authentication status. - */ -export function getAuthenticationStatus( - loginStatuses: LoginStatus[] -): AUTHENTICATION_STATUS { - for (const loginStatus of loginStatuses) { - if (!loginStatus.isSupported) continue; - if (loginStatus.requestStatus === REQUEST_STATUS.NOT_STARTED) { - return AUTHENTICATION_STATUS.INCOMPLETE; - } - } - return AUTHENTICATION_STATUS.COMPLETED; -} diff --git a/src/hooks/useAuthentication/useFetchGoogleProfile.ts b/src/hooks/useAuthentication/useFetchGoogleProfile.ts deleted file mode 100644 index bda5beec..00000000 --- a/src/hooks/useAuthentication/useFetchGoogleProfile.ts +++ /dev/null @@ -1,68 +0,0 @@ -import { useCallback, useEffect, useState } from "react"; -import { useAuthenticationConfig } from "../useAuthenticationConfig"; -import { - LOGIN_STATUS_FAILED, - LOGIN_STATUS_NOT_STARTED, -} from "./common/constants"; -import { LoginStatus, REQUEST_STATUS } from "./common/entities"; -import { getAuthenticationRequestOptions } from "./common/utils"; - -export type UserProfile = GoogleResponse; - -export interface GoogleResponse { - email: string; - email_verified: boolean; - family_name: string; - given_name: string; - hd: string; - locale: string; - name: string; - picture: string; - sub: string; -} - -type Status = LoginStatus; - -/** - * Returns user profile login status from configured endpoint. - * @param token - Token. - * @returns google profile login status. - */ -export const useFetchGoogleProfile = (token?: string): Status => { - const authenticationConfig = useAuthenticationConfig(); - const { googleGISAuthConfig: { googleProfileEndpoint: endpoint } = {} } = - authenticationConfig; - const [loginStatus, setLoginStatus] = useState( - LOGIN_STATUS_NOT_STARTED as Status - ); - - // Fetch google user profile. - const fetchEndpointData = useCallback( - (endpoint: string, accessToken: string): void => { - fetch(endpoint, getAuthenticationRequestOptions(accessToken)) - .then((response) => response.json()) - .then((profile: GoogleResponse) => { - setLoginStatus((prevStatus) => ({ - ...prevStatus, - isSuccess: true, - requestStatus: REQUEST_STATUS.COMPLETED, - response: profile, - })); - }) - .catch((err) => { - console.log(err); // TODO handle error. - setLoginStatus(LOGIN_STATUS_FAILED as Status); - }); - }, - [] - ); - - // Fetches user profile. - useEffect(() => { - if (!token) return; - if (!endpoint) return; - fetchEndpointData(endpoint, token); - }, [endpoint, fetchEndpointData, token]); - - return loginStatus; -}; diff --git a/src/hooks/useAuthentication/useTokenClient.ts b/src/hooks/useAuthentication/useTokenClient.ts deleted file mode 100644 index 304d37dd..00000000 --- a/src/hooks/useAuthentication/useTokenClient.ts +++ /dev/null @@ -1,54 +0,0 @@ -import { useEffect, useState } from "react"; -import { useAuthenticationConfig } from "../useAuthenticationConfig"; - -// eslint-disable-next-line @typescript-eslint/no-explicit-any -- TODO see https://github.com/clevercanary/data-browser/issues/544. -declare const google: any; - -// eslint-disable-next-line @typescript-eslint/no-explicit-any -- TODO see https://github.com/clevercanary/data-browser/issues/544. -type TokenClient = any; - -interface TokenResponse { - access_token: string; - expires_in: number; - refresh_token?: string; - scope?: string; - token_type: string; -} - -export interface UseTokenClient { - token: string | undefined; - tokenClient: TokenClient | undefined; -} - -/** - * Initializes the token client, sets token from token client callback. - * @returns initialized token client. - */ -export const useTokenClient = (): UseTokenClient => { - const [token, setToken] = useState(); - const [tokenClient, setTokenClient] = useState(); - const authenticationConfig = useAuthenticationConfig(); - const { googleGISAuthConfig: { clientId, scope } = {} } = - authenticationConfig; - - // Initializes token client - (authorization client id must be configured). - useEffect(() => { - if (clientId) { - setTokenClient( - google.accounts.oauth2.initTokenClient({ - callback: (tokenResponse: TokenResponse) => { - const access_token = tokenResponse.access_token; - setToken(access_token); - }, - client_id: clientId, - scope, - }) - ); - } - }, [clientId, scope]); - - return { - token, - tokenClient, - }; -}; diff --git a/src/hooks/useAuthenticationConfig.ts b/src/hooks/useAuthenticationConfig.ts deleted file mode 100644 index c80573e8..00000000 --- a/src/hooks/useAuthenticationConfig.ts +++ /dev/null @@ -1,18 +0,0 @@ -import { AuthenticationConfig } from "../config/entities"; -import { useConfig } from "./useConfig"; - -/** - * Hook to get the authentication config - * @returns @see AuthenticationConfig used in the current config. - */ -export const useAuthenticationConfig = (): AuthenticationConfig => { - const { config } = useConfig(); - - if (!config.authentication) { - return { - title: "", - }; - } - - return config.authentication; -}; diff --git a/src/hooks/useEntityList.ts b/src/hooks/useEntityList.ts index eb7c955a..0516293b 100644 --- a/src/hooks/useEntityList.ts +++ b/src/hooks/useEntityList.ts @@ -13,8 +13,8 @@ import { EntityMapper } from "../config/entities"; import { getEntityConfig } from "../config/utils"; import { ExploreActionKind } from "../providers/exploreState"; import { DEFAULT_PAGINATION_STATE } from "../providers/exploreState/initializer/constants"; +import { useToken } from "./authentication/token/useToken"; import { useAsync } from "./useAsync"; -import { useAuthentication } from "./useAuthentication/useAuthentication"; import { useConfig } from "./useConfig"; import { useEntityService } from "./useEntityService"; import { EXPLORE_MODE, ExploreMode, useExploreMode } from "./useExploreMode"; @@ -31,7 +31,7 @@ export const useEntityList = ( staticResponse: AzulEntitiesStaticResponse ): void => { const { data: staticData, entityListType } = staticResponse; - const { token } = useAuthentication(); + const { token } = useToken(); const { config } = useConfig(); const { apiPath } = getEntityConfig(config.entities, entityListType); const exploreMode = useExploreMode(); diff --git a/src/hooks/useFetchEntity.tsx b/src/hooks/useFetchEntity.tsx index 3b93d552..74311778 100644 --- a/src/hooks/useFetchEntity.tsx +++ b/src/hooks/useFetchEntity.tsx @@ -2,8 +2,8 @@ import { useRouter } from "next/router"; import { useEffect, useMemo } from "react"; import { PARAMS_INDEX_UUID } from "../common/constants"; import { EntityDetailViewProps } from "../views/EntityDetailView/entityDetailView"; +import { useToken } from "./authentication/token/useToken"; import { useAsync } from "./useAsync"; -import { useAuthentication } from "./useAuthentication/useAuthentication"; import { useEntityService } from "./useEntityService"; import { EXPLORE_MODE, useExploreMode } from "./useExploreMode"; import { useExploreState } from "./useExploreState"; @@ -23,7 +23,7 @@ export const useFetchEntity = ( detailViewProps?: EntityDetailViewProps ): UseEntityDetailResponse => { const { data: staticData, entityListType } = detailViewProps || {}; - const { token } = useAuthentication(); + const { token } = useToken(); const exploreMode = useExploreMode(); const { exploreState } = useExploreState(); const { catalogState } = exploreState; diff --git a/src/hooks/useFileManifest/useFetchFilesFacets.ts b/src/hooks/useFileManifest/useFetchFilesFacets.ts index ba9dbcae..7d91c00a 100644 --- a/src/hooks/useFileManifest/useFetchFilesFacets.ts +++ b/src/hooks/useFileManifest/useFetchFilesFacets.ts @@ -6,8 +6,8 @@ import { import { Filters } from "../../common/entities"; import { fetchEntitiesFromURL } from "../../entity/common/service"; import { fetchQueryParams, SearchParams } from "../../utils/fetchQueryParams"; +import { useToken } from "../authentication/token/useToken"; import { useAsync } from "../useAsync"; -import { useAuthentication } from "../useAuthentication/useAuthentication"; import { useFetchRequestURL } from "../useFetchRequestURL"; import { FetchFilesFacets } from "./common/entities"; import { bindEntitySearchResultsResponse } from "./common/utils"; @@ -26,8 +26,7 @@ export const useFetchFilesFacets = ( searchParams: SearchParams | undefined, isEnabled: boolean ): FetchFilesFacets => { - // Grab token from authentication. - const { token } = useAuthentication(); + const { token } = useToken(); // Build request params. const requestParams = fetchQueryParams(filters, catalog, searchParams); // Build request URL. diff --git a/src/hooks/useFileManifest/useFetchSummary.ts b/src/hooks/useFileManifest/useFetchSummary.ts index 14c388d2..7b5e850a 100644 --- a/src/hooks/useFileManifest/useFetchSummary.ts +++ b/src/hooks/useFileManifest/useFetchSummary.ts @@ -6,8 +6,8 @@ import { import { Filters } from "../../common/entities"; import { fetchSummaryFromURL } from "../../entity/api/service"; import { fetchQueryParams } from "../../utils/fetchQueryParams"; +import { useToken } from "../authentication/token/useToken"; import { useAsync } from "../useAsync"; -import { useAuthentication } from "../useAuthentication/useAuthentication"; import { useFetchRequestURL } from "../useFetchRequestURL"; import { FetchFileSummary } from "./common/entities"; @@ -23,8 +23,7 @@ export const useFetchSummary = ( catalog: string, isEnabled: boolean ): FetchFileSummary => { - // Grab token from authentication. - const { token } = useAuthentication(); + const { token } = useToken(); // Build request params. const requestParams = fetchQueryParams(filters, catalog, undefined); // Build request URL. diff --git a/src/hooks/useRequestFileLocation.ts b/src/hooks/useRequestFileLocation.ts index cd8424cd..cbf0928b 100644 --- a/src/hooks/useRequestFileLocation.ts +++ b/src/hooks/useRequestFileLocation.ts @@ -4,8 +4,8 @@ import { FILE_LOCATION_SUCCESSFULLY, } from "../apis/azul/common/constants"; import { FileLocationResponse } from "../apis/azul/common/entities"; +import { useToken } from "./authentication/token/useToken"; import { useAsync } from "./useAsync"; -import { useAuthentication } from "./useAuthentication/useAuthentication"; export interface FileLocation { commandLine?: { [key: string]: string }; @@ -127,8 +127,7 @@ export const useRequestFileLocation = ( url?: string, method?: Method ): UseRequestFileLocationResult => { - // Grab token from authentication. - const { token } = useAuthentication(); + const { token } = useToken(); const { data, isIdle, diff --git a/src/hooks/useRouteHistory.ts b/src/hooks/useRouteHistory.ts new file mode 100644 index 00000000..9500ac90 --- /dev/null +++ b/src/hooks/useRouteHistory.ts @@ -0,0 +1,65 @@ +import Router, { useRouter } from "next/router"; +import { useCallback, useEffect, useRef } from "react"; +import { useRouteRoot } from "./useRouteRoot"; + +const ROUTE_CHANGE_EVENT = "routeChangeComplete"; +const MAX_HISTORY_LENGTH = 4; + +export type TransformRouteFn = (routes: string[]) => string | undefined; + +export interface UseRouteHistory { + callbackUrl: (transformFn?: TransformRouteFn) => string; +} + +export function useRouteHistory( + maxHistory = MAX_HISTORY_LENGTH +): UseRouteHistory { + const { asPath } = useRouter(); + const rootPath = useRouteRoot(); + const historyRef = useRef([asPath]); + + const onRouteChange = useCallback( + (route: string): void => { + if (route === historyRef.current[0]) return; + historyRef.current.unshift(route); + if (historyRef.current.length > maxHistory) { + historyRef.current.pop(); + } + }, + [maxHistory] + ); + + useEffect(() => { + Router.events.on(ROUTE_CHANGE_EVENT, onRouteChange); + return (): void => { + Router.events.off(ROUTE_CHANGE_EVENT, onRouteChange); + }; + }, [onRouteChange]); + + const callbackUrl = useCallback( + (transformFn?: TransformRouteFn): string => + getCallbackUrl(historyRef.current, rootPath, transformFn), + [rootPath] + ); + + return { callbackUrl }; +} + +/** + * Generates a callback URL based on the provided history and root path. + * Returns the callback URL determined by the transform function or the second item in history and if neither condition is met, returns the root path. + * @param history - Navigation history. + * @param rootPath - The default root path to return if no other conditions are met. + * @param [transformFn] - An optional function that transforms the history array to a specific route. + * @returns {string} - The callback UR. + */ +export function getCallbackUrl( + history: string[], + rootPath: string, + transformFn?: TransformRouteFn +): string { + if (transformFn) { + return transformFn(history) || rootPath; + } + return history[1] || rootPath; +} diff --git a/src/hooks/useRouteRoot.ts b/src/hooks/useRouteRoot.ts new file mode 100644 index 00000000..5cae4a66 --- /dev/null +++ b/src/hooks/useRouteRoot.ts @@ -0,0 +1,11 @@ +import { useRouter } from "next/router"; +import { useMemo } from "react"; +import { useConfig } from "./useConfig"; + +export function useRouteRoot(): string { + const { + config: { redirectRootToPath: path }, + } = useConfig(); + const { basePath } = useRouter(); + return useMemo(() => `${basePath}${path}`, [basePath, path]); +} diff --git a/src/hooks/useSummary.ts b/src/hooks/useSummary.ts index 5c83ae32..a3f25c63 100644 --- a/src/hooks/useSummary.ts +++ b/src/hooks/useSummary.ts @@ -1,7 +1,7 @@ import { useEffect } from "react"; import { AzulSummaryResponse } from "../apis/azul/common/entities"; +import { useToken } from "./authentication/token/useToken"; import { useAsync } from "./useAsync"; -import { useAuthentication } from "./useAuthentication/useAuthentication"; import { useConfig } from "./useConfig"; import { useEntityService } from "./useEntityService"; import { useExploreState } from "./useExploreState"; @@ -16,7 +16,7 @@ interface UseSummaryResponse { * @returns an object with the loaded data and a flag indicating is the data is loading */ export const useSummary = (): UseSummaryResponse => { - const { token } = useAuthentication(); + const { token } = useToken(); const { config } = useConfig(); const { exploreState } = useExploreState(); const { filterState } = exploreState; @@ -39,7 +39,7 @@ export const useSummary = (): UseSummaryResponse => { return { isLoading: false }; //TODO: return a summary placeholder } - // Return the fetch status and summary data once fetch is complete.. + // Return the fetch status and summary data once fetch is complete. return { isLoading: apiIsLoading, response, diff --git a/src/providers/authentication.tsx b/src/providers/authentication.tsx deleted file mode 100644 index 36680c04..00000000 --- a/src/providers/authentication.tsx +++ /dev/null @@ -1,181 +0,0 @@ -import Router, { useRouter } from "next/router"; -import React, { createContext, ReactNode, useCallback } from "react"; -import { useIdleTimer } from "react-idle-timer"; -import { LOGIN_STATUS_NOT_STARTED } from "../hooks/useAuthentication/common/constants"; -import { - AUTHENTICATION_STATUS, - LoginStatus, -} from "../hooks/useAuthentication/common/entities"; -import { useAuthenticationComplete } from "../hooks/useAuthentication/useAuthenticationComplete"; -import { useAuthenticationStatus } from "../hooks/useAuthentication/useAuthenticationStatus"; -import { - useFetchGoogleProfile, - UserProfile, -} from "../hooks/useAuthentication/useFetchGoogleProfile"; -import { - TerraNIHResponse, - useFetchTerraNIHProfile, -} from "../hooks/useAuthentication/useFetchTerraNIHProfile"; -import { - TerraResponse, - useFetchTerraProfile, -} from "../hooks/useAuthentication/useFetchTerraProfile"; -import { - TerraTermsOfServiceResponse, - useFetchTerraTermsOfService, -} from "../hooks/useAuthentication/useFetchTerraTermsOfService"; -import { useTokenClient } from "../hooks/useAuthentication/useTokenClient"; -import { useConfig } from "../hooks/useConfig"; -import { INACTIVITY_PARAM } from "../hooks/useSessionTimeout"; - -// Template constants -export const ROUTE_LOGIN = "/login"; - -type AuthenticateUserFn = () => void; -type RequestAuthenticationFn = () => void; - -/** - * Model of authentication context. - */ -export interface AuthContextProps { - authenticateUser: AuthenticateUserFn; - authenticationStatus: AUTHENTICATION_STATUS; - isAuthenticated: boolean; - isEnabled: boolean; - requestAuthentication: RequestAuthenticationFn; - terraNIHProfileLoginStatus: LoginStatus; - terraProfileLoginStatus: LoginStatus; - terraTOSLoginStatus: LoginStatus; - token?: string; - userProfile?: UserProfile; -} - -/** - * Auth context for storing and using auth-related state. - */ -export const AuthContext = createContext({ - // eslint-disable-next-line @typescript-eslint/no-empty-function -- allow dummy function for default state. - authenticateUser: () => {}, - authenticationStatus: AUTHENTICATION_STATUS.INCOMPLETE, - isAuthenticated: false, - isEnabled: false, - // eslint-disable-next-line @typescript-eslint/no-empty-function -- allow dummy function for default state. - requestAuthentication: () => {}, - terraNIHProfileLoginStatus: - LOGIN_STATUS_NOT_STARTED as LoginStatus, - terraProfileLoginStatus: - LOGIN_STATUS_NOT_STARTED as LoginStatus, - terraTOSLoginStatus: - LOGIN_STATUS_NOT_STARTED as LoginStatus, - token: undefined, - userProfile: undefined, -}); - -interface Props { - children: ReactNode | ReactNode[]; - sessionTimeout?: number; -} - -/** - * Auth provider for consuming components to subscribe to changes in auth-related state. - * @param props - Component inputs. - * @param props.children - Set of children components that can possibly consume the query provider. - * @param props.sessionTimeout - If provided, will set the value for a session timeout (in milliseconds). - * @returns Provider element to be used by consumers to both update authentication state and subscribe to changes in authentication state. - */ -export function AuthProvider({ children, sessionTimeout }: Props): JSX.Element { - const { config } = useConfig(); - const { authentication, redirectRootToPath } = config; - const { basePath } = useRouter(); - const { token, tokenClient } = useTokenClient(); - const terraNIHProfileLoginStatus = useFetchTerraNIHProfile(token); - const terraProfileLoginStatus = useFetchTerraProfile(token); - const terraTOSLoginStatus = useFetchTerraTermsOfService(token); - const userProfileLoginStatus = useFetchGoogleProfile(token); - const isEnabled = Boolean(authentication); - const isAuthenticated = userProfileLoginStatus.isSuccess; - const releaseToken = shouldReleaseToken( - userProfileLoginStatus, - terraProfileLoginStatus, - terraTOSLoginStatus - ); - const authenticationStatus = useAuthenticationStatus( - userProfileLoginStatus, - terraProfileLoginStatus, - terraTOSLoginStatus, - terraNIHProfileLoginStatus - ); - - // Handle completion of authentication process. - useAuthenticationComplete(authenticationStatus); - - /** - * If sessionTimeout is set and user is authenticated, the app will reload and redirect to - * origin, including base path, root path, and query param. - */ - useIdleTimer({ - onIdle: () => - isAuthenticated && - sessionTimeout && - (window.location.href = - window.location.origin + - basePath + - redirectRootToPath + - "?" + - `${INACTIVITY_PARAM}=true`), - timeout: sessionTimeout, - }); - - /** - * Requests access token and authenticates user. - */ - const authenticateUser = useCallback((): void => { - tokenClient.requestAccessToken(); - }, [tokenClient]); - - /** - * Navigates to login page. - */ - const requestAuthentication = useCallback((): void => { - Router.push(ROUTE_LOGIN); - }, []); - - return ( - - {children} - - ); -} - -/** - * Token is released for the following conditions: - * - Terra endpoint is configured and the terms of service response is successful, or - * - Terra endpoint is not configured and the user profile response is successful. - * @param userProfileLoginStatus - User profile login status. - * @param terraProfileLoginStatus - Terra profile login status. - * @param terraTOSLoginStatus - Terra terms of service login status. - * @returns true if the token should be released. - */ -export function shouldReleaseToken( - userProfileLoginStatus: LoginStatus, - terraProfileLoginStatus: LoginStatus, - terraTOSLoginStatus: LoginStatus -): boolean { - if (terraProfileLoginStatus.isSupported) { - return terraTOSLoginStatus.isSuccess; - } - return userProfileLoginStatus.isSuccess; -} diff --git a/src/providers/authentication/auth/actions.ts b/src/providers/authentication/auth/actions.ts new file mode 100644 index 00000000..410f1b81 --- /dev/null +++ b/src/providers/authentication/auth/actions.ts @@ -0,0 +1,17 @@ +import { AuthState, UpdateAuthStatePayload } from "./types"; + +/** + * Update auth state. + * @param state - State. + * @param payload - Payload. + * @returns state. + */ +export function updateAuthState( + state: AuthState, + payload: UpdateAuthStatePayload +): AuthState { + return { + ...state, + ...payload, + }; +} diff --git a/src/providers/authentication/auth/constants.ts b/src/providers/authentication/auth/constants.ts new file mode 100644 index 00000000..99f4d156 --- /dev/null +++ b/src/providers/authentication/auth/constants.ts @@ -0,0 +1,6 @@ +import { AUTH_STATUS, AuthState } from "./types"; + +export const DEFAULT_AUTH_STATE: AuthState = { + isAuthenticated: false, + status: AUTH_STATUS.PENDING, +}; diff --git a/src/providers/authentication/auth/context.ts b/src/providers/authentication/auth/context.ts new file mode 100644 index 00000000..cb71ec08 --- /dev/null +++ b/src/providers/authentication/auth/context.ts @@ -0,0 +1,9 @@ +import { createContext } from "react"; +import { DEFAULT_AUTH_STATE } from "./constants"; +import { AuthContextProps } from "./types"; + +export const AuthContext = createContext({ + authDispatch: null, + authState: DEFAULT_AUTH_STATE, + service: undefined, +}); diff --git a/src/providers/authentication/auth/dispatch.ts b/src/providers/authentication/auth/dispatch.ts new file mode 100644 index 00000000..7250b197 --- /dev/null +++ b/src/providers/authentication/auth/dispatch.ts @@ -0,0 +1,31 @@ +import { + AuthActionKind, + ResetStateAction, + UpdateAuthStateAction, + UpdateAuthStatePayload, +} from "./types"; + +/** + * Reset state action. + * @returns state. + */ +export function resetState(): ResetStateAction { + return { + payload: undefined, + type: AuthActionKind.ResetState, + }; +} + +/** + * Update auth state action. + * @param payload - Payload. + * @returns Action. + */ +export function updateAuthState( + payload: UpdateAuthStatePayload +): UpdateAuthStateAction { + return { + payload, + type: AuthActionKind.UpdateAuthState, + }; +} diff --git a/src/providers/authentication/auth/hook.ts b/src/providers/authentication/auth/hook.ts new file mode 100644 index 00000000..59c574ed --- /dev/null +++ b/src/providers/authentication/auth/hook.ts @@ -0,0 +1,11 @@ +import { useContext } from "react"; +import { AuthContext } from "./context"; +import { AuthContextProps } from "./types"; + +/** + * Auth hook. + * @returns auth context. + */ +export const useAuth = (): AuthContextProps => { + return useContext(AuthContext); +}; diff --git a/src/providers/authentication/auth/reducer.ts b/src/providers/authentication/auth/reducer.ts new file mode 100644 index 00000000..db9512d2 --- /dev/null +++ b/src/providers/authentication/auth/reducer.ts @@ -0,0 +1,22 @@ +import { updateAuthState } from "./actions"; +import { AuthAction, AuthActionKind, AuthState } from "./types"; + +/** + * Auth reducer. + * @param state - State. + * @param action - Action. + * @returns state. + */ +export function authReducer(state: AuthState, action: AuthAction): AuthState { + const { payload, type } = action; + switch (type) { + case AuthActionKind.ResetState: { + return { ...state, ...state.initialState }; + } + case AuthActionKind.UpdateAuthState: { + return updateAuthState(state, payload); + } + default: + return state; + } +} diff --git a/src/providers/authentication/auth/types.ts b/src/providers/authentication/auth/types.ts new file mode 100644 index 00000000..44b009bc --- /dev/null +++ b/src/providers/authentication/auth/types.ts @@ -0,0 +1,52 @@ +import { Dispatch } from "react"; +import { ProviderId } from "../common/types"; + +export type AuthAction = ResetStateAction | UpdateAuthStateAction; + +export enum AuthActionKind { + ResetState = "RESET_STATE", + UpdateAuthState = "UPDATE_AUTH_STATE", +} + +export interface AuthContextProps { + authDispatch: Dispatch | null; + authState: AuthState; + service: Service | undefined; +} + +export interface AuthState { + initialState?: AuthState; + isAuthenticated: boolean; + status: AUTH_STATUS; +} + +export type ResetStateAction = { + payload: ResetStatePayload; + type: AuthActionKind.ResetState; +}; + +export type ResetStatePayload = undefined; + +export interface Service { + [key: string]: unknown; + requestLogin: (providerId: ProviderId) => void; + requestLogout: (options?: { + callbackUrl?: string; + redirect?: boolean; + }) => void; +} + +export enum AUTH_STATUS { + PENDING = "PENDING", + SETTLED = "SETTLED", +} + +export interface UpdateAuthStateAction { + payload: UpdateAuthStatePayload; + type: AuthActionKind.UpdateAuthState; +} + +export interface UpdateAuthStatePayload { + isAuthenticated?: boolean; + status?: AUTH_STATUS; +} diff --git a/src/providers/authentication/authentication/actions.ts b/src/providers/authentication/authentication/actions.ts new file mode 100644 index 00000000..7d82b41a --- /dev/null +++ b/src/providers/authentication/authentication/actions.ts @@ -0,0 +1,17 @@ +import { AuthenticationState, UpdateAuthenticationPayload } from "./types"; + +/** + * Update authentication action. + * @param state - State. + * @param payload - Payload. + * @returns state. + */ +export function updateAuthenticationAction( + state: AuthenticationState, + payload: UpdateAuthenticationPayload +): AuthenticationState { + return { + ...state, + ...payload, + }; +} diff --git a/src/providers/authentication/authentication/constants.ts b/src/providers/authentication/authentication/constants.ts new file mode 100644 index 00000000..d09cc9b2 --- /dev/null +++ b/src/providers/authentication/authentication/constants.ts @@ -0,0 +1,6 @@ +import { AUTHENTICATION_STATUS, AuthenticationState } from "./types"; + +export const DEFAULT_AUTHENTICATION_STATE: AuthenticationState = { + profile: undefined, + status: AUTHENTICATION_STATUS.PENDING, +}; diff --git a/src/providers/authentication/authentication/context.ts b/src/providers/authentication/authentication/context.ts new file mode 100644 index 00000000..fa66b3c9 --- /dev/null +++ b/src/providers/authentication/authentication/context.ts @@ -0,0 +1,8 @@ +import { createContext } from "react"; +import { DEFAULT_AUTHENTICATION_STATE } from "./constants"; +import { AuthenticationContextProps } from "./types"; + +export const AuthenticationContext = createContext({ + authenticationDispatch: null, + authenticationState: DEFAULT_AUTHENTICATION_STATE, +}); diff --git a/src/providers/authentication/authentication/dispatch.ts b/src/providers/authentication/authentication/dispatch.ts new file mode 100644 index 00000000..197d5363 --- /dev/null +++ b/src/providers/authentication/authentication/dispatch.ts @@ -0,0 +1,55 @@ +import { + AUTHENTICATION_STATUS, + AuthenticationActionKind, + RequestAuthenticationAction, + ResetStateAction, + UpdateAuthenticationAction, + UpdateAuthenticationPayload, +} from "./types"; + +/** + * Authentication is complete. + * @returns Action. + */ +export function authenticationComplete(): UpdateAuthenticationAction { + return { + payload: { status: AUTHENTICATION_STATUS.SETTLED }, + type: AuthenticationActionKind.UpdateAuthentication, + }; +} + +/** + * Request authentication action. + * @returns Action. + */ +export function requestAuthentication(): RequestAuthenticationAction { + return { + payload: undefined, + type: AuthenticationActionKind.RequestAuthentication, + }; +} + +/** + * Reset authentication action. + * @returns Action. + */ +export function resetState(): ResetStateAction { + return { + payload: undefined, + type: AuthenticationActionKind.ResetState, + }; +} + +/** + * Update authentication action. + * @param payload - Payload. + * @returns Action. + */ +export function updateAuthentication( + payload: UpdateAuthenticationPayload +): UpdateAuthenticationAction { + return { + payload, + type: AuthenticationActionKind.UpdateAuthentication, + }; +} diff --git a/src/providers/authentication/authentication/hook.ts b/src/providers/authentication/authentication/hook.ts new file mode 100644 index 00000000..a5a32100 --- /dev/null +++ b/src/providers/authentication/authentication/hook.ts @@ -0,0 +1,11 @@ +import { useContext } from "react"; +import { AuthenticationContext } from "./context"; +import { AuthenticationContextProps } from "./types"; + +/** + * Authentication hook. + * @returns authentication context. + */ +export const useAuthentication = (): AuthenticationContextProps => { + return useContext(AuthenticationContext); +}; diff --git a/src/providers/authentication/authentication/reducer.ts b/src/providers/authentication/authentication/reducer.ts new file mode 100644 index 00000000..d21ddc90 --- /dev/null +++ b/src/providers/authentication/authentication/reducer.ts @@ -0,0 +1,33 @@ +import { updateAuthenticationAction } from "./actions"; +import { + AUTHENTICATION_STATUS, + AuthenticationAction, + AuthenticationActionKind, + AuthenticationState, +} from "./types"; + +/** + * Authentication reducer. + * @param state - State. + * @param action - Action. + * @returns state. + */ +export function authenticationReducer( + state: AuthenticationState, + action: AuthenticationAction +): AuthenticationState { + const { payload, type } = action; + switch (type) { + case AuthenticationActionKind.RequestAuthentication: { + return { ...state, status: AUTHENTICATION_STATUS.PENDING }; + } + case AuthenticationActionKind.ResetState: { + return { ...state, ...state.initialState }; + } + case AuthenticationActionKind.UpdateAuthentication: { + return updateAuthenticationAction(state, payload); + } + default: + return state; + } +} diff --git a/src/providers/authentication/authentication/types.ts b/src/providers/authentication/authentication/types.ts new file mode 100644 index 00000000..d9562a97 --- /dev/null +++ b/src/providers/authentication/authentication/types.ts @@ -0,0 +1,64 @@ +import { Dispatch } from "react"; + +export enum AUTHENTICATION_STATUS { + PENDING = "PENDING", + SETTLED = "SETTLED", +} + +export type AuthenticationAction = + | RequestAuthenticationAction + | ResetStateAction + | UpdateAuthenticationAction; + +export enum AuthenticationActionKind { + RequestAuthentication = "REQUEST_AUTHENTICATION", + ResetState = "RESET_STATE", + UpdateAuthentication = "UPDATE_AUTHENTICATION", +} + +export interface AuthenticationContextProps { + authenticationDispatch: Dispatch | null; + authenticationState: AuthenticationState; +} + +export interface AuthenticationState { + initialState?: AuthenticationState; + profile: Profile; + status: AUTHENTICATION_STATUS; +} + +export interface BaseProfile { + id?: string; + name: string; +} + +export type Profile

= P | undefined; + +export type RequestAuthenticationAction = { + payload: RequestAuthenticationPayload; + type: AuthenticationActionKind.RequestAuthentication; +}; + +export type RequestAuthenticationPayload = undefined; + +export type ResetStateAction = { + payload: ResetStatePayload; + type: AuthenticationActionKind.ResetState; +}; + +export type ResetStatePayload = undefined; + +export type UpdateAuthenticationAction = { + payload: UpdateAuthenticationPayload; + type: AuthenticationActionKind.UpdateAuthentication; +}; + +export interface UpdateAuthenticationPayload { + profile?: Profile; + status?: AUTHENTICATION_STATUS; +} + +export interface UserProfile extends BaseProfile { + email: string; + image?: string; +} diff --git a/src/providers/authentication/authentication/utils.ts b/src/providers/authentication/authentication/utils.ts new file mode 100644 index 00000000..389f6153 --- /dev/null +++ b/src/providers/authentication/authentication/utils.ts @@ -0,0 +1,25 @@ +/** + * Fetches data from given endpoint and options. + * @param endpoint - Endpoint. + * @param options - Request options. + * @param callback - Callback. + * @param callback.onError - Error callback. + * @param callback.onSuccess - Success callback. + */ +export function fetchProfile( + endpoint: string, + options?: RequestInit, + callback?: { + onError: (error: E) => void; + onSuccess: (response: R) => void; + } +): void { + fetch(endpoint, options) + .then((response) => response.json()) + .then((r: R) => { + callback?.onSuccess(r); + }) + .catch((e: E) => { + callback?.onError(e); + }); +} diff --git a/src/providers/authentication/common/types.ts b/src/providers/authentication/common/types.ts new file mode 100644 index 00000000..42c5c76e --- /dev/null +++ b/src/providers/authentication/common/types.ts @@ -0,0 +1 @@ +export type ProviderId = string; diff --git a/src/providers/authentication/common/utils.ts b/src/providers/authentication/common/utils.ts new file mode 100644 index 00000000..71567845 --- /dev/null +++ b/src/providers/authentication/common/utils.ts @@ -0,0 +1,11 @@ +/** + * Reducer state initializer. + * @param initialState - Initial state. + * @returns initial reducer state. + */ +export function initializer(initialState: S): S { + return { + ...initialState, + initialState, + }; +} diff --git a/src/providers/authentication/credentials/actions.ts b/src/providers/authentication/credentials/actions.ts new file mode 100644 index 00000000..668c4fe9 --- /dev/null +++ b/src/providers/authentication/credentials/actions.ts @@ -0,0 +1,17 @@ +import { CredentialsState, UpdateCredentialsPayload } from "./types"; + +/** + * Update credentials action. + * @param state - State. + * @param payload - Payload. + * @returns state. + */ +export function updateCredentialsAction( + state: CredentialsState, + payload: UpdateCredentialsPayload +): CredentialsState { + return { + ...state, + credentials: payload, + }; +} diff --git a/src/providers/authentication/credentials/constants.ts b/src/providers/authentication/credentials/constants.ts new file mode 100644 index 00000000..6c1a0a9a --- /dev/null +++ b/src/providers/authentication/credentials/constants.ts @@ -0,0 +1,5 @@ +import { CredentialsState } from "./types"; + +export const DEFAULT_CREDENTIALS_STATE: CredentialsState = { + credentials: undefined, +}; diff --git a/src/providers/authentication/credentials/context.ts b/src/providers/authentication/credentials/context.ts new file mode 100644 index 00000000..ee1729ea --- /dev/null +++ b/src/providers/authentication/credentials/context.ts @@ -0,0 +1,8 @@ +import { createContext } from "react"; +import { DEFAULT_CREDENTIALS_STATE } from "./constants"; +import { CredentialsContextProps } from "./types"; + +export const CredentialsContext = createContext({ + credentialsDispatch: null, + credentialsState: DEFAULT_CREDENTIALS_STATE, +}); diff --git a/src/providers/authentication/credentials/dispatch.ts b/src/providers/authentication/credentials/dispatch.ts new file mode 100644 index 00000000..37d34cf9 --- /dev/null +++ b/src/providers/authentication/credentials/dispatch.ts @@ -0,0 +1,31 @@ +import { + CredentialsActionKind, + ResetStateAction, + UpdateCredentialsAction, + UpdateCredentialsPayload, +} from "./types"; + +/** + * Update credentials action. + * @returns Action. + */ +export function resetState(): ResetStateAction { + return { + payload: undefined, + type: CredentialsActionKind.ResetState, + }; +} + +/** + * Update credentials action. + * @param payload - Payload. + * @returns Action. + */ +export function updateCredentials( + payload: UpdateCredentialsPayload +): UpdateCredentialsAction { + return { + payload, + type: CredentialsActionKind.UpdateCredentials, + }; +} diff --git a/src/providers/authentication/credentials/hook.ts b/src/providers/authentication/credentials/hook.ts new file mode 100644 index 00000000..1b34a43a --- /dev/null +++ b/src/providers/authentication/credentials/hook.ts @@ -0,0 +1,11 @@ +import { useContext } from "react"; +import { CredentialsContext } from "./context"; +import { CredentialsContextProps } from "./types"; + +/** + * Credentials hook. + * @returns credentials context. + */ +export const useCredentials = (): CredentialsContextProps => { + return useContext(CredentialsContext); +}; diff --git a/src/providers/authentication/credentials/reducer.ts b/src/providers/authentication/credentials/reducer.ts new file mode 100644 index 00000000..d6b9c981 --- /dev/null +++ b/src/providers/authentication/credentials/reducer.ts @@ -0,0 +1,30 @@ +import { updateCredentialsAction } from "./actions"; +import { DEFAULT_CREDENTIALS_STATE } from "./constants"; +import { + CredentialsAction, + CredentialsActionKind, + CredentialsState, +} from "./types"; + +/** + * Credentials reducer. + * @param state - State. + * @param action - Action. + * @returns state. + */ +export function credentialsReducer( + state: CredentialsState, + action: CredentialsAction +): CredentialsState { + const { payload, type } = action; + switch (type) { + case CredentialsActionKind.ResetState: { + return DEFAULT_CREDENTIALS_STATE; + } + case CredentialsActionKind.UpdateCredentials: { + return updateCredentialsAction(state, payload); + } + default: + return state; + } +} diff --git a/src/providers/authentication/credentials/types.ts b/src/providers/authentication/credentials/types.ts new file mode 100644 index 00000000..84690ce8 --- /dev/null +++ b/src/providers/authentication/credentials/types.ts @@ -0,0 +1,33 @@ +import { Dispatch } from "react"; + +export type Credentials = C; + +export type CredentialsAction = ResetStateAction | UpdateCredentialsAction; + +export enum CredentialsActionKind { + ResetState = "RESET_STATE", + UpdateCredentials = "STORE_CREDENTIALS", +} + +export interface CredentialsContextProps { + credentialsDispatch: Dispatch | null; + credentialsState: CredentialsState; +} + +export interface CredentialsState { + credentials: Credentials; +} + +export type ResetStateAction = { + payload: ResetStatePayload; + type: CredentialsActionKind.ResetState; +}; + +export type ResetStatePayload = undefined; + +export type UpdateCredentialsAction = { + payload: UpdateCredentialsPayload; + type: CredentialsActionKind.UpdateCredentials; +}; + +export type UpdateCredentialsPayload = Credentials; diff --git a/src/providers/authentication/terra/context.ts b/src/providers/authentication/terra/context.ts new file mode 100644 index 00000000..6ccf5bac --- /dev/null +++ b/src/providers/authentication/terra/context.ts @@ -0,0 +1,16 @@ +import { createContext } from "react"; +import { LOGIN_STATUS_NOT_STARTED } from "./hooks/common/constants"; +import { LoginStatus } from "./hooks/common/entities"; +import { TerraNIHResponse } from "./hooks/useFetchTerraNIHProfile"; +import { TerraResponse } from "./hooks/useFetchTerraProfile"; +import { TerraTermsOfServiceResponse } from "./hooks/useFetchTerraTermsOfService"; +import { TerraProfileContextProps } from "./types"; + +export const TerraProfileContext = createContext({ + terraNIHProfileLoginStatus: + LOGIN_STATUS_NOT_STARTED as LoginStatus, + terraProfileLoginStatus: + LOGIN_STATUS_NOT_STARTED as LoginStatus, + terraTOSLoginStatus: + LOGIN_STATUS_NOT_STARTED as LoginStatus, +}); diff --git a/src/providers/authentication/terra/hook.ts b/src/providers/authentication/terra/hook.ts new file mode 100644 index 00000000..9a16acc8 --- /dev/null +++ b/src/providers/authentication/terra/hook.ts @@ -0,0 +1,11 @@ +import { useContext } from "react"; +import { TerraProfileContext } from "./context"; +import { TerraProfileContextProps } from "./types"; + +/** + * Terra profile hook. + * @returns terra profile context. + */ +export const useTerraProfile = (): TerraProfileContextProps => { + return useContext(TerraProfileContext); +}; diff --git a/src/hooks/useAuthentication/common/constants.ts b/src/providers/authentication/terra/hooks/common/constants.ts similarity index 73% rename from src/hooks/useAuthentication/common/constants.ts rename to src/providers/authentication/terra/hooks/common/constants.ts index af025127..361a9a3f 100644 --- a/src/hooks/useAuthentication/common/constants.ts +++ b/src/providers/authentication/terra/hooks/common/constants.ts @@ -20,3 +20,12 @@ export const LOGIN_STATUS_NOT_SUPPORTED: LoginStatus = { requestStatus: REQUEST_STATUS.NOT_STARTED, response: undefined, }; + +export const LOGIN_STATUS_PENDING: LoginStatus = { + isSuccess: false, + isSupported: true, + requestStatus: REQUEST_STATUS.PENDING, + response: undefined, +}; + +export const TERRA_SERVICE_ID = "terra"; diff --git a/src/hooks/useAuthentication/common/entities.ts b/src/providers/authentication/terra/hooks/common/entities.ts similarity index 79% rename from src/hooks/useAuthentication/common/entities.ts rename to src/providers/authentication/terra/hooks/common/entities.ts index b0e66cd0..47632102 100644 --- a/src/hooks/useAuthentication/common/entities.ts +++ b/src/providers/authentication/terra/hooks/common/entities.ts @@ -1,15 +1,8 @@ -import { GoogleResponse } from "../useFetchGoogleProfile"; import { TerraNIHResponse } from "../useFetchTerraNIHProfile"; import { TerraResponse } from "../useFetchTerraProfile"; import { TerraTermsOfServiceResponse } from "../useFetchTerraTermsOfService"; -export enum AUTHENTICATION_STATUS { - COMPLETED = "COMPLETED", - INCOMPLETE = "INCOMPLETE", -} - export type LoginResponse = - | GoogleResponse | TerraResponse | TerraNIHResponse | TerraTermsOfServiceResponse; @@ -31,4 +24,5 @@ export enum REQUEST_STATUS { COMPLETED = "COMPLETED", FAILED = "FAILED", NOT_STARTED = "NOT_STARTED", + PENDING = "PENDING", } diff --git a/src/hooks/useAuthentication/common/utils.ts b/src/providers/authentication/terra/hooks/common/utils.ts similarity index 100% rename from src/hooks/useAuthentication/common/utils.ts rename to src/providers/authentication/terra/hooks/common/utils.ts diff --git a/src/providers/authentication/terra/hooks/useFetchProfiles.ts b/src/providers/authentication/terra/hooks/useFetchProfiles.ts new file mode 100644 index 00000000..47ab2fdc --- /dev/null +++ b/src/providers/authentication/terra/hooks/useFetchProfiles.ts @@ -0,0 +1,59 @@ +import { useEffect, useState } from "react"; +import { useAuthentication } from "../../authentication/hook"; +import { TERRA_PROFILE_STATUS } from "../types"; +import { getProfileStatus } from "../utils"; +import { LoginStatus } from "./common/entities"; +import { + TerraNIHResponse, + useFetchTerraNIHProfile, +} from "./useFetchTerraNIHProfile"; +import { TerraResponse, useFetchTerraProfile } from "./useFetchTerraProfile"; +import { + TerraTermsOfServiceResponse, + useFetchTerraTermsOfService, +} from "./useFetchTerraTermsOfService"; + +export interface UseFetchProfiles { + isComplete: boolean; + isProfileActive: boolean; + terraNIHProfileLoginStatus: LoginStatus; + terraProfileLoginStatus: LoginStatus; + terraTOSLoginStatus: LoginStatus; +} + +export const useFetchProfiles = (token?: string): UseFetchProfiles => { + const [status, setStatus] = useState( + TERRA_PROFILE_STATUS.PENDING + ); + const { + authenticationState: { profile }, + } = useAuthentication(); + const isUserAuthenticated = !!profile; + const terraNIHProfileLoginStatus = useFetchTerraNIHProfile(token); + const terraProfileLoginStatus = useFetchTerraProfile(token); + const terraTOSLoginStatus = useFetchTerraTermsOfService(token); + + useEffect(() => { + setStatus( + getProfileStatus( + isUserAuthenticated, + terraNIHProfileLoginStatus, + terraProfileLoginStatus, + terraTOSLoginStatus + ) + ); + }, [ + isUserAuthenticated, + terraNIHProfileLoginStatus, + terraProfileLoginStatus, + terraTOSLoginStatus, + ]); + + return { + isComplete: status !== TERRA_PROFILE_STATUS.PENDING, + isProfileActive: status === TERRA_PROFILE_STATUS.AUTHENTICATED, + terraNIHProfileLoginStatus, + terraProfileLoginStatus, + terraTOSLoginStatus, + }; +}; diff --git a/src/hooks/useAuthentication/useFetchTerraNIHProfile.ts b/src/providers/authentication/terra/hooks/useFetchTerraNIHProfile.ts similarity index 77% rename from src/hooks/useAuthentication/useFetchTerraNIHProfile.ts rename to src/providers/authentication/terra/hooks/useFetchTerraNIHProfile.ts index 25e6f42a..4dc90b40 100644 --- a/src/hooks/useAuthentication/useFetchTerraNIHProfile.ts +++ b/src/providers/authentication/terra/hooks/useFetchTerraNIHProfile.ts @@ -1,6 +1,11 @@ import { useCallback, useEffect, useState } from "react"; -import { useAuthenticationConfig } from "../useAuthenticationConfig"; -import { LOGIN_STATUS_FAILED } from "./common/constants"; +import { useAuthenticationConfig } from "../../../../hooks/authentication/config/useAuthenticationConfig"; +import { + LOGIN_STATUS_FAILED, + LOGIN_STATUS_NOT_STARTED, + LOGIN_STATUS_PENDING, + TERRA_SERVICE_ID, +} from "./common/constants"; import { LoginResponseError, LoginStatus, @@ -10,6 +15,9 @@ import { getAuthenticationRequestOptions, initLoginStatus, } from "./common/utils"; +import { getServiceEndpoint } from "./utils"; + +const ENDPOINT_ID = "nihStatus"; interface DatasetPermission { authorized: boolean; @@ -30,16 +38,20 @@ export interface TerraNIHResponse { * @returns Terra NIH login status. */ export const useFetchTerraNIHProfile = (token?: string): Status => { - const authenticationConfig = useAuthenticationConfig(); - const { terraAuthConfig: { terraNIHProfileEndpoint: endpoint } = {} } = - authenticationConfig; + const { services } = useAuthenticationConfig() || {}; + const endpoint = getServiceEndpoint(services, TERRA_SERVICE_ID, ENDPOINT_ID); const [loginStatus, setLoginStatus] = useState( initLoginStatus(endpoint) as Status ); // Fetch Terra NIH account profile. const fetchEndpointData = useCallback( - (endpoint: string, accessToken: string): void => { + (endpoint: string, accessToken?: string): void => { + if (!accessToken) { + setLoginStatus(LOGIN_STATUS_NOT_STARTED as Status); + return; + } + setLoginStatus(LOGIN_STATUS_PENDING as Status); fetch(endpoint, getAuthenticationRequestOptions(accessToken)) .then((response) => response.json()) .then((response: LoginResponseError | TerraNIHResponse) => { @@ -64,7 +76,6 @@ export const useFetchTerraNIHProfile = (token?: string): Status => { // Fetches Terra NIH account profile. useEffect(() => { - if (!token) return; if (!endpoint) return; fetchEndpointData(endpoint, token); }, [endpoint, fetchEndpointData, token]); diff --git a/src/hooks/useAuthentication/useFetchTerraProfile.ts b/src/providers/authentication/terra/hooks/useFetchTerraProfile.ts similarity index 78% rename from src/hooks/useAuthentication/useFetchTerraProfile.ts rename to src/providers/authentication/terra/hooks/useFetchTerraProfile.ts index b91657e2..83ec4c9a 100644 --- a/src/hooks/useAuthentication/useFetchTerraProfile.ts +++ b/src/providers/authentication/terra/hooks/useFetchTerraProfile.ts @@ -1,6 +1,11 @@ import { useCallback, useEffect, useState } from "react"; -import { useAuthenticationConfig } from "../useAuthenticationConfig"; -import { LOGIN_STATUS_FAILED } from "./common/constants"; +import { useAuthenticationConfig } from "../../../../hooks/authentication/config/useAuthenticationConfig"; +import { + LOGIN_STATUS_FAILED, + LOGIN_STATUS_NOT_STARTED, + LOGIN_STATUS_PENDING, + TERRA_SERVICE_ID, +} from "./common/constants"; import { LoginResponseError, LoginStatus, @@ -10,6 +15,9 @@ import { getAuthenticationRequestOptions, initLoginStatus, } from "./common/utils"; +import { getServiceEndpoint } from "./utils"; + +const ENDPOINT_ID = "profile"; type Status = LoginStatus; @@ -37,16 +45,20 @@ interface TerraResponseUserInfo { * @returns Terra profile login status. */ export const useFetchTerraProfile = (token?: string): Status => { - const authenticationConfig = useAuthenticationConfig(); - const { terraAuthConfig: { terraProfileEndpoint: endpoint } = {} } = - authenticationConfig; + const { services } = useAuthenticationConfig() || {}; + const endpoint = getServiceEndpoint(services, TERRA_SERVICE_ID, ENDPOINT_ID); const [loginStatus, setLoginStatus] = useState( initLoginStatus(endpoint) as Status ); // Fetch Terra profile. const fetchEndpointData = useCallback( - (endpoint: string, accessToken: string): void => { + (endpoint: string, accessToken?: string): void => { + if (!accessToken) { + setLoginStatus(LOGIN_STATUS_NOT_STARTED as Status); + return; + } + setLoginStatus(LOGIN_STATUS_PENDING as Status); fetch(endpoint, getAuthenticationRequestOptions(accessToken)) .then((response) => response.json()) .then((response: TerraResponse | LoginResponseError) => { @@ -71,7 +83,6 @@ export const useFetchTerraProfile = (token?: string): Status => { // Fetches Terra profile. useEffect(() => { - if (!token) return; if (!endpoint) return; fetchEndpointData(endpoint, token); }, [endpoint, fetchEndpointData, token]); diff --git a/src/hooks/useAuthentication/useFetchTerraTermsOfService.ts b/src/providers/authentication/terra/hooks/useFetchTerraTermsOfService.ts similarity index 78% rename from src/hooks/useAuthentication/useFetchTerraTermsOfService.ts rename to src/providers/authentication/terra/hooks/useFetchTerraTermsOfService.ts index 1a54f65b..d9588abf 100644 --- a/src/hooks/useAuthentication/useFetchTerraTermsOfService.ts +++ b/src/providers/authentication/terra/hooks/useFetchTerraTermsOfService.ts @@ -1,6 +1,11 @@ import { useCallback, useEffect, useState } from "react"; -import { useAuthenticationConfig } from "../useAuthenticationConfig"; -import { LOGIN_STATUS_FAILED } from "./common/constants"; +import { useAuthenticationConfig } from "../../../../hooks/authentication/config/useAuthenticationConfig"; +import { + LOGIN_STATUS_FAILED, + LOGIN_STATUS_NOT_STARTED, + LOGIN_STATUS_PENDING, + TERRA_SERVICE_ID, +} from "./common/constants"; import { LoginResponseError, LoginStatus, @@ -10,6 +15,9 @@ import { getAuthenticationRequestOptions, initLoginStatus, } from "./common/utils"; +import { getServiceEndpoint } from "./utils"; + +const ENDPOINT_ID = "tos"; type Status = LoginStatus; @@ -26,16 +34,20 @@ export interface TerraTermsOfServiceResponse { * @returns Terra terms of service login status. */ export const useFetchTerraTermsOfService = (token?: string): Status => { - const authenticationConfig = useAuthenticationConfig(); - const { terraAuthConfig: { termsOfServiceEndpoint: endpoint } = {} } = - authenticationConfig; + const { services } = useAuthenticationConfig() || {}; + const endpoint = getServiceEndpoint(services, TERRA_SERVICE_ID, ENDPOINT_ID); const [loginStatus, setLoginStatus] = useState( initLoginStatus(endpoint) as Status ); // Fetch Terra terms of service. const fetchEndpointData = useCallback( - (endpoint: string, accessToken: string): void => { + (endpoint: string, accessToken?: string): void => { + if (!accessToken) { + setLoginStatus(LOGIN_STATUS_NOT_STARTED as Status); + return; + } + setLoginStatus(LOGIN_STATUS_PENDING as Status); fetch(endpoint, getAuthenticationRequestOptions(accessToken)) .then((response) => response.json()) .then((response: LoginResponseError | TerraTermsOfServiceResponse) => { @@ -60,7 +72,6 @@ export const useFetchTerraTermsOfService = (token?: string): Status => { // Fetches Terra terms of service. useEffect(() => { - if (!token) return; if (!endpoint) return; fetchEndpointData(endpoint, token); }, [endpoint, fetchEndpointData, token]); diff --git a/src/providers/authentication/terra/hooks/utils.ts b/src/providers/authentication/terra/hooks/utils.ts new file mode 100644 index 00000000..b313a49d --- /dev/null +++ b/src/providers/authentication/terra/hooks/utils.ts @@ -0,0 +1,29 @@ +import { AuthService } from "../../../../config/entities"; + +/** + * Returns service endpoint. + * @param services - Services. + * @param serviceId - Service ID. + * @param endpointId - Endpoint ID. + * @returns Service endpoint. + */ +export function getServiceEndpoint( + services: AuthService[] | undefined, + serviceId: string, + endpointId: string +): string | undefined { + return findService(services, serviceId)?.endpoint[endpointId]; +} + +/** + * Find a service by service ID. + * @param services - Services. + * @param serviceId - Service ID. + * @returns Service. + */ +export function findService( + services: AuthService[] | undefined, + serviceId: string +): AuthService | undefined { + return services?.find(({ id }) => id === serviceId); +} diff --git a/src/providers/authentication/terra/provider.tsx b/src/providers/authentication/terra/provider.tsx new file mode 100644 index 00000000..d7639c57 --- /dev/null +++ b/src/providers/authentication/terra/provider.tsx @@ -0,0 +1,48 @@ +import React, { useEffect } from "react"; +import { authenticationComplete } from "../authentication/dispatch"; +import { useAuthentication } from "../authentication/hook"; +import { updateCredentials } from "../credentials/dispatch"; +import { useCredentials } from "../credentials/hook"; +import { TerraProfileContext } from "./context"; +import { useFetchProfiles } from "./hooks/useFetchProfiles"; +import { TerraProfileProviderProps } from "./types"; + +export function TerraProfileProvider({ + children, + token, +}: TerraProfileProviderProps): JSX.Element { + const { authenticationDispatch } = useAuthentication(); + const { credentialsDispatch } = useCredentials(); + const { + isComplete, + isProfileActive, + terraNIHProfileLoginStatus, + terraProfileLoginStatus, + terraTOSLoginStatus, + } = useFetchProfiles(token); + + useEffect(() => { + if (!isComplete) return; + authenticationDispatch?.(authenticationComplete()); + if (!isProfileActive) return; + credentialsDispatch?.(updateCredentials(token)); + }, [ + authenticationDispatch, + credentialsDispatch, + isComplete, + isProfileActive, + token, + ]); + + return ( + + {children} + + ); +} diff --git a/src/providers/authentication/terra/types.ts b/src/providers/authentication/terra/types.ts new file mode 100644 index 00000000..a1b0e17a --- /dev/null +++ b/src/providers/authentication/terra/types.ts @@ -0,0 +1,23 @@ +import { ReactNode } from "react"; +import { TokenState } from "../token/types"; +import { LoginStatus } from "./hooks/common/entities"; +import { TerraNIHResponse } from "./hooks/useFetchTerraNIHProfile"; +import { TerraResponse } from "./hooks/useFetchTerraProfile"; +import { TerraTermsOfServiceResponse } from "./hooks/useFetchTerraTermsOfService"; + +export enum TERRA_PROFILE_STATUS { + AUTHENTICATED = "AUTHENTICATED", + PENDING = "PENDING", + UNAUTHENTICATED = "UNAUTHENTICATED", +} + +export interface TerraProfileProviderProps { + children: ReactNode; + token: TokenState["token"]; +} + +export interface TerraProfileContextProps { + terraNIHProfileLoginStatus: LoginStatus; + terraProfileLoginStatus: LoginStatus; + terraTOSLoginStatus: LoginStatus; +} diff --git a/src/providers/authentication/terra/utils.ts b/src/providers/authentication/terra/utils.ts new file mode 100644 index 00000000..c40d3e95 --- /dev/null +++ b/src/providers/authentication/terra/utils.ts @@ -0,0 +1,49 @@ +import { LoginStatus, REQUEST_STATUS } from "./hooks/common/entities"; +import { TerraNIHResponse } from "./hooks/useFetchTerraNIHProfile"; +import { TerraResponse } from "./hooks/useFetchTerraProfile"; +import { TerraTermsOfServiceResponse } from "./hooks/useFetchTerraTermsOfService"; +import { TERRA_PROFILE_STATUS } from "./types"; + +/** + * Determines the status of a user based on authentication and Terra service statuses. + * **Logic:** + * - **Pending** if the user is not authenticated. + * - **Pending** if any supported Terra service request is not started or pending. + * - **Unauthenticated** if the Terra profile is supported but the Terms of Service have not been accepted. + * - **Authenticated** in all other cases. + * @param isUserAuthenticated - User authentication status. + * @param terraNIHProfileLoginStatus - Terra NIH profile login status. + * @param terraProfileLoginStatus - Terra profile login status. + * @param terraTOSLoginStatus - Terra terms of service login status. + * @returns Terra profile status. + */ +export function getProfileStatus( + isUserAuthenticated: boolean, + terraNIHProfileLoginStatus: LoginStatus, + terraProfileLoginStatus: LoginStatus, + terraTOSLoginStatus: LoginStatus +): TERRA_PROFILE_STATUS { + if (!isUserAuthenticated) return TERRA_PROFILE_STATUS.PENDING; + + // Check if any supported Terra service request is not started or pending. + const terraServices = [ + terraNIHProfileLoginStatus, + terraProfileLoginStatus, + terraTOSLoginStatus, + ]; + const isAnyServicePending = terraServices.some( + ({ isSupported, requestStatus }) => + isSupported && + (requestStatus === REQUEST_STATUS.NOT_STARTED || + requestStatus === REQUEST_STATUS.PENDING) + ); + if (isAnyServicePending) return TERRA_PROFILE_STATUS.PENDING; + + // If Terra profile is supported but Terms of Service not accepted. + if (terraProfileLoginStatus.isSupported && !terraTOSLoginStatus.isSuccess) { + return TERRA_PROFILE_STATUS.UNAUTHENTICATED; + } + + // Authenticated in all other cases. + return TERRA_PROFILE_STATUS.AUTHENTICATED; +} diff --git a/src/providers/authentication/token/constants.ts b/src/providers/authentication/token/constants.ts new file mode 100644 index 00000000..59fa56bf --- /dev/null +++ b/src/providers/authentication/token/constants.ts @@ -0,0 +1,6 @@ +import { TokenState } from "./types"; + +export const DEFAULT_TOKEN_STATE: TokenState = { + providerId: undefined, + token: undefined, +}; diff --git a/src/providers/authentication/token/dispatch.ts b/src/providers/authentication/token/dispatch.ts new file mode 100644 index 00000000..c4af8a70 --- /dev/null +++ b/src/providers/authentication/token/dispatch.ts @@ -0,0 +1,29 @@ +import { + ResetStateAction, + TokenActionKind, + UpdateTokenAction, + UpdateTokenPayload, +} from "./types"; + +/** + * Reset state action. + * @returns Action. + */ +export function resetState(): ResetStateAction { + return { + payload: undefined, + type: TokenActionKind.ResetState, + }; +} + +/** + * Update token action. + * @param payload - Payload. + * @returns Action. + */ +export function updateToken(payload: UpdateTokenPayload): UpdateTokenAction { + return { + payload, + type: TokenActionKind.UpdateToken, + }; +} diff --git a/src/providers/authentication/token/reducer.ts b/src/providers/authentication/token/reducer.ts new file mode 100644 index 00000000..ecad03ef --- /dev/null +++ b/src/providers/authentication/token/reducer.ts @@ -0,0 +1,26 @@ +import { DEFAULT_TOKEN_STATE } from "./constants"; +import { TokenAction, TokenActionKind, TokenState } from "./types"; + +/** + * Token reducer. + * @param state - State. + * @param action - Action. + * @returns state. + */ +export function tokenReducer( + state: TokenState, + action: TokenAction +): TokenState { + const { payload, type } = action; + switch (type) { + case TokenActionKind.ResetState: + return DEFAULT_TOKEN_STATE; + case TokenActionKind.UpdateToken: + return { + ...state, + ...payload, + }; + default: + return state; + } +} diff --git a/src/providers/authentication/token/types.ts b/src/providers/authentication/token/types.ts new file mode 100644 index 00000000..a75a8cde --- /dev/null +++ b/src/providers/authentication/token/types.ts @@ -0,0 +1,36 @@ +import { Dispatch } from "react"; +import { ProviderId } from "../common/types"; + +export interface ResetStateAction { + payload: ResetStatePayload; + type: TokenActionKind.ResetState; +} + +export type ResetStatePayload = undefined; + +export type TokenAction = ResetStateAction | UpdateTokenAction; + +export enum TokenActionKind { + ResetState = "RESET_TOKEN", + UpdateToken = "UPDATE_TOKEN", +} + +export interface TokenContextProps { + tokenDispatch: Dispatch | null; + tokenState: TokenState; +} + +export interface TokenState { + providerId: ProviderId | undefined; + token: string | undefined; +} + +export interface UpdateTokenAction { + payload: UpdateTokenPayload; + type: TokenActionKind.UpdateToken; +} + +export interface UpdateTokenPayload { + providerId: ProviderId | undefined; + token: string | undefined; +} diff --git a/src/providers/exploreState.tsx b/src/providers/exploreState.tsx index abefaddd..0e2096e5 100644 --- a/src/providers/exploreState.tsx +++ b/src/providers/exploreState.tsx @@ -12,7 +12,7 @@ import { AzulSearchIndex } from "../apis/azul/common/entities"; import { SelectCategoryView, SelectedFilter } from "../common/entities"; import { RowPreviewState } from "../components/Table/features/RowPreview/entities"; import { CategoryGroup, SiteConfig } from "../config/entities"; -import { useAuthentication } from "../hooks/useAuthentication/useAuthentication"; +import { useToken } from "../hooks/authentication/token/useToken"; import { buildCategoryViews, buildNextFilterState, @@ -168,7 +168,7 @@ export function ExploreStateProvider({ const { config, defaultEntityListType } = useConfig(); const { decodedCatalogParam, decodedFeatureFlagParam, decodedFilterParam } = useURLFilterParams(); - const { isEnabled: isAuthEnabled, token } = useAuthentication(); + const { token } = useToken(); const entityList = entityListType || defaultEntityListType; const [initializerArg] = useState(() => initReducerArguments( @@ -196,12 +196,11 @@ export function ExploreStateProvider({ // Reset explore response when token changes. useEffect(() => { - if (!isAuthEnabled) return; exploreDispatch({ payload: undefined, type: ExploreActionKind.ResetExploreResponse, }); - }, [exploreDispatch, isAuthEnabled, token]); + }, [exploreDispatch, token]); return ( diff --git a/src/providers/googleSignInAuthentication/common/types.ts b/src/providers/googleSignInAuthentication/common/types.ts new file mode 100644 index 00000000..8e187e22 --- /dev/null +++ b/src/providers/googleSignInAuthentication/common/types.ts @@ -0,0 +1,25 @@ +import { Dispatch } from "react"; +import { + AuthenticationAction, + AuthenticationContextProps, +} from "../../authentication/authentication/types"; +import { + CredentialsAction, + CredentialsContextProps, +} from "../../authentication/credentials/types"; +import { + TokenAction, + TokenContextProps, +} from "../../authentication/token/types"; + +export interface SessionReducer { + authenticationReducer: AuthenticationContextProps; + credentialsReducer: CredentialsContextProps; + tokenReducer: TokenContextProps; +} + +export interface SessionDispatch { + authenticationDispatch: Dispatch | null; + credentialsDispatch: Dispatch | null; + tokenDispatch: Dispatch | null; +} diff --git a/src/providers/googleSignInAuthentication/constants.ts b/src/providers/googleSignInAuthentication/constants.ts new file mode 100644 index 00000000..2b565cd6 --- /dev/null +++ b/src/providers/googleSignInAuthentication/constants.ts @@ -0,0 +1,17 @@ +import { DEFAULT_AUTH_STATE } from "../authentication/auth/constants"; +import { AUTH_STATUS, AuthState } from "../authentication/auth/types"; +import { DEFAULT_AUTHENTICATION_STATE } from "../authentication/authentication/constants"; +import { + AUTHENTICATION_STATUS, + AuthenticationState, +} from "../authentication/authentication/types"; + +export const AUTH_STATE: AuthState = { + ...DEFAULT_AUTH_STATE, + status: AUTH_STATUS.SETTLED, +}; + +export const AUTHENTICATION_STATE: AuthenticationState = { + ...DEFAULT_AUTHENTICATION_STATE, + status: AUTHENTICATION_STATUS.SETTLED, +}; diff --git a/src/providers/googleSignInAuthentication/hooks/useGoogleSignInService.ts b/src/providers/googleSignInAuthentication/hooks/useGoogleSignInService.ts new file mode 100644 index 00000000..1cee20d8 --- /dev/null +++ b/src/providers/googleSignInAuthentication/hooks/useGoogleSignInService.ts @@ -0,0 +1,40 @@ +import Router from "next/router"; +import { useCallback } from "react"; +import { useProviders } from "../../../hooks/authentication/providers/useProviders"; +import { Service } from "../../authentication/auth/types"; +import { ProviderId } from "../../authentication/common/types"; +import { SessionReducer } from "../common/types"; +import { service } from "../service/service"; + +export const useGoogleSignInService = (reducer: SessionReducer): Service => { + const { findProvider } = useProviders(); + const { + authenticationReducer: { authenticationDispatch }, + credentialsReducer: { credentialsDispatch }, + tokenReducer: { tokenDispatch }, + } = reducer; + + const onLogin = useCallback( + (providerId: ProviderId) => { + const provider = findProvider(providerId); + if (!provider) return; + service.login(provider, { authenticationDispatch, tokenDispatch }); + }, + [authenticationDispatch, findProvider, tokenDispatch] + ); + + const onLogout = useCallback( + (options?: { callbackUrl?: string }) => { + service.logout({ + authenticationDispatch, + credentialsDispatch, + tokenDispatch, + }); + if (!options?.callbackUrl) return; + Router.push(options?.callbackUrl).catch((e) => console.error(e)); + }, + [authenticationDispatch, credentialsDispatch, tokenDispatch] + ); + + return { requestLogin: onLogin, requestLogout: onLogout }; +}; diff --git a/src/providers/googleSignInAuthentication/profile/types.ts b/src/providers/googleSignInAuthentication/profile/types.ts new file mode 100644 index 00000000..c99079e5 --- /dev/null +++ b/src/providers/googleSignInAuthentication/profile/types.ts @@ -0,0 +1,15 @@ +export interface GoogleProfile { + email: string; + email_verified: boolean; + family_name: string; + given_name: string; + hd: string; + locale: string; + name: string; + picture: string; + sub: string; +} + +export interface TokenSetParameters { + access_token: string; +} diff --git a/src/providers/googleSignInAuthentication/profile/utils.ts b/src/providers/googleSignInAuthentication/profile/utils.ts new file mode 100644 index 00000000..dda481dc --- /dev/null +++ b/src/providers/googleSignInAuthentication/profile/utils.ts @@ -0,0 +1,29 @@ +import { UserProfile } from "../../authentication/authentication/types"; +import { GOOGLE_SIGN_IN_PROVIDER_ID } from "../service/constants"; +import { GoogleProfile } from "./types"; + +/** + * Returns full name, from given and family name. + * @param profile - Google response. + * @returns full name. + */ +function getFullName(profile: GoogleProfile): string { + const { family_name: lastName = "", given_name: firstName = "" } = profile; + return `${firstName} ${lastName}`.trim(); +} + +/** + * Returns user profile from google response. + * @param profile - Google response. + * @returns user profile. + */ +export function mapProfile(profile: GoogleProfile): UserProfile { + const { email, picture: image } = profile; + const name = getFullName(profile); + return { + email, + id: GOOGLE_SIGN_IN_PROVIDER_ID, + image, + name, + }; +} diff --git a/src/providers/googleSignInAuthentication/provider.tsx b/src/providers/googleSignInAuthentication/provider.tsx new file mode 100644 index 00000000..9d63f871 --- /dev/null +++ b/src/providers/googleSignInAuthentication/provider.tsx @@ -0,0 +1,52 @@ +import React from "react"; +import { useAuthReducer } from "../../hooks/authentication/auth/useAuthReducer"; +import { useAuthenticationReducer } from "../../hooks/authentication/authentication/useAuthenticationReducer"; +import { useCredentialsReducer } from "../../hooks/authentication/credentials/useCredentialsReducer"; +import { useSessionActive } from "../../hooks/authentication/session/useSessionActive"; +import { useSessionAuth } from "../../hooks/authentication/session/useSessionAuth"; +import { useSessionCallbackUrl } from "../../hooks/authentication/session/useSessionCallbackUrl"; +import { useSessionIdleTimer } from "../../hooks/authentication/session/useSessionIdleTimer"; +import { useTokenReducer } from "../../hooks/authentication/token/useTokenReducer"; +import { AuthContext } from "../authentication/auth/context"; +import { AuthenticationContext } from "../authentication/authentication/context"; +import { CredentialsContext } from "../authentication/credentials/context"; +import { AUTH_STATE, AUTHENTICATION_STATE } from "./constants"; +import { useGoogleSignInService } from "./hooks/useGoogleSignInService"; +import { GoogleSignInAuthenticationProviderProps } from "./types"; + +export function GoogleSignInAuthenticationProvider({ + APIServicesProvider, + children, + timeout, +}: GoogleSignInAuthenticationProviderProps): JSX.Element { + const authReducer = useAuthReducer(AUTH_STATE); + const authenticationReducer = useAuthenticationReducer(AUTHENTICATION_STATE); + const credentialsReducer = useCredentialsReducer(); + const tokenReducer = useTokenReducer(); // Reducer, local to Google Sign-In process only. + const service = useGoogleSignInService({ + authenticationReducer, + credentialsReducer, + tokenReducer, + }); + const { callbackUrl } = useSessionCallbackUrl(); + const { authDispatch, authState } = authReducer; + const { isAuthenticated } = authState; + useSessionActive(authState); + useSessionIdleTimer({ + disabled: !isAuthenticated, + onIdle: () => service.requestLogout({ callbackUrl }), + timeout, + }); + useSessionAuth({ authReducer, authenticationReducer }); + return ( + + + + + {children} + + + + + ); +} diff --git a/src/providers/googleSignInAuthentication/service/constants.ts b/src/providers/googleSignInAuthentication/service/constants.ts new file mode 100644 index 00000000..ea875ae6 --- /dev/null +++ b/src/providers/googleSignInAuthentication/service/constants.ts @@ -0,0 +1,16 @@ +import { GoogleIcon } from "../../../components/common/CustomIcon/components/GoogleIcon/googleIcon"; +import { OAuthProvider } from "../../../config/entities"; +import { GoogleProfile } from "../profile/types"; +import { mapProfile } from "../profile/utils"; + +export const GOOGLE_SIGN_IN_PROVIDER_ID = "google"; + +export const GOOGLE_SIGN_IN_PROVIDER: Pick< + OAuthProvider, + "icon" | "id" | "name" | "profile" +> = { + icon: GoogleIcon({}), + id: GOOGLE_SIGN_IN_PROVIDER_ID, + name: "Google", + profile: mapProfile, +}; diff --git a/src/providers/googleSignInAuthentication/service/service.ts b/src/providers/googleSignInAuthentication/service/service.ts new file mode 100644 index 00000000..9592644f --- /dev/null +++ b/src/providers/googleSignInAuthentication/service/service.ts @@ -0,0 +1,59 @@ +import { OAuthProvider } from "../../../config/entities"; +import { + requestAuthentication, + resetState as resetAuthenticationState, + updateAuthentication, +} from "../../authentication/authentication/dispatch"; +import { AUTHENTICATION_STATUS } from "../../authentication/authentication/types"; +import { fetchProfile } from "../../authentication/authentication/utils"; +import { resetState as resetCredentialsState } from "../../authentication/credentials/dispatch"; +import { getAuthenticationRequestOptions } from "../../authentication/terra/hooks/common/utils"; +import { + resetState as resetTokenState, + updateToken, +} from "../../authentication/token/dispatch"; +import { SessionDispatch } from "../common/types"; +import { GoogleProfile, TokenSetParameters } from "../profile/types"; + +// eslint-disable-next-line @typescript-eslint/no-explicit-any -- TODO see https://github.com/clevercanary/data-browser/issues/544. +declare const google: any; + +export const service = { + login: ( + provider: OAuthProvider, + dispatch: Pick + ): void => { + const client = google.accounts.oauth2.initTokenClient({ + callback: (response: TokenSetParameters) => { + const { id, profile, userinfo } = provider; + const { access_token: token } = response; + dispatch.authenticationDispatch?.(requestAuthentication()); + dispatch.tokenDispatch?.(updateToken({ providerId: id, token })); + fetchProfile(userinfo, getAuthenticationRequestOptions(token), { + onError: () => + dispatch.authenticationDispatch?.( + updateAuthentication({ + profile: undefined, + status: AUTHENTICATION_STATUS.SETTLED, + }) + ), + onSuccess: (r: GoogleProfile) => + dispatch.authenticationDispatch?.( + updateAuthentication({ + profile: profile(r), + status: AUTHENTICATION_STATUS.PENDING, // Authentication is pending until Terra profile status is resolved. + }) + ), + }); + }, + client_id: provider.clientId, + scope: provider.authorization.params.scope, + }); + client.requestAccessToken(); + }, + logout: (dispatch: SessionDispatch): void => { + dispatch.authenticationDispatch?.(resetAuthenticationState()); + dispatch.credentialsDispatch?.(resetCredentialsState()); + dispatch.tokenDispatch?.(resetTokenState()); + }, +}; diff --git a/src/providers/googleSignInAuthentication/types.ts b/src/providers/googleSignInAuthentication/types.ts new file mode 100644 index 00000000..bdf4a594 --- /dev/null +++ b/src/providers/googleSignInAuthentication/types.ts @@ -0,0 +1,7 @@ +import { ElementType, ReactNode } from "react"; + +export interface GoogleSignInAuthenticationProviderProps { + APIServicesProvider: ElementType; + children: ReactNode | ReactNode[]; + timeout?: number; +} diff --git a/src/providers/nextAuthAuthentication/hooks/useNextAuthService.ts b/src/providers/nextAuthAuthentication/hooks/useNextAuthService.ts new file mode 100644 index 00000000..7c5c2151 --- /dev/null +++ b/src/providers/nextAuthAuthentication/hooks/useNextAuthService.ts @@ -0,0 +1,26 @@ +import { useCallback } from "react"; +import { transformRoute } from "../../../hooks/authentication/session/useSessionActive"; +import { useRouteHistory } from "../../../hooks/useRouteHistory"; +import { Service } from "../../authentication/auth/types"; +import { ProviderId } from "../../authentication/common/types"; +import { service } from "../service/service"; + +export const useNextAuthService = (): Service => { + const { callbackUrl } = useRouteHistory(2); + + const onLogin = useCallback( + (providerId: ProviderId) => { + service.login(providerId, { callbackUrl: callbackUrl(transformRoute) }); + }, + [callbackUrl] + ); + + const onLogout = useCallback( + (options?: { callbackUrl?: string; redirect?: boolean }) => { + service.logout(options); + }, + [] + ); + + return { requestLogin: onLogin, requestLogout: onLogout }; +}; diff --git a/src/providers/nextAuthAuthentication/provider.tsx b/src/providers/nextAuthAuthentication/provider.tsx new file mode 100644 index 00000000..52b1e120 --- /dev/null +++ b/src/providers/nextAuthAuthentication/provider.tsx @@ -0,0 +1,44 @@ +import { SessionProvider } from "next-auth/react"; +import React from "react"; +import { SessionController } from "../../components/Authentication/components/SessionController/SessionController"; +import { useAuthReducer } from "../../hooks/authentication/auth/useAuthReducer"; +import { useAuthenticationReducer } from "../../hooks/authentication/authentication/useAuthenticationReducer"; +import { useSessionAuth } from "../../hooks/authentication/session/useSessionAuth"; +import { useSessionCallbackUrl } from "../../hooks/authentication/session/useSessionCallbackUrl"; +import { useSessionIdleTimer } from "../../hooks/authentication/session/useSessionIdleTimer"; +import { AuthContext } from "../authentication/auth/context"; +import { AuthenticationContext } from "../authentication/authentication/context"; +import { useNextAuthService } from "./hooks/useNextAuthService"; +import { NextAuthAuthenticationProviderProps } from "./types"; + +export function NextAuthAuthenticationProvider({ + children, + refetchInterval = 0, + session, + timeout, +}: NextAuthAuthenticationProviderProps): JSX.Element { + const authReducer = useAuthReducer(); + const authenticationReducer = useAuthenticationReducer(); + const service = useNextAuthService(); + const { authDispatch, authState } = authReducer; + const { isAuthenticated } = authState; + const { callbackUrl } = useSessionCallbackUrl(); + useSessionIdleTimer({ + crossTab: true, + disabled: !isAuthenticated, + onIdle: () => { + service.requestLogout({ callbackUrl, redirect: true }); + }, + timeout, + }); + useSessionAuth({ authReducer, authenticationReducer }); + return ( + + + + {children} + + + + ); +} diff --git a/src/providers/nextAuthAuthentication/service/service.ts b/src/providers/nextAuthAuthentication/service/service.ts new file mode 100644 index 00000000..419cbe66 --- /dev/null +++ b/src/providers/nextAuthAuthentication/service/service.ts @@ -0,0 +1,14 @@ +import { signIn, SignInOptions, signOut, SignOutParams } from "next-auth/react"; +import { ProviderId } from "../../authentication/common/types"; + +export const service = { + login: (providerId: ProviderId, options?: SignInOptions): void => { + signIn(providerId, options).catch((e) => console.error(e)); + }, + logout: (options?: SignOutParams): void => { + signOut({ + callbackUrl: options?.callbackUrl, + redirect: options?.redirect || false, + }).catch((e) => console.error(e)); + }, +}; diff --git a/src/providers/nextAuthAuthentication/types.ts b/src/providers/nextAuthAuthentication/types.ts new file mode 100644 index 00000000..02239de5 --- /dev/null +++ b/src/providers/nextAuthAuthentication/types.ts @@ -0,0 +1,9 @@ +import { Session } from "next-auth"; +import { ReactNode } from "react"; + +export interface NextAuthAuthenticationProviderProps { + children: ReactNode | ReactNode[]; + refetchInterval?: number; + session?: Session | null; + timeout?: number; +} diff --git a/src/routes/constants.ts b/src/routes/constants.ts new file mode 100644 index 00000000..5527a210 --- /dev/null +++ b/src/routes/constants.ts @@ -0,0 +1,3 @@ +export const ROUTE = { + LOGIN: "/login", +}; diff --git a/src/styles/common/mui/button.ts b/src/styles/common/mui/button.ts new file mode 100644 index 00000000..58595080 --- /dev/null +++ b/src/styles/common/mui/button.ts @@ -0,0 +1,20 @@ +import { ButtonProps } from "@mui/material"; + +export const COLOR: Record = { + ERROR: "error", + INFO: "info", + INHERIT: "inherit", + PRIMARY: "primary", + SECONDARY: "secondary", + SUCCESS: "success", + WARNING: "warning", +}; + +export const VARIANT: Record = { + ACTIVE_NAV: "activeNav", + BACK_NAV: "backNav", + CONTAINED: "contained", + NAV: "nav", + OUTLINED: "outlined", + TEXT: "text", +}; diff --git a/src/styles/common/mui/paper.ts b/src/styles/common/mui/paper.ts new file mode 100644 index 00000000..1eaaeac6 --- /dev/null +++ b/src/styles/common/mui/paper.ts @@ -0,0 +1,11 @@ +import { PaperProps } from "@mui/material"; + +export const VARIANT: Record = { + ELEVATION: "elevation", + FOOTER: "footer", + MENU: "menu", + OUTLINED: "outlined", + PANEL: "panel", + SEARCH_BAR: "searchbar", + TABLE: "table", +}; diff --git a/src/styles/common/mui/popover.ts b/src/styles/common/mui/popover.ts new file mode 100644 index 00000000..6f1364cf --- /dev/null +++ b/src/styles/common/mui/popover.ts @@ -0,0 +1,19 @@ +import { PopoverOrigin } from "@mui/material"; + +export const POPOVER_ORIGIN_HORIZONTAL: Record< + string, + PopoverOrigin["horizontal"] +> = { + CENTER: "center", + LEFT: "left", + RIGHT: "right", +}; + +export const POPOVER_ORIGIN_VERTICAL: Record< + string, + PopoverOrigin["vertical"] +> = { + BOTTOM: "bottom", + CENTER: "center", + TOP: "top", +}; diff --git a/src/theme/common/entities.ts b/src/theme/common/entities.ts new file mode 100644 index 00000000..f806b189 --- /dev/null +++ b/src/theme/common/entities.ts @@ -0,0 +1,7 @@ +export interface BaseComponentProps { + className?: string; +} + +export interface TrackingComponentProps { + trackingId?: string; +} diff --git a/src/views/LoginView/loginView.tsx b/src/views/LoginView/loginView.tsx index bcc8b6a1..434a8a5d 100644 --- a/src/views/LoginView/loginView.tsx +++ b/src/views/LoginView/loginView.tsx @@ -1,18 +1,24 @@ +import { ClientSafeProvider } from "next-auth/react"; import React from "react"; import { Login } from "../../components/Login/login"; -import { useAuthenticationConfig } from "../../hooks/useAuthenticationConfig"; +import { useAuthenticationConfig } from "../../hooks/authentication/config/useAuthenticationConfig"; -export const LoginView = (): JSX.Element => { - const { googleGISAuthConfig, termsOfService, text, title, warning } = - useAuthenticationConfig(); +export interface LoginViewProps { + providers?: ClientSafeProvider[]; +} +export const LoginView = ({ + providers, +}: LoginViewProps): JSX.Element | null => { + const authConfig = useAuthenticationConfig(); + if (!authConfig) return null; return ( ); }; diff --git a/tests/authentication.test.ts b/tests/authentication.test.ts deleted file mode 100644 index 358f9d63..00000000 --- a/tests/authentication.test.ts +++ /dev/null @@ -1,142 +0,0 @@ -import { jest } from "@jest/globals"; -import { LOGIN_STATUS_NOT_STARTED } from "../src/hooks/useAuthentication/common/constants"; -import { - LoginStatus, - REQUEST_STATUS, -} from "../src/hooks/useAuthentication/common/entities"; -import { GoogleResponse } from "../src/hooks/useAuthentication/useFetchGoogleProfile"; -import { TerraResponse } from "../src/hooks/useAuthentication/useFetchTerraProfile"; -import { TerraTermsOfServiceResponse } from "../src/hooks/useAuthentication/useFetchTerraTermsOfService"; - -jest.unstable_mockModule("react-idle-timer", () => ({ - useIdleTimer: jest.fn(), -})); - -const { shouldReleaseToken } = await import("../src/providers/authentication"); - -describe("authentication", () => { - // Boolean constants. - const IS_NOT_SUCCESS = false; - const IS_NOT_SUPPORTED = false; - const IS_SUCCESS = true; - const IS_SUPPORTED = true; - // Response objects. - const GOOGLE_RESPONSE = {} as GoogleResponse; - const TERRA_RESPONSE = {} as TerraResponse; - const TERRA_TOS_RESPONSE = {} as TerraTermsOfServiceResponse; - // Login statuses - not started, not supported. - const LOGIN_STATUS_NOT_STARTED_NOT_SUPPORTED_TERRA: LoginStatus = - { - isSuccess: IS_NOT_SUCCESS, - isSupported: IS_NOT_SUPPORTED, - requestStatus: REQUEST_STATUS.NOT_STARTED, - response: TERRA_RESPONSE, - }; - const LOGIN_STATUS_NOT_STARTED_NOT_SUPPORTED_TERRA_TOS: LoginStatus = - { - isSuccess: IS_NOT_SUCCESS, - isSupported: IS_NOT_SUPPORTED, - requestStatus: REQUEST_STATUS.NOT_STARTED, - response: TERRA_TOS_RESPONSE, - }; - // Login statuses - not started, supported. - const LOGIN_STATUS_NOT_STARTED_SUPPORTED_TERRA = - LOGIN_STATUS_NOT_STARTED as LoginStatus; - const LOGIN_STATUS_NOT_STARTED_SUPPORTED_TERRA_TOS = - LOGIN_STATUS_NOT_STARTED as LoginStatus; - const LOGIN_STATUS_NOT_STARTED_SUPPORTED_USER_PROFILE = - LOGIN_STATUS_NOT_STARTED as LoginStatus; - // Login statuses - completed, not successful. - const LOGIN_STATUS_COMPLETED_NOT_SUCCESS_TERRA_TOS: LoginStatus = - { - isSuccess: IS_NOT_SUCCESS, - isSupported: IS_SUPPORTED, - requestStatus: REQUEST_STATUS.COMPLETED, - response: TERRA_TOS_RESPONSE, - }; - const LOGIN_STATUS_COMPLETED_NOT_SUCCESS_USER_PROFILE: LoginStatus = - { - isSuccess: IS_NOT_SUCCESS, - isSupported: IS_SUPPORTED, - requestStatus: REQUEST_STATUS.COMPLETED, - response: GOOGLE_RESPONSE, - }; - // Login statuses - completed, successful. - const LOGIN_STATUS_COMPLETED_SUCCESS_TERRA: LoginStatus = { - isSuccess: IS_SUCCESS, - isSupported: IS_SUPPORTED, - requestStatus: REQUEST_STATUS.COMPLETED, - response: TERRA_RESPONSE, - }; - const LOGIN_STATUS_COMPLETED_SUCCESS_TERRA_TOS: LoginStatus = - { - isSuccess: IS_SUCCESS, - isSupported: IS_SUPPORTED, - requestStatus: REQUEST_STATUS.COMPLETED, - response: TERRA_TOS_RESPONSE, - }; - const LOGIN_STATUS_COMPLETED_SUCCESS_USER_PROFILE: LoginStatus = - { - isSuccess: IS_SUCCESS, - isSupported: IS_SUPPORTED, - requestStatus: REQUEST_STATUS.COMPLETED, - response: GOOGLE_RESPONSE, - }; - - describe("Should Release Token", () => { - describe("shouldReleaseToken", () => { - describe("Terra endpoint is configured", () => { - test("login not started", () => { - const releaseToken = shouldReleaseToken( - LOGIN_STATUS_NOT_STARTED_SUPPORTED_USER_PROFILE, - LOGIN_STATUS_NOT_STARTED_SUPPORTED_TERRA, - LOGIN_STATUS_NOT_STARTED_SUPPORTED_TERRA_TOS - ); - expect(releaseToken).toBeFalsy(); - }); - test("login completed and Terra terms of service is not successful", () => { - const releaseToken = shouldReleaseToken( - LOGIN_STATUS_COMPLETED_SUCCESS_USER_PROFILE, - LOGIN_STATUS_COMPLETED_SUCCESS_TERRA, - LOGIN_STATUS_COMPLETED_NOT_SUCCESS_TERRA_TOS - ); - expect(releaseToken).toBeFalsy(); - }); - test("login completed and Terra terms of service is successful", () => { - const releaseToken = shouldReleaseToken( - LOGIN_STATUS_COMPLETED_SUCCESS_USER_PROFILE, - LOGIN_STATUS_COMPLETED_SUCCESS_TERRA, - LOGIN_STATUS_COMPLETED_SUCCESS_TERRA_TOS - ); - expect(releaseToken).toBeTruthy(); - }); - }); - describe("Terra endpoint is not configured", () => { - test("login not started", () => { - const releaseToken = shouldReleaseToken( - LOGIN_STATUS_NOT_STARTED_SUPPORTED_USER_PROFILE, - LOGIN_STATUS_NOT_STARTED_NOT_SUPPORTED_TERRA, - LOGIN_STATUS_NOT_STARTED_NOT_SUPPORTED_TERRA_TOS - ); - expect(releaseToken).toBeFalsy(); - }); - test("user profile is not successful", () => { - const releaseToken = shouldReleaseToken( - LOGIN_STATUS_COMPLETED_NOT_SUCCESS_USER_PROFILE, - LOGIN_STATUS_NOT_STARTED_NOT_SUPPORTED_TERRA, - LOGIN_STATUS_NOT_STARTED_NOT_SUPPORTED_TERRA_TOS - ); - expect(releaseToken).toBeFalsy(); - }); - test("user profile is successful", () => { - const releaseToken = shouldReleaseToken( - LOGIN_STATUS_COMPLETED_SUCCESS_USER_PROFILE, - LOGIN_STATUS_NOT_STARTED_NOT_SUPPORTED_TERRA, - LOGIN_STATUS_NOT_STARTED_NOT_SUPPORTED_TERRA_TOS - ); - expect(releaseToken).toBeTruthy(); - }); - }); - }); - }); -}); diff --git a/tests/getProfileStatus.test.ts b/tests/getProfileStatus.test.ts new file mode 100644 index 00000000..3f08a651 --- /dev/null +++ b/tests/getProfileStatus.test.ts @@ -0,0 +1,290 @@ +import { + LoginStatus, + REQUEST_STATUS, +} from "../src/providers/authentication/terra/hooks/common/entities"; +import { TerraNIHResponse } from "../src/providers/authentication/terra/hooks/useFetchTerraNIHProfile"; +import { TerraResponse } from "../src/providers/authentication/terra/hooks/useFetchTerraProfile"; +import { TerraTermsOfServiceResponse } from "../src/providers/authentication/terra/hooks/useFetchTerraTermsOfService"; +import { TERRA_PROFILE_STATUS } from "../src/providers/authentication/terra/types"; +import { getProfileStatus } from "../src/providers/authentication/terra/utils"; + +const LOGIN_STATUS_NIH_COMPLETED: LoginStatus = { + isSuccess: true, + isSupported: true, + requestStatus: REQUEST_STATUS.COMPLETED, + response: undefined, +}; + +const LOGIN_STATUS_TERRA_COMPLETED: LoginStatus = { + isSuccess: true, + isSupported: true, + requestStatus: REQUEST_STATUS.COMPLETED, + response: undefined, +}; + +const LOGIN_STATUS_TOS_COMPLETED: LoginStatus = { + isSuccess: true, + isSupported: true, + requestStatus: REQUEST_STATUS.COMPLETED, + response: undefined, +}; + +const LOGIN_STATUS_TOS_COMPLETED_UNSUCCESSFUL: LoginStatus = + { + isSuccess: false, + isSupported: true, + requestStatus: REQUEST_STATUS.COMPLETED, + response: undefined, + }; + +const LOGIN_STATUS_NIH_NOT_STARTED: LoginStatus = { + isSuccess: false, + isSupported: true, + requestStatus: REQUEST_STATUS.NOT_STARTED, + response: undefined, +}; + +const LOGIN_STATUS_TERRA_NOT_STARTED: LoginStatus = { + isSuccess: false, + isSupported: true, + requestStatus: REQUEST_STATUS.NOT_STARTED, + response: undefined, +}; + +const LOGIN_STATUS_TOS_NOT_STARTED: LoginStatus = { + isSuccess: false, + isSupported: true, + requestStatus: REQUEST_STATUS.NOT_STARTED, + response: undefined, +}; + +const LOGIN_STATUS_NIH_PENDING: LoginStatus = { + isSuccess: false, + isSupported: true, + requestStatus: REQUEST_STATUS.PENDING, + response: undefined, +}; + +const LOGIN_STATUS_TERRA_PENDING: LoginStatus = { + isSuccess: false, + isSupported: true, + requestStatus: REQUEST_STATUS.PENDING, + response: undefined, +}; + +const LOGIN_STATUS_TOS_PENDING: LoginStatus = { + isSuccess: false, + isSupported: true, + requestStatus: REQUEST_STATUS.PENDING, + response: undefined, +}; + +const LOGIN_STATUS_NIH_UNSUPPORTED: LoginStatus = { + isSuccess: false, + isSupported: false, + requestStatus: REQUEST_STATUS.NOT_STARTED, + response: undefined, +}; + +const LOGIN_STATUS_TERRA_UNSUPPORTED: LoginStatus = { + isSuccess: false, + isSupported: false, + requestStatus: REQUEST_STATUS.NOT_STARTED, + response: undefined, +}; + +const LOGIN_STATUS_TOS_UNSUPPORTED: LoginStatus = { + isSuccess: false, + isSupported: false, + requestStatus: REQUEST_STATUS.NOT_STARTED, + response: undefined, +}; + +describe("getProfileStatus", () => { + test("not authenticated, services not started", () => { + expect( + getProfileStatus( + false, + LOGIN_STATUS_NIH_NOT_STARTED, + LOGIN_STATUS_TERRA_NOT_STARTED, + LOGIN_STATUS_TOS_NOT_STARTED + ) + ).toBe(TERRA_PROFILE_STATUS.PENDING); + }); + + test("not authenticated, services unsupported", () => { + expect( + getProfileStatus( + false, + LOGIN_STATUS_NIH_UNSUPPORTED, + LOGIN_STATUS_TERRA_UNSUPPORTED, + LOGIN_STATUS_TOS_UNSUPPORTED + ) + ).toBe(TERRA_PROFILE_STATUS.PENDING); + }); + + test("not authenticated, services completed", () => { + expect( + getProfileStatus( + false, + LOGIN_STATUS_NIH_COMPLETED, + LOGIN_STATUS_TERRA_COMPLETED, + LOGIN_STATUS_TOS_COMPLETED + ) + ).toBe(TERRA_PROFILE_STATUS.PENDING); + }); + + test("authenticated, services not started", () => { + expect( + getProfileStatus( + true, + LOGIN_STATUS_NIH_NOT_STARTED, + LOGIN_STATUS_TERRA_NOT_STARTED, + LOGIN_STATUS_TOS_NOT_STARTED + ) + ).toBe(TERRA_PROFILE_STATUS.PENDING); + }); + + test("authenticated, services pending", () => { + expect( + getProfileStatus( + true, + LOGIN_STATUS_NIH_PENDING, + LOGIN_STATUS_TERRA_PENDING, + LOGIN_STATUS_TOS_PENDING + ) + ).toBe(TERRA_PROFILE_STATUS.PENDING); + }); + + test("authenticated, nih pending, other services completed", () => { + expect( + getProfileStatus( + true, + LOGIN_STATUS_NIH_PENDING, + LOGIN_STATUS_TERRA_COMPLETED, + LOGIN_STATUS_TOS_COMPLETED + ) + ).toBe(TERRA_PROFILE_STATUS.PENDING); + }); + + test("authenticated, terra pending, other services completed", () => { + expect( + getProfileStatus( + true, + LOGIN_STATUS_NIH_COMPLETED, + LOGIN_STATUS_TERRA_PENDING, + LOGIN_STATUS_TOS_COMPLETED + ) + ).toBe(TERRA_PROFILE_STATUS.PENDING); + }); + + test("authenticated, tos pending, other services completed", () => { + expect( + getProfileStatus( + true, + LOGIN_STATUS_NIH_COMPLETED, + LOGIN_STATUS_TERRA_COMPLETED, + LOGIN_STATUS_TOS_PENDING + ) + ).toBe(TERRA_PROFILE_STATUS.PENDING); + }); + + test("authenticated, nih completed, terra pending, tos not started", () => { + expect( + getProfileStatus( + true, + LOGIN_STATUS_NIH_COMPLETED, + LOGIN_STATUS_TERRA_PENDING, + LOGIN_STATUS_TOS_NOT_STARTED + ) + ).toBe(TERRA_PROFILE_STATUS.PENDING); + }); + + test("authenticated, nih not started, terra completed, tos pending", () => { + expect( + getProfileStatus( + true, + LOGIN_STATUS_NIH_NOT_STARTED, + LOGIN_STATUS_TERRA_COMPLETED, + LOGIN_STATUS_TOS_PENDING + ) + ).toBe(TERRA_PROFILE_STATUS.PENDING); + }); + + test("authenticated, nih pending, terra not started, tos completed", () => { + expect( + getProfileStatus( + true, + LOGIN_STATUS_NIH_PENDING, + LOGIN_STATUS_TERRA_NOT_STARTED, + LOGIN_STATUS_TOS_COMPLETED + ) + ).toBe(TERRA_PROFILE_STATUS.PENDING); + }); + + test("authenticated, services unsupported", () => { + expect( + getProfileStatus( + true, + LOGIN_STATUS_NIH_UNSUPPORTED, + LOGIN_STATUS_TERRA_UNSUPPORTED, + LOGIN_STATUS_TOS_UNSUPPORTED + ) + ).toBe(TERRA_PROFILE_STATUS.AUTHENTICATED); + }); + + test("authenticated, services completed, tos unsuccessful", () => { + expect( + getProfileStatus( + true, + LOGIN_STATUS_NIH_COMPLETED, + LOGIN_STATUS_TERRA_COMPLETED, + LOGIN_STATUS_TOS_COMPLETED_UNSUCCESSFUL + ) + ).toBe(TERRA_PROFILE_STATUS.UNAUTHENTICATED); + }); + + test("authenticated, nih unsupported, terra completed, tos completed unsuccessfully", () => { + expect( + getProfileStatus( + true, + LOGIN_STATUS_NIH_UNSUPPORTED, + LOGIN_STATUS_TERRA_COMPLETED, + LOGIN_STATUS_TOS_COMPLETED_UNSUCCESSFUL + ) + ).toBe(TERRA_PROFILE_STATUS.UNAUTHENTICATED); + }); + + test("authenticated, nih unsupported, other services completed", () => { + expect( + getProfileStatus( + true, + LOGIN_STATUS_NIH_UNSUPPORTED, + LOGIN_STATUS_TERRA_COMPLETED, + LOGIN_STATUS_TOS_COMPLETED + ) + ).toBe(TERRA_PROFILE_STATUS.AUTHENTICATED); + }); + + test("authenticated, terra completed, other services unsupported", () => { + expect( + getProfileStatus( + true, + LOGIN_STATUS_NIH_UNSUPPORTED, + LOGIN_STATUS_TERRA_COMPLETED, + LOGIN_STATUS_TOS_UNSUPPORTED + ) + ).toBe(TERRA_PROFILE_STATUS.UNAUTHENTICATED); + }); + + test("authenticated, services completed", () => { + expect( + getProfileStatus( + true, + LOGIN_STATUS_NIH_COMPLETED, + LOGIN_STATUS_TERRA_COMPLETED, + LOGIN_STATUS_TOS_COMPLETED + ) + ).toBe(TERRA_PROFILE_STATUS.AUTHENTICATED); + }); +}); diff --git a/tests/terraProfileProvider.test.tsx b/tests/terraProfileProvider.test.tsx new file mode 100644 index 00000000..10a3ad13 --- /dev/null +++ b/tests/terraProfileProvider.test.tsx @@ -0,0 +1,121 @@ +import { jest } from "@jest/globals"; +import { render } from "@testing-library/react"; +import React from "react"; +import { DEFAULT_AUTHENTICATION_STATE } from "../src/providers/authentication/authentication/constants"; +import { authenticationComplete } from "../src/providers/authentication/authentication/dispatch"; +import { DEFAULT_CREDENTIALS_STATE } from "../src/providers/authentication/credentials/constants"; +import { updateCredentials } from "../src/providers/authentication/credentials/dispatch"; + +const TOKEN = "test-token"; + +const PROFILE_PENDING = { + isComplete: false, + isProfileActive: false, +}; + +const PROFILE_SETTLED_ACTIVE = { + isComplete: true, + isProfileActive: true, +}; + +const PROFILE_SETTLED_INACTIVE = { + isComplete: true, + isProfileActive: false, +}; + +jest.unstable_mockModule( + "../src/providers/authentication/authentication/hook", + () => ({ + useAuthentication: jest.fn(), + }) +); +jest.unstable_mockModule( + "../src/providers/authentication/credentials/hook", + () => ({ + useCredentials: jest.fn(), + }) +); +jest.unstable_mockModule( + "../src/providers/authentication/terra/hooks/useFetchProfiles", + () => ({ + useFetchProfiles: jest.fn(), + }) +); + +const { useAuthentication } = await import( + "../src/providers/authentication/authentication/hook" +); +const { useCredentials } = await import( + "../src/providers/authentication/credentials/hook" +); +const { useFetchProfiles } = await import( + "../src/providers/authentication/terra/hooks/useFetchProfiles" +); +const { TerraProfileProvider } = await import( + "../src/providers/authentication/terra/provider" +); + +const MOCK_AUTHENTICATION_DISPATCH = jest.fn(); +const MOCK_CREDENTIALS_DISPATCH = jest.fn(); +const MOCK_USE_AUTHENTICATION = useAuthentication as jest.MockedFunction< + typeof useAuthentication +>; +const MOCK_USE_CREDENTIALS = useCredentials as jest.MockedFunction< + typeof useCredentials +>; +const MOCK_USE_FETCH_PROFILES = useFetchProfiles as jest.MockedFunction< + () => Partial> +>; + +describe("TerraProfileProvider", () => { + beforeEach(() => { + jest.clearAllMocks(); + MOCK_USE_AUTHENTICATION.mockReturnValue({ + authenticationDispatch: MOCK_AUTHENTICATION_DISPATCH, + authenticationState: DEFAULT_AUTHENTICATION_STATE, + }); + MOCK_USE_CREDENTIALS.mockReturnValue({ + credentialsDispatch: MOCK_CREDENTIALS_DISPATCH, + credentialsState: DEFAULT_CREDENTIALS_STATE, + }); + MOCK_USE_FETCH_PROFILES.mockReturnValue(PROFILE_PENDING); + }); + + it("does not dispatch actions when terra profile is incomplete", () => { + render( + +

Child Component
+ + ); + expect(MOCK_AUTHENTICATION_DISPATCH).not.toHaveBeenCalled(); + expect(MOCK_CREDENTIALS_DISPATCH).not.toHaveBeenCalled(); + }); + + it("calls authenticationComplete dispatch when terra profile is SETTLED and INACTIVE", () => { + MOCK_USE_FETCH_PROFILES.mockReturnValue(PROFILE_SETTLED_INACTIVE); + render( + +
Child Component
+
+ ); + expect(MOCK_AUTHENTICATION_DISPATCH).toHaveBeenCalledWith( + authenticationComplete() + ); + expect(MOCK_CREDENTIALS_DISPATCH).not.toHaveBeenCalled(); + }); + + it("dispatches authenticationComplete and updateCredentials when terra profile is SETTLED and ACTIVE", () => { + MOCK_USE_FETCH_PROFILES.mockReturnValue(PROFILE_SETTLED_ACTIVE); + render( + +
Child Component
+
+ ); + expect(MOCK_AUTHENTICATION_DISPATCH).toHaveBeenCalledWith( + authenticationComplete() + ); + expect(MOCK_CREDENTIALS_DISPATCH).toHaveBeenCalledWith( + updateCredentials(TOKEN) + ); + }); +}); diff --git a/tests/transformRoute.test.ts b/tests/transformRoute.test.ts new file mode 100644 index 00000000..36f61dd0 --- /dev/null +++ b/tests/transformRoute.test.ts @@ -0,0 +1,21 @@ +import { transformRoute } from "../src/hooks/authentication/session/useSessionActive"; + +describe("transformRoute", () => { + it("should return the first non-login route without the inactivity param", () => { + const routes = ["/login", "/route1?inactivityTimeout=true", "/route2"]; + const result = transformRoute(routes); + expect(result).toBe("/route1"); + }); + + it("should remove the inactivity param from the route", () => { + const routes = ["/route1?inactivityTimeout=true"]; + const result = transformRoute(routes); + expect(result).toBe("/route1"); + }); + + it("should return undefined if all routes are login routes", () => { + const routes = ["/login"]; + const result = transformRoute(routes); + expect(result).toBeUndefined(); + }); +}); diff --git a/tests/useAuthenticationStatus.test.ts b/tests/useAuthenticationStatus.test.ts deleted file mode 100644 index 74df4366..00000000 --- a/tests/useAuthenticationStatus.test.ts +++ /dev/null @@ -1,155 +0,0 @@ -import { - LOGIN_STATUS_NOT_STARTED, - LOGIN_STATUS_NOT_SUPPORTED, -} from "../src/hooks/useAuthentication/common/constants"; -import { - AUTHENTICATION_STATUS, - LoginResponse, - LoginStatus, - REQUEST_STATUS, -} from "../src/hooks/useAuthentication/common/entities"; -import { getAuthenticationStatus } from "../src/hooks/useAuthentication/useAuthenticationStatus"; - -const TEST_LOGIN_IS_COMPLETE = "login is complete"; -const TEST_LOGIN_NOT_STARTED = "login not started"; -const TEST_USER_PROFILE_AND_TERRA_AND_TOS_IS_COMPLETE = - "user profile, terra and terms of service is complete"; -const TEST_USER_PROFILE_AND_TERRA_IS_COMPLETE = - "user profile and terra is complete"; -const TEST_USER_PROFILE_IS_COMPLETE = "user profile is complete"; - -describe("useAuthenticationStatus", () => { - // Login statuses - completed, successful. - const LOGIN_STATUS_COMPLETED_SUCCESS: LoginStatus = { - isSuccess: true, - isSupported: true, - requestStatus: REQUEST_STATUS.COMPLETED, - response: {} as LoginResponse, - }; - - describe("Calculate Authentication Status", () => { - describe("getAuthenticationStatus", () => { - describe("endpoints are configured", () => { - test(TEST_LOGIN_NOT_STARTED, () => { - const authenticationStatus = getAuthenticationStatus([ - LOGIN_STATUS_NOT_STARTED, - LOGIN_STATUS_NOT_STARTED, - LOGIN_STATUS_NOT_STARTED, - LOGIN_STATUS_NOT_STARTED, - ]); - expect(authenticationStatus).toEqual( - AUTHENTICATION_STATUS.INCOMPLETE - ); - }); - test(TEST_USER_PROFILE_IS_COMPLETE, () => { - const authenticationStatus = getAuthenticationStatus([ - LOGIN_STATUS_NOT_STARTED, - LOGIN_STATUS_NOT_STARTED, - LOGIN_STATUS_NOT_STARTED, - LOGIN_STATUS_COMPLETED_SUCCESS, - ]); - expect(authenticationStatus).toEqual( - AUTHENTICATION_STATUS.INCOMPLETE - ); - }); - test(TEST_USER_PROFILE_AND_TERRA_IS_COMPLETE, () => { - const authenticationStatus = getAuthenticationStatus([ - LOGIN_STATUS_NOT_STARTED, - LOGIN_STATUS_COMPLETED_SUCCESS, - LOGIN_STATUS_NOT_STARTED, - LOGIN_STATUS_COMPLETED_SUCCESS, - ]); - expect(authenticationStatus).toEqual( - AUTHENTICATION_STATUS.INCOMPLETE - ); - }); - test(TEST_USER_PROFILE_AND_TERRA_AND_TOS_IS_COMPLETE, () => { - const authenticationStatus = getAuthenticationStatus([ - LOGIN_STATUS_NOT_STARTED, - LOGIN_STATUS_COMPLETED_SUCCESS, - LOGIN_STATUS_COMPLETED_SUCCESS, - LOGIN_STATUS_COMPLETED_SUCCESS, - ]); - expect(authenticationStatus).toEqual( - AUTHENTICATION_STATUS.INCOMPLETE - ); - }); - test(TEST_LOGIN_IS_COMPLETE, () => { - const authenticationStatus = getAuthenticationStatus([ - LOGIN_STATUS_COMPLETED_SUCCESS, - LOGIN_STATUS_COMPLETED_SUCCESS, - LOGIN_STATUS_COMPLETED_SUCCESS, - LOGIN_STATUS_COMPLETED_SUCCESS, - ]); - expect(authenticationStatus).toEqual(AUTHENTICATION_STATUS.COMPLETED); - }); - }); - describe("NIH endpoint is not configured", () => { - test(TEST_LOGIN_NOT_STARTED, () => { - const authenticationStatus = getAuthenticationStatus([ - LOGIN_STATUS_NOT_SUPPORTED, - LOGIN_STATUS_NOT_STARTED, - LOGIN_STATUS_NOT_STARTED, - LOGIN_STATUS_NOT_STARTED, - ]); - expect(authenticationStatus).toEqual( - AUTHENTICATION_STATUS.INCOMPLETE - ); - }); - test(TEST_USER_PROFILE_IS_COMPLETE, () => { - const authenticationStatus = getAuthenticationStatus([ - LOGIN_STATUS_NOT_SUPPORTED, - LOGIN_STATUS_NOT_STARTED, - LOGIN_STATUS_NOT_STARTED, - LOGIN_STATUS_COMPLETED_SUCCESS, - ]); - expect(authenticationStatus).toEqual( - AUTHENTICATION_STATUS.INCOMPLETE - ); - }); - test(TEST_USER_PROFILE_AND_TERRA_IS_COMPLETE, () => { - const authenticationStatus = getAuthenticationStatus([ - LOGIN_STATUS_NOT_SUPPORTED, - LOGIN_STATUS_COMPLETED_SUCCESS, - LOGIN_STATUS_NOT_STARTED, - LOGIN_STATUS_COMPLETED_SUCCESS, - ]); - expect(authenticationStatus).toEqual( - AUTHENTICATION_STATUS.INCOMPLETE - ); - }); - test(TEST_LOGIN_IS_COMPLETE, () => { - const authenticationStatus = getAuthenticationStatus([ - LOGIN_STATUS_NOT_SUPPORTED, - LOGIN_STATUS_COMPLETED_SUCCESS, - LOGIN_STATUS_COMPLETED_SUCCESS, - LOGIN_STATUS_COMPLETED_SUCCESS, - ]); - expect(authenticationStatus).toEqual(AUTHENTICATION_STATUS.COMPLETED); - }); - }); - describe("Terra endpoint is not configured", () => { - test(TEST_LOGIN_NOT_STARTED, () => { - const authenticationStatus = getAuthenticationStatus([ - LOGIN_STATUS_NOT_SUPPORTED, - LOGIN_STATUS_NOT_SUPPORTED, - LOGIN_STATUS_NOT_SUPPORTED, - LOGIN_STATUS_NOT_STARTED, - ]); - expect(authenticationStatus).toEqual( - AUTHENTICATION_STATUS.INCOMPLETE - ); - }); - test(TEST_LOGIN_IS_COMPLETE, () => { - const authenticationStatus = getAuthenticationStatus([ - LOGIN_STATUS_NOT_SUPPORTED, - LOGIN_STATUS_NOT_SUPPORTED, - LOGIN_STATUS_NOT_SUPPORTED, - LOGIN_STATUS_COMPLETED_SUCCESS, - ]); - expect(authenticationStatus).toEqual(AUTHENTICATION_STATUS.COMPLETED); - }); - }); - }); - }); -}); diff --git a/tests/useRouteHistory.test.ts b/tests/useRouteHistory.test.ts new file mode 100644 index 00000000..ad81f177 --- /dev/null +++ b/tests/useRouteHistory.test.ts @@ -0,0 +1,97 @@ +import { jest } from "@jest/globals"; +import { act, renderHook } from "@testing-library/react"; +import Router, { NextRouter } from "next/router"; + +const ROOT_PATH = "/"; +const ROUTES = ["/route1", "/route2", "/route3", "/route4"]; + +jest.unstable_mockModule("next/router", () => { + return { + ...jest.requireActual("next/router"), + useRouter: jest.fn(), + }; +}); +jest.unstable_mockModule("../src/hooks/useRouteRoot", () => ({ + useRouteRoot: jest.fn(), +})); + +const { useRouter } = await import("next/router"); +const { useRouteRoot } = await import("../src/hooks/useRouteRoot"); +const { useRouteHistory } = await import("../src/hooks/useRouteHistory"); + +const MOCK_USE_ROUTER = useRouter as jest.MockedFunction< + () => Partial +>; +const MOCK_USE_ROUTE_ROOT = useRouteRoot as jest.MockedFunction< + typeof useRouteRoot +>; + +describe("useRouteHistory", () => { + beforeEach(() => { + MOCK_USE_ROUTE_ROOT.mockReset(); + MOCK_USE_ROUTER.mockReset(); + MOCK_USE_ROUTER.mockReturnValue({ + asPath: ROUTES[0], + }); + MOCK_USE_ROUTE_ROOT.mockReturnValue(ROOT_PATH); + }); + + test("returns the root path when no previous route exists", () => { + const { result } = renderHook(() => useRouteHistory()); + expect(result.current.callbackUrl()).toBe(ROOT_PATH); + }); + + test("updates history on route change", () => { + const { result } = renderHook(() => useRouteHistory()); + act(() => { + Router.events.emit("routeChangeComplete", ROUTES[1]); + Router.events.emit("routeChangeComplete", ROUTES[2]); + }); + expect(result.current.callbackUrl()).toBe(ROUTES[1]); + }); + + test("does not add duplicate routes to history", () => { + const { result } = renderHook(() => useRouteHistory()); + act(() => { + Router.events.emit("routeChangeComplete", ROUTES[2]); + Router.events.emit("routeChangeComplete", ROUTES[2]); + }); + expect(result.current.callbackUrl()).toBe(ROUTES[0]); + }); + + test("limits history length to maxHistory", () => { + const { result } = renderHook(() => useRouteHistory(2)); + act(() => { + Router.events.emit("routeChangeComplete", ROUTES[1]); + Router.events.emit("routeChangeComplete", ROUTES[2]); + Router.events.emit("routeChangeComplete", ROUTES[3]); + }); + // Use `callbackUrl` with a transform function to capture the full history. + let history; + result.current.callbackUrl((routes) => { + history = routes; + return ROOT_PATH; + }); + // After emitting the routes, the history stack is [ROUTES[3], ROUTES[2]. + expect(history).toHaveLength(2); + expect(history).toEqual([ROUTES[3], ROUTES[2]]); + }); + + test("uses transform function if provided", () => { + const { result } = renderHook(() => useRouteHistory(4)); + act(() => { + Router.events.emit("routeChangeComplete", ROUTES[1]); + Router.events.emit("routeChangeComplete", ROUTES[2]); + Router.events.emit("routeChangeComplete", ROUTES[3]); + }); + // After emitting the routes, the history stack is [ROUTES[3], ROUTES[2], ROUTES[1], ROUTES[0]]. + const transformFn = (routes: string[]): string => routes[2]; + expect(result.current.callbackUrl(transformFn)).toBe(ROUTES[1]); + }); + + test("returns root path when transform function is provided but history stack lacks sufficient entries", () => { + const { result } = renderHook(() => useRouteHistory()); + const transformFn = (routes: string[]): string => routes[2]; + expect(result.current.callbackUrl(transformFn)).toBe(ROOT_PATH); + }); +}); diff --git a/tests/useSessionActive.test.ts b/tests/useSessionActive.test.ts new file mode 100644 index 00000000..606bc190 --- /dev/null +++ b/tests/useSessionActive.test.ts @@ -0,0 +1,74 @@ +import { jest } from "@jest/globals"; +import { renderHook } from "@testing-library/react"; +import { TransformRouteFn } from "../src/hooks/useRouteHistory"; +import { + AUTH_STATUS, + AuthState, +} from "../src/providers/authentication/auth/types"; + +const AUTH_STATE_AUTHENTICATED_SETTLED: AuthState = { + isAuthenticated: true, + status: AUTH_STATUS.SETTLED, +}; + +const AUTH_STATE_PENDING: AuthState = { + isAuthenticated: false, + status: AUTH_STATUS.PENDING, +}; + +const AUTH_STATE_UNAUTHENTICATED_SETTLED: AuthState = { + isAuthenticated: false, + status: AUTH_STATUS.SETTLED, +}; + +const ROOT_PATH = "/"; +const ROUTES = ["/login", "/route1", "/route2"]; + +jest.unstable_mockModule("next/router", () => { + return { + ...jest.requireActual("next/router"), + default: { + push: jest.fn(), + }, + }; +}); +jest.unstable_mockModule("../src/hooks/useRouteHistory", () => ({ + useRouteHistory: jest.fn(), +})); + +const Router = (await import("next/router")).default; +const { useRouteHistory } = await import("../src/hooks/useRouteHistory"); +const { useSessionActive } = await import( + "../src/hooks/authentication/session/useSessionActive" +); + +const MOCK_USE_ROUTE_HISTORY = useRouteHistory as jest.MockedFunction< + typeof useRouteHistory +>; + +describe("useSessionActive", () => { + beforeEach(() => { + MOCK_USE_ROUTE_HISTORY.mockReset(); + MOCK_USE_ROUTE_HISTORY.mockReturnValue({ + callbackUrl: jest.fn( + (transformFn?: TransformRouteFn | undefined) => + transformFn?.(ROUTES) ?? ROOT_PATH + ), + }); + }); + + test("does not redirect if auth status is PENDING", () => { + renderHook(() => useSessionActive(AUTH_STATE_PENDING)); + expect(Router.push).not.toHaveBeenCalled(); + }); + + test("redirects if auth status is SETTLED", () => { + renderHook(() => useSessionActive(AUTH_STATE_UNAUTHENTICATED_SETTLED)); + expect(Router.push).toHaveBeenCalled(); + }); + + test("redirects to callback URL if auth status is SETTLED", () => { + renderHook(() => useSessionActive(AUTH_STATE_AUTHENTICATED_SETTLED)); + expect(Router.push).toHaveBeenCalledWith(ROUTES[1]); + }); +}); diff --git a/tests/useSessionAuth.test.ts b/tests/useSessionAuth.test.ts new file mode 100644 index 00000000..bae48ca9 --- /dev/null +++ b/tests/useSessionAuth.test.ts @@ -0,0 +1,91 @@ +import { renderHook } from "@testing-library/react"; +import { useAuthReducer } from "../src/hooks/authentication/auth/useAuthReducer"; +import { useAuthenticationReducer } from "../src/hooks/authentication/authentication/useAuthenticationReducer"; +import { useSessionAuth } from "../src/hooks/authentication/session/useSessionAuth"; +import { + AUTH_STATUS, + AuthState, +} from "../src/providers/authentication/auth/types"; +import { + AUTHENTICATION_STATUS, + AuthenticationState, +} from "../src/providers/authentication/authentication/types"; + +describe("useSessionAuth", () => { + test("auth status SETTLED with no profile", async () => { + testAuthenticationState( + { + profile: undefined, + status: AUTHENTICATION_STATUS.SETTLED, + }, + { + isAuthenticated: false, + status: AUTH_STATUS.SETTLED, + } + ); + }); + + test("auth status PENDING with no profile", async () => { + testAuthenticationState( + { + profile: undefined, + status: AUTHENTICATION_STATUS.PENDING, + }, + { + isAuthenticated: false, + status: AUTH_STATUS.PENDING, + } + ); + }); + + test("auth status PENDING with profile", async () => { + testAuthenticationState( + { + profile: { + email: "test@example.com", + name: "Test", + }, + status: AUTHENTICATION_STATUS.PENDING, + }, + { + isAuthenticated: false, + status: AUTH_STATUS.PENDING, + } + ); + }); + + test("auth status SETTLED with profile", async () => { + testAuthenticationState( + { + profile: { + email: "test@example.com", + name: "Test", + }, + status: AUTHENTICATION_STATUS.SETTLED, + }, + { + isAuthenticated: true, + status: AUTH_STATUS.SETTLED, + } + ); + }); +}); + +function testAuthenticationState( + authenticationState: AuthenticationState, + expectedAuthState: AuthState +): void { + const { result: authenticationReducerResult } = renderHook(() => + useAuthenticationReducer(authenticationState) + ); + const { result: authReducerResult } = renderHook(() => useAuthReducer()); + + renderHook(() => + useSessionAuth({ + authReducer: authReducerResult.current, + authenticationReducer: authenticationReducerResult.current, + }) + ); + + expect(authReducerResult.current.authState).toMatchObject(expectedAuthState); +}