diff --git a/.env b/.env index bdf7fe1..384242f 100644 --- a/.env +++ b/.env @@ -3,4 +3,7 @@ APP_DESCRIPTION=GFTS point cloud #MAPBOX_TOKEN= -#DATA_API='' \ No newline at end of file +#DATA_API='' +REACT_APP_KEYCLOAK_URL +REACT_APP_KEYCLOAK_REALM +REACT_APP_KEYCLOAK_CLIENT_ID \ No newline at end of file diff --git a/.yarn/install-state.gz b/.yarn/install-state.gz index 59f58e7..70818de 100644 Binary files a/.yarn/install-state.gz and b/.yarn/install-state.gz differ diff --git a/app/components/auth/context.tsx b/app/components/auth/context.tsx new file mode 100644 index 0000000..2b90c0b --- /dev/null +++ b/app/components/auth/context.tsx @@ -0,0 +1,118 @@ +import React, { + createContext, + useContext, + useEffect, + useRef, + useState +} from 'react'; +import Keycloak from 'keycloak-js'; + +const url = process.env.REACT_APP_KEYCLOAK_URL; +const realm = process.env.REACT_APP_KEYCLOAK_REALM; +const clientId = process.env.REACT_APP_KEYCLOAK_CLIENT_ID; + +const isAuthEnabled = !!(url && realm && clientId); + +const keycloak: Keycloak | undefined = isAuthEnabled + ? new (Keycloak as any)({ + url, + realm, + clientId + }) + : undefined; + +export type KeycloakContextProps = { + initStatus: 'loading' | 'success' | 'error'; + isLoading: boolean; + profile?: Keycloak.KeycloakProfile; +} & ( + | { + keycloak: Keycloak; + isEnabled: true; + } + | { + keycloak: undefined; + isEnabled: false; + } +); + +const KeycloakContext = createContext({ + initStatus: 'loading', + isEnabled: isAuthEnabled +} as KeycloakContextProps); + +export const KeycloakProvider = (props: { children: React.ReactNode }) => { + const [initStatus, setInitStatus] = + useState('loading'); + const [profile, setProfile] = useState< + Keycloak.KeycloakProfile | undefined + >(); + + const wasInit = useRef(false); + + useEffect(() => { + async function initialize() { + if (!keycloak) return; + // Keycloak can only be initialized once. This is a workaround to avoid + // multiple initialization attempts, specially by React double rendering. + if (wasInit.current) return; + wasInit.current = true; + + try { + await keycloak.init({ + // onLoad: 'login-required', + onLoad: 'check-sso', + checkLoginIframe: false + }); + if (keycloak.authenticated) { + const profile = + await (keycloak.loadUserProfile() as unknown as Promise); + setProfile(profile); + } + + setInitStatus('success'); + } catch (err) { + setInitStatus('error'); + // eslint-disable-next-line no-console + console.error('Failed to initialize keycloak adapter:', err); + } + } + initialize(); + }, []); + + const base = { + initStatus, + isLoading: isAuthEnabled && initStatus === 'loading', + profile + }; + + return ( + + {props.children} + + ); +}; + +export const useKeycloak = () => { + const ctx = useContext(KeycloakContext); + + if (!ctx) { + throw new Error('useKeycloak must be used within a KeycloakProvider'); + } + + return ctx; +}; diff --git a/app/components/auth/userInfo.tsx b/app/components/auth/userInfo.tsx new file mode 100644 index 0000000..4f5d016 --- /dev/null +++ b/app/components/auth/userInfo.tsx @@ -0,0 +1,82 @@ +import React, { useEffect, useState } from 'react'; +import { Avatar, IconButton, Tooltip } from '@chakra-ui/react'; +import { CollecticonLogin } from '@devseed-ui/collecticons-chakra'; + +import { useKeycloak } from './context'; +import SmartLink from '$components/common/smart-link'; + +async function hash(string: string) { + const utf8 = new TextEncoder().encode(string); + const hashBuffer = await crypto.subtle.digest('SHA-256', utf8); + const hashArray = Array.from(new Uint8Array(hashBuffer)); + const hashHex = hashArray + .map((bytes) => bytes.toString(16).padStart(2, '0')) + .join(''); + return hashHex; +} + +export function UserInfo() { + const { profile, isLoading, isEnabled, keycloak } = useKeycloak(); + + const [userEmailHash, setUserEmailHash] = useState(''); + useEffect(() => { + if (profile?.email) { + hash(profile.email).then(setUserEmailHash); + } + }, [profile?.email]); + + if (!isEnabled) { + return null; + } + + const isAuthenticated = keycloak.authenticated; + + if (!isAuthenticated || !profile || isLoading) { + return ( + + } + onClick={() => { + if (!isLoading) { + keycloak.login({ + redirectUri: window.location.href + }); + } + }} + /> + + ); + } + + const username = + `${profile.firstName} ${profile.lastName}`.trim() || profile.username; + + return ( + + { + e.preventDefault(); + keycloak.logout({ + redirectUri: window.location.href + }); + }} + > + + + + ); +} diff --git a/app/components/common/page-layout.tsx b/app/components/common/page-layout.tsx index 361b744..4f253d1 100644 --- a/app/components/common/page-layout.tsx +++ b/app/components/common/page-layout.tsx @@ -19,12 +19,15 @@ import { Link, LinkProps, Route, Switch, useRoute } from 'wouter'; import SmartLink from './smart-link'; import Logo from './logo'; import { AppContextProvider } from '$components/common/app-context'; +import { UserInfo } from '$components/auth/userInfo'; const Home = React.lazy(() => import('../home/')); const Search = React.lazy(() => import('../search/')); const Species = React.lazy(() => import('../species/')); const IndividualSingle = React.lazy(() => import('../individual/')); -const IndividualSingleExplore = React.lazy(() => import('../individual/explore-data')); +const IndividualSingleExplore = React.lazy( + () => import('../individual/explore-data') +); const MbMap = React.lazy(() => import('./mb-map')); @@ -84,6 +87,9 @@ export default function PageLayout() { + + + }> diff --git a/app/main.tsx b/app/main.tsx index 4e76729..ac58b4e 100644 --- a/app/main.tsx +++ b/app/main.tsx @@ -7,6 +7,7 @@ import { Router } from 'wouter'; import theme from '$styles/theme'; import PageLayout from '$components/common/page-layout'; +import { KeycloakProvider } from '$components/auth/context'; const publicUrl = process.env.PUBLIC_URL || ''; @@ -33,21 +34,23 @@ function Root() { }, []); return ( - - - - - - - + + + + + + + + + ); } diff --git a/package.json b/package.json index a8dc643..a782a91 100644 --- a/package.json +++ b/package.json @@ -30,6 +30,7 @@ "@tanstack/eslint-plugin-query": "^5.62.1", "@testing-library/jest-dom": "^6.4.0", "@testing-library/react": "^14.2.0", + "@types/keycloak-js": "^3.4.1", "@types/node": "^22.10.1", "@types/react": "^18.3.12", "@types/react-dom": "^18.3.1", @@ -92,6 +93,7 @@ "date-fns": "^3.3.1", "deck.gl": "^9.0.0", "framer-motion": "^11.11.0", + "keycloak-js": "26.1.5", "mapbox-gl": "^3.1.2", "polished": "^4.3.1", "react": "^18.2.0", diff --git a/tsconfig.json b/tsconfig.json index 79fdc6c..78ec1f5 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -4,6 +4,9 @@ "strictNullChecks": true, "baseUrl": "./", "jsx": "react", + /* Modules */ + "module": "ESNext", /* Specify what module code is generated. */ + "moduleResolution": "bundler", "paths": { /* Specify a set of entries that re-map imports to additional lookup locations. */ "$components/*": ["./app/components/*"], diff --git a/yarn.lock b/yarn.lock index 7c3afed..9eabf97 100644 --- a/yarn.lock +++ b/yarn.lock @@ -6309,6 +6309,15 @@ __metadata: languageName: node linkType: hard +"@types/keycloak-js@npm:^3.4.1": + version: 3.4.1 + resolution: "@types/keycloak-js@npm:3.4.1" + dependencies: + keycloak-js: "npm:*" + checksum: 10c0/acc6384153dbc809ffc803c6245dce44a0cc1235bfa138c216c8eb0c7cedd16558954d192a6fced4f397afadebf09bdb80acec71ab373c71d12a6f70bcf503f9 + languageName: node + linkType: hard + "@types/lodash.mergewith@npm:4.6.9": version: 4.6.9 resolution: "@types/lodash.mergewith@npm:4.6.9" @@ -10279,6 +10288,7 @@ __metadata: "@turf/bbox": "npm:^7.2.0" "@types/d3": "npm:^7.4.3" "@types/geojson": "npm:^7946.0.14" + "@types/keycloak-js": "npm:^3.4.1" "@types/mapbox-gl": "npm:^2.7.20" "@types/node": "npm:^22.10.1" "@types/react": "npm:^18.3.12" @@ -10311,6 +10321,7 @@ __metadata: jest: "npm:^29.7.0" jest-css-modules-transform: "npm:^4.4.2" jest-environment-jsdom: "npm:^29.7.0" + keycloak-js: "npm:26.1.5" mapbox-gl: "npm:^3.1.2" parcel: "npm:^2.11.0" parcel-resolver-ignore: "npm:^2.2.0" @@ -11916,6 +11927,20 @@ __metadata: languageName: node linkType: hard +"keycloak-js@npm:*": + version: 26.2.0 + resolution: "keycloak-js@npm:26.2.0" + checksum: 10c0/a0c97c29ce90ac689f27dce9c6fed92a353b4d06b4339e03ed1adc663edbbcffac59ac9a55abf18f7df95fa57f08958683c7e8a36bc536791407ef6a02eb0de0 + languageName: node + linkType: hard + +"keycloak-js@npm:26.1.5": + version: 26.1.5 + resolution: "keycloak-js@npm:26.1.5" + checksum: 10c0/d0050d4de981b2dd0ec33fc906ea30638bf06c76f5087979acff992e443b86d2bacd427f1bc0cbe4c8fc40c3d7756d148caa6af14d2c1a88fb0993cae2762f5e + languageName: node + linkType: hard + "keyv@npm:^4.5.3": version: 4.5.4 resolution: "keyv@npm:4.5.4"