Skip to content

Prepare for authentication with keycloack #7

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 1 commit into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 4 additions & 1 deletion .env
Original file line number Diff line number Diff line change
Expand Up @@ -3,4 +3,7 @@ APP_DESCRIPTION=GFTS point cloud

#MAPBOX_TOKEN=

#DATA_API=''
#DATA_API=''
REACT_APP_KEYCLOAK_URL
REACT_APP_KEYCLOAK_REALM
REACT_APP_KEYCLOAK_CLIENT_ID
Binary file modified .yarn/install-state.gz
Binary file not shown.
118 changes: 118 additions & 0 deletions app/components/auth/context.tsx
Original file line number Diff line number Diff line change
@@ -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)({

Check warning on line 17 in app/components/auth/context.tsx

View workflow job for this annotation

GitHub Actions / lint

Unexpected any. Specify a different type
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<KeycloakContextProps>({
initStatus: 'loading',
isEnabled: isAuthEnabled
} as KeycloakContextProps);

export const KeycloakProvider = (props: { children: React.ReactNode }) => {
const [initStatus, setInitStatus] =
useState<KeycloakContextProps['initStatus']>('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<Keycloak.KeycloakProfile>);
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 (
<KeycloakContext.Provider
value={
isAuthEnabled
? {
...base,
keycloak: keycloak!,
isEnabled: true
}
: {
...base,
keycloak: undefined,
isEnabled: false
}
}
>
{props.children}
</KeycloakContext.Provider>
);
};

export const useKeycloak = () => {
const ctx = useContext(KeycloakContext);

if (!ctx) {
throw new Error('useKeycloak must be used within a KeycloakProvider');
}

return ctx;
};
82 changes: 82 additions & 0 deletions app/components/auth/userInfo.tsx
Original file line number Diff line number Diff line change
@@ -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<string>('');
useEffect(() => {
if (profile?.email) {
hash(profile.email).then(setUserEmailHash);
}
}, [profile?.email]);

if (!isEnabled) {
return null;
}

const isAuthenticated = keycloak.authenticated;

if (!isAuthenticated || !profile || isLoading) {
return (
<Tooltip hasArrow label='Login' placement='right' bg='base.500'>
<IconButton
aria-label='Login'
size='sm'
variant='ghost'
colorScheme='base'
_active={{ bg: 'base.100a' }}
icon={<CollecticonLogin />}
onClick={() => {
if (!isLoading) {
keycloak.login({
redirectUri: window.location.href
});
}
}}
/>
</Tooltip>
);
}

const username =
`${profile.firstName} ${profile.lastName}`.trim() || profile.username;

return (
<Tooltip hasArrow label='Logout' placement='right' bg='base.500'>
<SmartLink
to='/'
display='block'
onClick={(e) => {
e.preventDefault();
keycloak.logout({
redirectUri: window.location.href
});
}}
>
<Avatar
size='sm'
name={username}
bg='secondary.500'
color='white'
borderRadius='4px'
src={`https://www.gravatar.com/avatar/${userEmailHash}?d=404`}
/>
</SmartLink>
</Tooltip>
);
}
8 changes: 7 additions & 1 deletion app/components/common/page-layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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'));

Expand Down Expand Up @@ -84,6 +87,9 @@ export default function PageLayout() {
</ListItem>
</List>
</Box>
<Box mt='auto'>
<UserInfo />
</Box>
</Flex>
<Flex as='main' bg='surface.500' borderRadius='md' w='100%' p={4}>
<Suspense fallback={<Loading />}>
Expand Down
33 changes: 18 additions & 15 deletions app/main.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 || '';

Expand All @@ -33,21 +34,23 @@ function Root() {
}, []);

return (
<ChakraProvider theme={theme} resetCSS>
<QueryClientProvider client={queryClient}>
<Router
base={
new URL(
publicUrl.startsWith('http')
? publicUrl
: `https://ds.io/${publicUrl.replace(/^\//, '')}`
).pathname
}
>
<PageLayout />
</Router>
</QueryClientProvider>
</ChakraProvider>
<KeycloakProvider>
<ChakraProvider theme={theme} resetCSS>
<QueryClientProvider client={queryClient}>
<Router
base={
new URL(
publicUrl.startsWith('http')
? publicUrl
: `https://ds.io/${publicUrl.replace(/^\//, '')}`
).pathname
}
>
<PageLayout />
</Router>
</QueryClientProvider>
</ChakraProvider>
</KeycloakProvider>
);
}

Expand Down
2 changes: 2 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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",
Expand Down
3 changes: 3 additions & 0 deletions tsconfig.json
Original file line number Diff line number Diff line change
Expand Up @@ -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/*"],
Expand Down
25 changes: 25 additions & 0 deletions yarn.lock
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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"
Expand Down Expand Up @@ -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"
Expand Down Expand Up @@ -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"
Expand Down
Loading