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

Closed
wants to merge 8 commits into from
Closed
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
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@ lib-cov
*.pid
npm-debug.log
yarn-error.log
.yarn
.parcel-cache
.env.*

Expand Down
2 changes: 1 addition & 1 deletion .nvmrc
Original file line number Diff line number Diff line change
@@ -1 +1 @@
20
24
Binary file removed .yarn/install-state.gz
Binary file not shown.
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ The steps below will walk you through setting up your own instance of the projec
### Install Project Dependencies
To set up the development environment for this website, you'll need to install the following on your system:

- [Node](http://nodejs.org/) v20 (To manage multiple node versions we recommend [nvm](https://github.com/creationix/nvm))
- [Node](http://nodejs.org/) v24 (To manage multiple node versions we recommend [nvm](https://github.com/creationix/nvm))
- [Yarn](https://yarnpkg.com/) Package manager

### Install Application Dependencies
Expand Down
124 changes: 124 additions & 0 deletions app/components/auth/context.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,124 @@
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;

interface UserProfile {
groups: string[];
username: string;
}

export type KeycloakContextProps = {
initStatus: 'loading' | 'success' | 'error';
isLoading: boolean;
profile?: UserProfile;
} & (
| {
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<UserProfile | 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({
groups: keycloak.idTokenParsed?.access_group || [],
username: keycloak.idTokenParsed?.preferred_username || ''
});
}

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;
};
64 changes: 64 additions & 0 deletions app/components/auth/userInfo.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
import React 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';

export function UserInfo() {
const { profile, isLoading, isEnabled, keycloak } = useKeycloak();

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.username;

return (
<Tooltip hasArrow label='Logout' placement='right' bg='base.500'>
<SmartLink
to='/'
display='block'
onClick={(e) => {
e.preventDefault();
keycloak.clearToken();
keycloak.logout({
redirectUri: window.location.href
});
}}
>
<Avatar
size='sm'
name={username}
bg='secondary.500'
color='white'
borderRadius='4px'
/>
</SmartLink>
</Tooltip>
);
}
11 changes: 10 additions & 1 deletion app/components/common/md-content.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import {
} from '@chakra-ui/react';
import React, { Suspense, useEffect, useState } from 'react';
import { useQuery } from '@tanstack/react-query';
import { NotFound } from './error/not-found';
import { getMdFn } from '$utils/api';

const Markdown = React.lazy(() => import('react-markdown'));
Expand Down Expand Up @@ -53,9 +54,17 @@ export function MdContent(props: MdContentProps) {
const { data, error, isLoading } = useQuery({
enabled: !!url,
queryKey: ['markdown', url || 'n/a'],
queryFn: getMdFn(url || '')
queryFn: getMdFn(url || ''),
retry(failureCount, error) {
// Retry only if the error is a NotFound error
return error instanceof NotFound ? false : failureCount < 3;
}
});

if (error instanceof NotFound) {
return 'This was not a well-known individual. Nothing to learn about this fish.';
}

if (isLoading || !remarkGfmPlugin) {
return <Loading />;
}
Expand Down
112 changes: 112 additions & 0 deletions app/components/common/page-footer.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,112 @@
import React from 'react';
import {
IconButton,
Flex,
useDisclosure,
Divider,
Image
} from '@chakra-ui/react';
import { AnimatePresence, motion } from 'framer-motion';
import {
CollecticonCircleInformation,
CollecticonXmarkSmall
} from '@devseed-ui/collecticons-chakra';

import destineLogo from '../../media/layout/destine.svg';
import ecmwfLogo from '../../media/layout/ecmwf.svg';
import esaLogo from '../../media/layout/esa.svg';
import euFlag from '../../media/layout/eu.svg';
import eumetsatLogo from '../../media/layout/eumetsat.svg';

import SmartLink from './smart-link';
const FooterSection = (props) => (
<Flex as='p' gap={2} alignItems='center' {...props} />
);

export function PageFooter() {
const { getButtonProps, getDisclosureProps, isOpen } = useDisclosure();

return (
<Flex
as='footer'
position='absolute'
zIndex={100}
p={1}
bottom={8}
right={4}
bg='surface.400a'
borderRadius='md'
backdropFilter='blur(1rem)'
>
<AnimatePresence initial={false}>
{isOpen ? (
<motion.div
initial={{ opacity: 0, width: 0 }}
animate={{ opacity: 1, width: 'auto' }}
exit={{ opacity: 0, width: 0 }}
key='box'
>
<Flex
{...getDisclosureProps()}
alignItems='center'
px={2}
gap={4}
overflow='hidden'
height='100%'
justifyContent='end'
css={{
textWrap: 'nowrap',
a: {
transition: 'opacity 160ms ease-in-out',
':hover': {
opacity: 0.6
}
}
}}
color='base.400'
fontSize='xs'
>
<FooterSection>
Part of
<SmartLink to='https://destination-earth.eu/'>
<Image src={destineLogo} alt='Destination earth' />
</SmartLink>
</FooterSection>
<FooterSection>
Funded by The European Union
<Image src={euFlag} alt='EU Flag' />
</FooterSection>
<FooterSection>
Implemented by
<SmartLink to='https://www.ecmwf.int/'>
<Image src={ecmwfLogo} alt='ECMWF' />
</SmartLink>
<SmartLink to='https://www.esa.int/'>
<Image src={esaLogo} alt='ESA' />
</SmartLink>
<SmartLink to='https://www.eumetsat.int/'>
<Image src={eumetsatLogo} alt='EUMETSAT' />
</SmartLink>
</FooterSection>
<Divider
orientation='vertical'
height={4}
borderWidth='2px'
borderColor='base.200a'
borderRadius='md'
/>
</Flex>
</motion.div>
) : null}
</AnimatePresence>
<IconButton
size='sm'
variant='ghost'
aria-label='Information'
{...getButtonProps()}
>
{isOpen ? <CollecticonXmarkSmall /> : <CollecticonCircleInformation />}
</IconButton>
</Flex>
);
}
Loading
Loading