Skip to content

Commit

Permalink
[ENH] Implemented OIDC flow that results in a query sent to the f-API (
Browse files Browse the repository at this point in the history
…#205)

* Renamed `NBDialog` component to `GetDataDialog`

* Added `react-oauth/google` and `jwt-decode` to dependencies

* Wrapped the app with `GoogleOAuthProvider`

* Updated import statement

* Implemented `AuthDialog` component

* Implemented Avatar for user profile pic

* Updated request config to include ID token

* Implemented action to dismiss notificaitons

* Implemented profile menu

* Implemented logout functionality

* Small clean ups

* Implemented `NB_ENABLE_AUTH` and `NB_QUERY_CLIENT_ID` env vars

* Set a conditional to render auth related components and elements

* Updated README.md

* Changed the default for `NB_ENABLE_AUTH` from `true` to `false`

* Implemented check for `NB_QUERY_CLIENT_ID`

* Fixed typo in README.md

* Implemented component test for `AuthDialog`

* Added missing props to `Navbar` component test

* [FIX] auth-component test failed in Germany

Interesting behaviour for the oauth prompt:
it is written in the local language from where the request is sent.
So from Germany it's in German.

I changed the string check and also the login prompt language

---------

Co-authored-by: Sebastian Urchs <[email protected]>
Co-authored-by: Sebastian Urchs <[email protected]>
  • Loading branch information
3 people committed Jul 14, 2024
1 parent c34240c commit 7b16bba
Show file tree
Hide file tree
Showing 17 changed files with 306 additions and 57 deletions.
27 changes: 23 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -47,10 +47,12 @@ but before proceeding with either you need to set the environment variables.

### Mandatory configuration

| Environment variable | Type | Required | Default value if not set | Example |
| ----------------------------------------------- | ------- | -------- | ------------------------ | -------------------------------- |
| [`NB_API_QUERY_URL`](#nb_api_query_url) | string | Yes | - | https://federate.neurobagel.org/ |
| [`NB_IS_FEDERATION_API`](#nb_is_federation_api) | boolean | No | true | true |
| Environment variable | Type | Required | Default value if not set | Example |
| -------------------- | ------- | -------------------------------------- | ------------------------ | ------------------------------------------------------- |
| NB_API_QUERY_URL | string | Yes | - | https://federate.neurobagel.org/ |
| NB_IS_FEDERATION_API | boolean | No | true | true |
| NB_ENABLE_AUTH | boolean | no | false | false |
| NB_QUERY_CLIENT_ID | string | Yes (if NB_ENABLE_AUTH is set to true) | - | 46923719231972-dhsahgasl3123.apps.googleusercontent.com |

#### `NB_API_QUERY_URL`

Expand All @@ -60,6 +62,15 @@ You'll need to set the `NB_API_QUERY_URL` environment variable required to run t

If the API you'd like to send queries to is not a [federation api](https://neurobagel.org/federate/), you need to set the `NB_IS_FEDERATION_API` to `false` as it is `true` by default.

#### `NB_ENABLE_AUTH`

If the API you'd like to send queries to requires authentication, you need to set `NB_ENABLE_AUTH` to `true` as it is `false` by default. This will enable authentication flow of the app.

#### `NB_QUERY_CLIENT_ID`

If the `NB_ENABLE_AUTH` is set to `true` (it is `false` by default), you need to provide a valid client ID for the authentication.
_At the moment, query tool uses Google for authentication, so you need to obtain a client ID from Google developer console. See [documentation](https://developers.google.com/identity/gsi/web/guides/get-google-api-clientid) for more information._

#### Set the environment variables

To set environment variables, create a `.env` file in the root directory and add the environment variables there. If you're running a neurobagel node-API locally on your machine (following the instructions [here](https://github.com/neurobagel/api#local-installation)), your `.env` file would look something like this:
Expand All @@ -75,6 +86,14 @@ if you're using the remote (in this example federation) api, your `.env` file wo
NB_API_QUERY_URL=https://federate.neurobagel.org/
```

if you're using a federation api with authentication, your `.env` file would look something like this:

```bash
NB_API_QUERY_URL=https://federate.neurobagel.org/
NB_ENABLE_AUTH=true
NB_QUERY_CLIENT_ID=46923719231972-dhsahgasl3123.apps.googleusercontent.com
```

:warning: The protocol matters here.
If you wish to use the Neurobagel remote API, ensure your `NB_API_QUERY_URL` uses `https` instead of `http`.

Expand Down
23 changes: 23 additions & 0 deletions cypress/component/AuthDialog.cy.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
import { GoogleOAuthProvider } from '@react-oauth/google';
import AuthDialog from '../../src/components/AuthDialog';

const props = {
isLoggedIn: false,
onAuth: () => {},
};

describe('ContinuousField', () => {
it('Displays a MUI dialog with the title and "sing in with google" button', () => {
cy.mount(
<GoogleOAuthProvider clientId="mock-client-id">
{' '}
<AuthDialog isLoggedIn={props.isLoggedIn} onAuth={props.onAuth} />
</GoogleOAuthProvider>
);
cy.get('[data-cy="auth-dialog"]').should('be.visible');
cy.get('[data-cy="auth-dialog"]').should('contain', 'You must log in');
cy.get('[data-cy="auth-dialog"]').within(() => {
cy.contains('Google');
});
});
});
28 changes: 28 additions & 0 deletions cypress/component/GetDataDialog.cy.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
import GetDataDialog from '../../src/components/GetDataDialog';

const props = {
open: true,
onClose: () => {},
};

describe('GetDataDialog', () => {
it('Displays a MUI Diaglog with the title and content', () => {
cy.mount(<GetDataDialog open={props.open} onClose={props.onClose} />);
cy.get('[data-cy="get-data-dialog"]').should('be.visible');
cy.get('[data-cy="get-data-dialog"] h2').should('contain', 'Example usage');
cy.get('[data-cy="get-data-dialog"] p').should(
'contain',
'The command for automatically getting the data currently only applies to datasets available through datalad.'
);
});
it("Doesn't display the dialog when open prop is set to false", () => {
cy.mount(<GetDataDialog open={false} onClose={props.onClose} />);
cy.get('[data-cy="get-data-dialog"]').should('not.exist');
});
it('Fires onClose event handler when the close button is clicked', () => {
const onCloseSpy = cy.spy().as('onCloseSpy');
cy.mount(<GetDataDialog open={props.open} onClose={onCloseSpy} />);
cy.get('[data-cy="get-data-dialog"] button').click();
cy.get('@onCloseSpy').should('have.been.called');
});
});
28 changes: 0 additions & 28 deletions cypress/component/NBDialog.cy.tsx

This file was deleted.

8 changes: 7 additions & 1 deletion cypress/component/Navbar.cy.tsx
Original file line number Diff line number Diff line change
@@ -1,8 +1,14 @@
import Navbar from '../../src/components/Navbar';

const props = {
name: 'john doe',
profilePic: 'johndoe.png',
onLogout: () => {},
};

describe('Navbar', () => {
it('Displays a MUI Toolbar with logo, title, subtitle, documentation link, and GitHub link', () => {
cy.mount(<Navbar />);
cy.mount(<Navbar name={props.name} profilePic={props.profilePic} onLogout={props.onLogout} />);
cy.get("[data-cy='navbar']").should('be.visible');
cy.get("[data-cy='navbar'] img").should('exist');
cy.get("[data-cy='navbar'] h5").should('contain', 'Neurobagel Query');
Expand Down
4 changes: 2 additions & 2 deletions cypress/component/ResultContainer.cy.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -47,9 +47,9 @@ describe('ResultContainer', () => {
});
it('Clicking the how to get data modal button should open the modal', () => {
cy.mount(<ResultContainer response={protectedResponse2} />);
cy.get('[data-cy="nb-dialog"]').should('not.exist');
cy.get('[data-cy="get-data-dialog"]').should('not.exist');
cy.get('[data-cy="how-to-get-data-modal-button"]').click();
cy.get('[data-cy="nb-dialog"]').should('be.visible');
cy.get('[data-cy="get-data-dialog"]').should('be.visible');
});
it('Shows no result view when result is empty', () => {
cy.mount(
Expand Down
19 changes: 19 additions & 0 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 2 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -19,8 +19,10 @@
"@fontsource/roboto": "^5.0.13",
"@mui/icons-material": "^5.15.20",
"@mui/material": "^5.15.21",
"@react-oauth/google": "^0.12.1",
"axios": "^1.7.2",
"eslint-plugin-tsdoc": "^0.2.17",
"jwt-decode": "^4.0.0",
"notistack": "^3.0.1",
"react": "^18.2.0",
"react-dom": "^18.3.1",
Expand Down
75 changes: 63 additions & 12 deletions src/App.tsx
Original file line number Diff line number Diff line change
@@ -1,9 +1,12 @@
import { useState, useEffect } from 'react';
import { useSearchParams } from 'react-router-dom';
import axios, { AxiosResponse } from 'axios';
import { Alert, Grow } from '@mui/material';
import { SnackbarProvider, enqueueSnackbar } from 'notistack';
import { queryURL, attributesURL, isFederationAPI, nodesURL } from './utils/constants';
import { Alert, Grow, IconButton } from '@mui/material';
import CloseIcon from '@mui/icons-material/Close';
import { SnackbarKey, SnackbarProvider, closeSnackbar, enqueueSnackbar } from 'notistack';
import { jwtDecode } from 'jwt-decode';
import { googleLogout } from '@react-oauth/google';
import { queryURL, attributesURL, isFederationAPI, nodesURL, enableAuth } from './utils/constants';
import {
RetrievedAttributeOption,
AttributeOption,
Expand All @@ -12,10 +15,12 @@ import {
FieldInputOption,
NodeError,
QueryResponse,
GoogleJWT,
} from './utils/types';
import QueryForm from './components/QueryForm';
import ResultContainer from './components/ResultContainer';
import Navbar from './components/Navbar';
import AuthDialog from './components/AuthDialog';
import './App.css';

function App() {
Expand All @@ -42,6 +47,11 @@ function App() {
const [imagingModality, setImagingModality] = useState<FieldInput>(null);
const [searchParams, setSearchParams] = useSearchParams();

const [isLoggedIn, setIsLoggedIn] = useState<boolean>(false);
const [name, setName] = useState<string>('');
const [profilePic, setProfilePic] = useState<string>('');
const [IDToken, setIDToken] = useState<string | undefined>('');

const selectedNode: FieldInputOption[] = availableNodes
.filter((option) => searchParams.getAll('node').includes(option.NodeName))
.map((filteredOption) => ({ label: filteredOption.NodeName, id: filteredOption.ApiURL }));
Expand All @@ -53,6 +63,16 @@ function App() {
}
: null;

const action = (snackbarId: SnackbarKey) => (
<IconButton
onClick={() => {
closeSnackbar(snackbarId);
}}
>
<CloseIcon className="text-white" />
</IconButton>
);

useEffect(() => {
async function getAttributes(dataElementURI: string) {
try {
Expand All @@ -62,24 +82,26 @@ function App() {
if (response.data.nodes_response_status === 'fail') {
enqueueSnackbar(`Failed to retrieve ${dataElementURI.slice(3)} options`, {
variant: 'error',
action,
});
} else {
// If any errors occurred, report them
response.data.errors.forEach((error) => {
enqueueSnackbar(
`Failed to retrieve ${dataElementURI.slice(3)} options from ${error.node_name}`,
{ variant: 'warning' }
{ variant: 'warning', action }
);
});
// If the results are empty, report that
if (Object.keys(response.data.responses[dataElementURI]).length === 0) {
enqueueSnackbar(`No ${dataElementURI.slice(3)} options were available`, {
variant: 'info',
action,
});
} else if (response.data.responses[dataElementURI].some((item) => item.Label === null)) {
enqueueSnackbar(
`Warning: Missing labels were removed for ${dataElementURI.slice(3)} `,
{ variant: 'warning' }
{ variant: 'warning', action }
);
response.data.responses[dataElementURI] = response.data.responses[
dataElementURI
Expand Down Expand Up @@ -116,9 +138,9 @@ function App() {
if (isFederationAPI) {
getNodeOptions(nodesURL).then((nodeResponse) => {
if (nodeResponse === null) {
enqueueSnackbar('Failed to retrieve Node options', { variant: 'error' });
enqueueSnackbar('Failed to retrieve Node options', { variant: 'error', action });
} else if (nodeResponse.length === 0) {
enqueueSnackbar('No options found for Node', { variant: 'info' });
enqueueSnackbar('No options found for Node', { variant: 'info', action });
} else {
setAvailableNodes([...nodeResponse, { NodeName: 'All', ApiURL: 'allNodes' }]);
}
Expand Down Expand Up @@ -280,19 +302,27 @@ function App() {
setLoading(true);
const url: string = constructQueryURL();
try {
const response = await axios.get(url);
const response = await axios.get(url, {
headers: {
Authorization: `Bearer ${IDToken}`,
'Content-Type': 'application/json',
},
});
// TODO: remove this branch once there is no more non-federation option
if (isFederationAPI) {
setResult(response.data);
switch (response.data.nodes_response_status) {
case 'partial success': {
response.data.errors.forEach((error: NodeError) => {
enqueueSnackbar(`${error.node_name} failed to respond`, { variant: 'warning' });
enqueueSnackbar(`${error.node_name} failed to respond`, {
variant: 'warning',
action,
});
});
break;
}
case 'fail': {
enqueueSnackbar('Error: All nodes failed to respond', { variant: 'error' });
enqueueSnackbar('Error: All nodes failed to respond', { variant: 'error', action });
break;
}
default: {
Expand All @@ -308,19 +338,40 @@ function App() {
setResult(myResponse);
}
} catch (error) {
enqueueSnackbar('Failed to retrieve results', { variant: 'error' });
enqueueSnackbar('Failed to retrieve results', { variant: 'error', action });
}
setLoading(false);
}

function login(credential: string | undefined) {
setIsLoggedIn(true);
const jwt: GoogleJWT = credential ? jwtDecode(credential) : ({} as GoogleJWT);
setIDToken(credential);
setName(jwt.given_name);
setProfilePic(jwt.picture);
}

function logout() {
googleLogout();
setIsLoggedIn(false);
setIDToken('');
setName('');
setProfilePic('');
}

return (
<>
<div>
{enableAuth && (
<AuthDialog isLoggedIn={isLoggedIn} onAuth={(credential) => login(credential)} />
)}
</div>
<SnackbarProvider
autoHideDuration={6000}
anchorOrigin={{ vertical: 'top', horizontal: 'right' }}
maxSnack={7}
/>
<Navbar />
<Navbar name={name} profilePic={profilePic} onLogout={() => logout()} />
{showAlert() && (
<>
<Grow in={!alertDismissed}>
Expand Down
Loading

0 comments on commit 7b16bba

Please sign in to comment.