diff --git a/apps/graphpolaris/src/app/app.tsx b/apps/graphpolaris/src/app/app.tsx index f0d382be6575a7f4b19ef984243cc6f5c21e753c..4068d6b0b21c0d5ece760fa2207e0f04ab21911d 100644 --- a/apps/graphpolaris/src/app/app.tsx +++ b/apps/graphpolaris/src/app/app.tsx @@ -1,72 +1,114 @@ // eslint-disable-next-line @typescript-eslint/no-unused-vars +import { AuthorizationHandler } from '@graphpolaris/shared/data-access/authorization'; import { assignNewGraphQueryResult, useAppDispatch, } from '@graphpolaris/shared/data-access/store'; +import { useEffect, useState } from 'react'; import GridLayout from 'react-grid-layout'; -import Panel from '../web/components/panels/panel'; -import { RawJSONVis } from '../web/components/visualisations/rawjsonvis/rawjsonvis'; +import Panel from '../web/components/panels/Panel'; +import { RawJSONVis } from '../web/components/visualisations/RawJSONVis/rawjsonvis'; +import LoginScreen from '../web/components/login/loginScreen'; export function App() { const dispatch = useAppDispatch(); + + const [userAuthorized, setUserAuthorized] = useState<boolean>(false); + + // Attempt to Authorize the user + const authorize = async () => { + const authorized = await AuthorizationHandler.instance().Authorize(); + console.log('User authorized: ' + authorized); + + // If user is authorized don't show login screen + if (authorized) { + setUserAuthorized(true); + } + }; + + useEffect(() => { + authorize(); + }, []); + return ( - <GridLayout - className="layout" - cols={10} - rowHeight={30} - width={window.innerWidth} - > - <div - key="schema-panel" - data-grid={{ x: 0, y: 0, w: 3, h: 30, maxW: 5, isDraggable: false }} - > - <Panel content="Schema Panel" color="red"></Panel> - </div> - <div - key="query-panel" - data-grid={{ x: 3, y: 20, w: 5, h: 10, maxH: 20, isDraggable: false }} - > - <Panel content="Query Panel" color="blue"></Panel> - </div> - <div - key="visualisation-panel" - data-grid={{ x: 3, y: 0, w: 7, h: 20, isDraggable: false }} - > - <Panel content="Visualisation Panel" color="green"> - <div> - <button - onClick={() => - dispatch( - assignNewGraphQueryResult({ - nodes: [ - { id: 'agent/007', attributes: { name: 'Daniel Craig' } }, - ], - links: [], - }) - ) - } - > - Load in mock result - </button> - <button - onClick={() => - dispatch(assignNewGraphQueryResult({ nodes: [], links: [] })) - } - > - Remove mock result - </button> - </div> - <RawJSONVis /> - <div /> - </Panel> - </div> - <div - key="history-panel" - data-grid={{ x: 8, y: 20, w: 2, h: 10, isDraggable: false }} + <> + {!userAuthorized && <LoginScreen />} + <GridLayout + className="layout" + cols={10} + rowHeight={30} + width={window.innerWidth} + style={{ zIndex: 0 }} > - <Panel content="History Channel" color="purple"></Panel> - </div> - </GridLayout> + <div + key="schema-panel" + data-grid={{ + x: 0, + y: 0, + w: 3, + h: 30, + maxW: 5, + isDraggable: false, + }} + > + <Panel content="Schema Panel" color="white"></Panel> + </div> + <div + key="query-panel" + data-grid={{ + x: 3, + y: 20, + w: 5, + h: 10, + maxH: 20, + isDraggable: false, + }} + > + <Panel content="Query Panel" color="white"></Panel> + </div> + <div + key="visualisation-panel" + data-grid={{ x: 3, y: 0, w: 7, h: 20, isDraggable: false }} + > + <Panel content="Visualisation Panel" color="white"> + <div> + <button + onClick={() => + dispatch( + assignNewGraphQueryResult({ + nodes: [ + { + id: 'agent/007', + attributes: { name: 'Daniel Craig' }, + }, + ], + links: [], + }) + ) + } + > + Load in mock result + </button> + <button + onClick={() => + dispatch(assignNewGraphQueryResult({ nodes: [], links: [] })) + } + > + Remove mock result + </button> + </div> + <RawJSONVis /> + <div /> + </Panel> + </div> + <div + key="history-panel" + data-grid={{ x: 8, y: 20, w: 2, h: 10, isDraggable: false }} + > + <Panel content="History Channel" color="white"></Panel> + </div> + </GridLayout> + </> ); } diff --git a/apps/graphpolaris/src/assets/login-screen/github.png b/apps/graphpolaris/src/assets/login-screen/github.png new file mode 100644 index 0000000000000000000000000000000000000000..55f90676824d4901f6fe7a50ed11f6d4af3d6fef Binary files /dev/null and b/apps/graphpolaris/src/assets/login-screen/github.png differ diff --git a/apps/graphpolaris/src/assets/login-screen/google.png b/apps/graphpolaris/src/assets/login-screen/google.png new file mode 100644 index 0000000000000000000000000000000000000000..f27bb2433042aea5fc34e19fcf90944430ec331b Binary files /dev/null and b/apps/graphpolaris/src/assets/login-screen/google.png differ diff --git a/apps/graphpolaris/src/main.tsx b/apps/graphpolaris/src/main.tsx index 0eb1b52efbd1c4aab21e9ad90fe9845c7f697abd..f26b53e9746df5f2708f23b3bb80d03cd0f0b4fb 100644 --- a/apps/graphpolaris/src/main.tsx +++ b/apps/graphpolaris/src/main.tsx @@ -1,14 +1,23 @@ +import { BrowserRouter as Router, Route, Routes } from 'react-router-dom'; import { store } from '@graphpolaris/shared/data-access/store'; import { StrictMode } from 'react'; import * as ReactDOM from 'react-dom'; import { Provider } from 'react-redux'; import App from './app/app'; +import LoginPopupComponent from './web/components/login/popup'; ReactDOM.render( <StrictMode> <Provider store={store}> - <App /> + <Router> + <Routes> + {/* Route to auth component in popup */} + <Route path="/auth" element={<LoginPopupComponent />}></Route> + {/* App */} + <Route path="/" element={<App />}></Route> + </Routes> + </Router> </Provider> </StrictMode>, document.getElementById('root') diff --git a/apps/graphpolaris/src/web/components/login/loginScreen.tsx b/apps/graphpolaris/src/web/components/login/loginScreen.tsx new file mode 100644 index 0000000000000000000000000000000000000000..903b4eba29392bf34854205ddfd6517de82fa45f --- /dev/null +++ b/apps/graphpolaris/src/web/components/login/loginScreen.tsx @@ -0,0 +1,122 @@ +import styled from 'styled-components'; + +const Wrapper = styled.div` + font-family: 'Arial'; + position: absolute; + left: 0; + top: 0; + // Cover the screen + width: 100vw; + height: 100vh; + + display: flex; + justify-content: center; + align-items: center; +`; + +const Background = styled.div` + position: absolute; + + width: 100%; + height: 100%; + + z-index: 1; + + // Blur + background: rgba( + 0, + 0, + 0, + 0.4 + ); // Make sure this color has an opacity of less than 1 + backdrop-filter: blur(8px); // This be the blur +`; + +const Content = styled.div` + background-color: white; + box-shadow: 0 3px 10px rgb(0 0 0 / 0.2); + padding: 2em; + z-index: 2; + border-radius: 8px; + + display: flex; + flex-direction: column; + gap: 1em; + align-items: center; + justify-content: center; + + // Give children 0 padding and margin + * { + display: flex; + margin: 0; + padding: 0; + + // Same width flexbox items + flex: 1 1 0px; + + max-height: 3em; + + &:hover { + cursor: pointer; + } + } +`; + +const LoginScreen = () => { + const openSignInWindow = (url: string) => { + // remove any existing event listeners + window.removeEventListener('message', receiveMessage); + + // window features + const strWindowFeatures = + 'toolbar=no, menubar=no, width=600, height=700, top=100, left=100'; + + window.open(url, 'Google Oauth', strWindowFeatures); + + // add the listener for receiving a message from the popup + window.addEventListener('message', (event) => receiveMessage(event), false); + }; + + const receiveMessage = (event: any) => { + // Do we trust the sender of this message? (might be + // different from what we originally opened) + if (event.origin !== 'http://localhost:4200') { + return; + } + console.log(event); + }; + + return ( + <Wrapper> + <Background></Background> + <Content> + <h1>Sign In</h1> + <img + onClick={() => + openSignInWindow('http://localhost:3000/sign-in?provider=1') + } + src="assets/login-screen/google.png" + alt="sign up with google" + /> + <img + onClick={() => + openSignInWindow('http://localhost:3000/sign-in?provider=2') + } + src="assets/login-screen/github.png" + alt="sign up with github" + /> + <p + onClick={() => + openSignInWindow( + 'https://datastrophe.science.uu.nl/user/create_free/' + ) + } + > + Developer + </p> + </Content> + </Wrapper> + ); +}; + +export default LoginScreen; diff --git a/apps/graphpolaris/src/web/components/login/popup.tsx b/apps/graphpolaris/src/web/components/login/popup.tsx new file mode 100644 index 0000000000000000000000000000000000000000..d895b7cfdc62f1aa3250dd88d5487a750ec133ac --- /dev/null +++ b/apps/graphpolaris/src/web/components/login/popup.tsx @@ -0,0 +1,17 @@ +const LoginPopupComponent = () => { + if (window.opener) { + // Get the access token from the query params + const urlParams = new URLSearchParams(window.location.search); + const accessToken = urlParams.get('access_token'); + + // Send the access token to the parent window + window.opener.postMessage(accessToken, '*'); + + // Close this window + window.close(); + } + + return <h1>Loading...</h1>; +}; + +export default LoginPopupComponent; diff --git a/apps/graphpolaris/src/web/components/panels/Panel.tsx b/apps/graphpolaris/src/web/components/panels/Panel.tsx index bf43cca74d91330d0c8a92d807445b21c0035a8d..901772219ae518b77f837d51466437a61104d4e1 100644 --- a/apps/graphpolaris/src/web/components/panels/Panel.tsx +++ b/apps/graphpolaris/src/web/components/panels/Panel.tsx @@ -8,7 +8,10 @@ interface Props { const Wrapper = styled.div<{ color: string }>` background-color: ${(props) => props.color}; - font: 'Arial'; + font-family: 'Arial'; + + // Light shadow + box-shadow: 0 3px 10px rgb(0 0 0 / 0.2); height: 100%; width: 100%; diff --git a/libs/shared/data-access/authorization/src/index.ts b/libs/shared/data-access/authorization/src/index.ts index 39bd8dd1793a1fe9e8d172a1e4a5eb1d91fd36da..0309d93b41a3f18d0294efe7999d9038b98f42ee 100644 --- a/libs/shared/data-access/authorization/src/index.ts +++ b/libs/shared/data-access/authorization/src/index.ts @@ -1 +1 @@ -export * from './lib/authorizationHandler'; +export { AuthorizationHandler } from './lib/authorizationHandler'; diff --git a/libs/shared/data-access/authorization/src/lib/authorizationHandler.ts b/libs/shared/data-access/authorization/src/lib/authorizationHandler.ts index 68ae8fb2560c07f3fa69cb8f3bdceccd41b9efeb..8f2455e225788de6eee16d7ebad1801bf50a6d15 100644 --- a/libs/shared/data-access/authorization/src/lib/authorizationHandler.ts +++ b/libs/shared/data-access/authorization/src/lib/authorizationHandler.ts @@ -1 +1,156 @@ -export const test = 'hey!'; +export class AuthorizationHandler { + private static _instance: AuthorizationHandler; + private accessToken = ''; + private authorized = false; + private userID = ''; + private sessionID = ''; + + // instance gets the AuthorizationHandler singleton instance + public static instance(): AuthorizationHandler { + if (!AuthorizationHandler._instance) { + AuthorizationHandler._instance = new AuthorizationHandler(); + } + + return AuthorizationHandler._instance; + } + + // MARK: Authorization code + + /** + * Authorize attempts to authorize using a refresh-token set as a cookie. If the user has been inactive for more than 7 days this cookie will be gone. + * @returns true is authorization was successful, else returns false + */ + public async Authorize(): Promise<boolean> { + // Attempt to log in with a refresh-token + const authResponse = await this.getNewAccessToken(); + + // If the request was a success, we have an accessToken, userID and sessionID + if (authResponse.success) { + // Store them + this.accessToken = authResponse.accessToken ?? ''; + this.userID = authResponse.userID ?? ''; + this.sessionID = authResponse.sessionID ?? ''; + + // Start the automatic refreshing every 10 minutes + setInterval(() => { + this.refreshTokens(); + }, 10 * 60 * 1000); + } + + return new Promise((resolve) => { + resolve(authResponse.success); + }); + } + + /** + * getNewAccessToken gets a new access token using the refresh-token cookie + * @returns an authResponse containing details + */ + private async getNewAccessToken(): Promise<authResponse> { + // If we have an access token already, append it to the url as a query param to keep sessionID the same + let url = 'https://datastrophe.science.uu.nl/auth/refresh'; + if (this.accessToken != '') { + url += '?access_token=' + this.accessToken; + } + + return new Promise<authResponse>((resolve) => { + fetch(url, { + method: 'GET', + credentials: 'include', + }) + .then((response) => { + if (!response.ok) { + throw Error(response.statusText); + } + return response.json(); + }) + .then((responseJSON) => { + resolve({ + success: true, + accessToken: responseJSON.accessToken, + userID: responseJSON.userID, + sessionID: responseJSON.sessionID, + }); + }) + .catch(() => { + // User is not authorized + resolve({ success: false }); + }); + }); + } + + /** + * refreshTokens refreshes tokens + */ + private async refreshTokens() { + // Get a new access + refresh token pair + const authResponse = await this.getNewAccessToken(); + + if (authResponse.success) { + // Set the new access token + this.accessToken = authResponse.accessToken ?? ''; + + // Initialise the new refresh token + this.initialiseRefreshToken(); + } + } + + /** + * initialiseRefreshToken attempts to initialise a refresh token + */ + private async initialiseRefreshToken() { + fetch('https://datastrophe.science.uu.nl/auth/refresh', { + method: 'POST', + credentials: 'include', + }) + .then((response) => { + if (!response.ok) { + throw Error(response.statusText); + } + }) + .catch((error) => { + console.error(error); + }); + } + + // MARK: Getters + + /** + * Authorized returns the current authorization status + * @returns true if authorized + */ + Authorized(): boolean { + return this.authorized; + } + + /** + * AccessToken returns the current access token + * @returns token + */ + AccessToken(): string { + return this.accessToken; + } + + /** + * UserID returns the current user' ID + * @returns id + */ + UserID(): string { + return this.userID; + } + + /** + * SessionID returns the current session' ID + * @returns id + */ + SessionID(): string { + return this.sessionID; + } +} + +type authResponse = { + success: boolean; + accessToken?: string; + userID?: string; + sessionID?: string; +};