Generate Token through Auth0

This commit is contained in:
Charles Bochet 2023-01-27 12:12:04 +01:00
parent 54acb16db8
commit 8e0dc44bf6
21 changed files with 3616 additions and 2344 deletions

3
.gitignore vendored
View File

@ -1 +1,2 @@
.vscode/* .vscode/*
**/**/.env

View File

@ -1,40 +1,87 @@
# twenty # Twenty
Welcome to Twenty! Welcome to Twenty documentation!
## Setup & Development ## High Level Overview
Twenty development stack is composed of 5 different layers:
- twenty-front: our frontend React app
- twenty-api (Hasura): our backend presentation layer that can do straight forward CRUDs, permissionning, authentication.
- twenty-server: our backend that contain complex logics, scripts, jobs...
- [tbd] twenty-events (Jitsu): our event ingestor which is separated from api and server to ensure high availability
- storages: postgres, [tbd] elasticsearch, [tbd] redis.
## Development environment setup
This section only discusses the development setup. The whole developemnt environment is containerized with Docker and orchestrated with docker-compose.
### Step 1: pre-requesites
Make sure to have the latest Docker and Docker-compose versions installed on your computer.
### Step 2: docker build
Build docker containers.
The whole setup/development experience is happening in `infra/dev` folder. Make sure to be in this folder:
``` ```
docker-compose -f infra/dev/docker-compose.yml up --build --force-recreate cd infra/dev
``` ```
Browse:
- FE/BE: localhost:3000
- Hasura: localhost:8080
## Tests
Ssh into the twenty-server container using:
- `docker ps` to get the container id
- `docker exec -it CONTAINER_ID sh`
### Frontend
``` ```
cd front docker-compose up --build --force-recreate
npm run test
``` ```
### Backend Once this is completed you should have:
- twenty-front available on: http://localhost:3001
- twenty-api available on: http://localhost:8080
- twenty-server available on: http://localhost:3000/health
- postgres: available on http://localhost:5432 that should contain two database: twenty (data) and hasura (metadata)
### Step 3: environment file
Configure your environment by copying the `.env.example` file located in `infra/dev` folder into `.env`.
``` ```
cd server cp infra/dev/.env.example infra/dev/.env
npm run test
``` ```
## Production Then, you'll need to replace all REPLACE_ME variable by their development value. Please reach out to another engineer to get these values (as most of them are third party credentials, sensitive data)
### Step 4: API (Hasura) metadata
Browse Hasura console on http://localhost:8080, go to settings and import metadata file located in `infra/dev/twenty-api` folder
## Developping on Frontend
The development FE server is running on docker up and is exposing the `twenty-front` on port http://localhost:3001. As you modify the `/front` folder on your computer, this folder is synced with your `twenty-front` container and the frontend application is automatically refreshed.
### Develop
Recommended: as you modify frontend code, here is how to access `twenty-front` server logs in order to debug / watch typescript issues:
``` ```
cd front && npm run build docker-compose up
cd ../server && npm run build docker-compose logs twenty-front -f
``` ```
### Open a shell into the container
```
docker-compose exec twenty-front sh
```
### Tests
#### Unit tests:
```
docker-compose exec twenty-front sh -c "npm run test"
# coverage
docker-compose exec twenty-front sh -c "npm run coverage"
```
#### Storybook:
```
docker-compose exec twenty-front sh -c "npm run storybook"
```
## Developping on API
The API is a Hasura instance which is a no-code container. To modify API behavior, you'll need to connect to Hasura console on: http://localhost:8080/console
## Developping on server
Section TBD

5350
front/package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -3,6 +3,8 @@
"version": "0.1.0", "version": "0.1.0",
"private": true, "private": true,
"dependencies": { "dependencies": {
"@apollo/client": "^3.7.5",
"@auth0/auth0-react": "^2.0.0",
"@emotion/react": "^11.10.5", "@emotion/react": "^11.10.5",
"@emotion/styled": "^11.10.5", "@emotion/styled": "^11.10.5",
"@fortawesome/fontawesome-svg-core": "^6.2.1", "@fortawesome/fontawesome-svg-core": "^6.2.1",
@ -12,6 +14,7 @@
"@types/node": "^16.18.4", "@types/node": "^16.18.4",
"@types/react": "^18.0.25", "@types/react": "^18.0.25",
"@types/react-dom": "^18.0.9", "@types/react-dom": "^18.0.9",
"graphql": "^16.6.0",
"react": "^18.2.0", "react": "^18.2.0",
"react-dom": "^18.2.0", "react-dom": "^18.2.0",
"react-router-dom": "^6.4.4", "react-router-dom": "^6.4.4",
@ -19,7 +22,7 @@
"web-vitals": "^2.1.4" "web-vitals": "^2.1.4"
}, },
"scripts": { "scripts": {
"start": "react-scripts start", "start": "cp ../infra/dev/.env ./.env && PORT=3001 react-scripts start",
"build": "react-scripts build", "build": "react-scripts build",
"test": "react-scripts test", "test": "react-scripts test",
"eject": "react-scripts eject", "eject": "react-scripts eject",
@ -46,8 +49,7 @@
"overrides": { "overrides": {
"react-refresh": "0.14.0" "react-refresh": "0.14.0"
}, },
"jest": { "jest": {},
},
"browserslist": { "browserslist": {
"production": [ "production": [
">0.2%", ">0.2%",

View File

@ -2,16 +2,61 @@ import React from 'react';
import Inbox from './pages/inbox/Inbox'; import Inbox from './pages/inbox/Inbox';
import Contacts from './pages/Contacts'; import Contacts from './pages/Contacts';
import Insights from './pages/Insights'; import Insights from './pages/Insights';
import AuthCallback from './pages/AuthCallback';
import AppLayout from './layout/AppLayout'; import AppLayout from './layout/AppLayout';
import RequireAuth from './components/RequireAuth';
import { Routes, Route } from 'react-router-dom'; import { Routes, Route } from 'react-router-dom';
import { useQuery, gql } from '@apollo/client';
const GET_USER_PROFILE = gql`
query GetUserProfile {
users {
id
email
first_name
last_name
tenant {
id
name
}
}
}
`;
function App() { function App() {
const { data } = useQuery(GET_USER_PROFILE, {
fetchPolicy: 'network-only',
});
const user = data?.users[0];
return ( return (
<AppLayout> <AppLayout user={user}>
<Routes> <Routes>
<Route path="/" element={<Inbox />} /> <Route
<Route path="/contacts" element={<Contacts />} /> path="/"
<Route path="/insights" element={<Insights />} /> element={
<RequireAuth>
<Inbox />
</RequireAuth>
}
/>
<Route
path="/contacts"
element={
<RequireAuth>
<Contacts />
</RequireAuth>
}
/>
<Route
path="/insights"
element={
<RequireAuth>
<Insights />
</RequireAuth>
}
/>
<Route path="/auth/callback" element={<AuthCallback />} />
</Routes> </Routes>
</AppLayout> </AppLayout>
); );

View File

@ -0,0 +1,10 @@
import AuthService from '../hooks/AuthenticationHooks';
function RequireAuth({ children }: { children: JSX.Element }): JSX.Element {
const { redirectIfNotLoggedIn } = AuthService;
redirectIfNotLoggedIn();
return children;
}
export default RequireAuth;

View File

@ -0,0 +1,14 @@
import RequireAuth from '../RequireAuth';
import Navbar from '../RequireAuth';
export default {
title: 'RequireAuth',
component: Navbar,
};
export const RequireAuthWithHelloChild = () => (
<RequireAuth>
<div>Hello</div>
</RequireAuth>
);

View File

@ -0,0 +1,9 @@
import { render } from '@testing-library/react';
import { RequireAuthWithHelloChild } from '../__stories__/RequireAuth.stories';
it('Checks the Require Auth renders', () => {
const { getAllByText } = render(<RequireAuthWithHelloChild />);
expect(getAllByText('Hello')).toBeTruthy();
});

View File

@ -0,0 +1,39 @@
import { useAuth0 } from '@auth0/auth0-react';
import { useState, useEffect } from 'react';
const useIsNotLoggedIn = () => {
const { isAuthenticated, isLoading } = useAuth0();
const hasAccessToken = localStorage.getItem('accessToken');
return (!isAuthenticated || !hasAccessToken) && !isLoading;
};
const redirectIfNotLoggedIn = () => {
const isNotLoggedIn = useIsNotLoggedIn();
const { loginWithRedirect } = useAuth0();
if (isNotLoggedIn) {
loginWithRedirect();
}
};
const useGetAccessToken = () => {
const [loading, setLoading] = useState(false);
const [token, setToken] = useState('');
const { getAccessTokenSilently } = useAuth0();
useEffect(() => {
const fetchToken = async () => {
setLoading(true);
const accessToken = await getAccessTokenSilently();
localStorage.setItem('accessToken', accessToken);
setLoading(false);
setToken(accessToken);
};
fetchToken();
}, []);
return { loading, token };
};
export default { useIsNotLoggedIn, useGetAccessToken, redirectIfNotLoggedIn };

View File

@ -0,0 +1,106 @@
import { renderHook } from '@testing-library/react';
import AuthenticationHooks from '../AuthenticationHooks';
import { useAuth0 } from '@auth0/auth0-react';
import { mocked } from 'jest-mock';
jest.mock('@auth0/auth0-react');
const mockedUseAuth0 = mocked(useAuth0, true);
const user = {
email: 'johndoe@me.com',
email_verified: true,
sub: 'google-oauth2|12345678901234',
};
describe('useIsNotLoggedIn', () => {
beforeEach(() => {
window.localStorage.clear();
jest.resetModules();
});
it('returns false if auth0 is loading', () => {
mockedUseAuth0.mockReturnValue({
isAuthenticated: false,
user,
logout: jest.fn(),
loginWithRedirect: jest.fn(),
getAccessTokenWithPopup: jest.fn(),
getAccessTokenSilently: jest.fn(),
getIdTokenClaims: jest.fn(),
loginWithPopup: jest.fn(),
handleRedirectCallback: jest.fn(),
isLoading: true,
});
const { useIsNotLoggedIn } = AuthenticationHooks;
const { result } = renderHook(() => useIsNotLoggedIn());
const isNotLoggedIn = result.current;
expect(isNotLoggedIn).toBe(false);
});
it('returns false if token is not there', () => {
mockedUseAuth0.mockReturnValue({
isAuthenticated: false,
user,
logout: jest.fn(),
loginWithRedirect: jest.fn(),
getAccessTokenWithPopup: jest.fn(),
getAccessTokenSilently: jest.fn(),
getIdTokenClaims: jest.fn(),
loginWithPopup: jest.fn(),
handleRedirectCallback: jest.fn(),
isLoading: false,
});
const { useIsNotLoggedIn } = AuthenticationHooks;
const { result } = renderHook(() => useIsNotLoggedIn());
const isNotLoggedIn = result.current;
expect(isNotLoggedIn).toBe(true);
});
it('returns false if token is there but user is not connected on auth0', () => {
mockedUseAuth0.mockReturnValue({
isAuthenticated: false,
user,
logout: jest.fn(),
loginWithRedirect: jest.fn(),
getAccessTokenWithPopup: jest.fn(),
getAccessTokenSilently: jest.fn(),
getIdTokenClaims: jest.fn(),
loginWithPopup: jest.fn(),
handleRedirectCallback: jest.fn(),
isLoading: false,
});
window.localStorage.setItem('accessToken', 'token');
const { useIsNotLoggedIn } = AuthenticationHooks;
const { result } = renderHook(() => useIsNotLoggedIn());
const isNotLoggedIn = result.current;
expect(isNotLoggedIn).toBe(true);
});
it('returns false if token is there and user is connected on auth0', () => {
mockedUseAuth0.mockReturnValue({
isAuthenticated: true,
user,
logout: jest.fn(),
loginWithRedirect: jest.fn(),
getAccessTokenWithPopup: jest.fn(),
getAccessTokenSilently: jest.fn(),
getIdTokenClaims: jest.fn(),
loginWithPopup: jest.fn(),
handleRedirectCallback: jest.fn(),
isLoading: false,
});
window.localStorage.setItem('accessToken', 'token');
const { useIsNotLoggedIn } = AuthenticationHooks;
const { result } = renderHook(() => useIsNotLoggedIn());
const isNotLoggedIn = result.current;
expect(isNotLoggedIn).toBe(false);
});
});

View File

@ -3,12 +3,48 @@ import ReactDOM from 'react-dom/client';
import './index.css'; import './index.css';
import App from './App'; import App from './App';
import { BrowserRouter } from 'react-router-dom'; import { BrowserRouter } from 'react-router-dom';
import { Auth0Provider } from '@auth0/auth0-react';
import {
ApolloClient,
InMemoryCache,
ApolloProvider,
createHttpLink,
} from '@apollo/client';
import { setContext } from '@apollo/client/link/context';
const httpLink = createHttpLink({ uri: process.env.REACT_APP_API_URL });
const authLink = setContext((_, { headers }) => {
const token = localStorage.getItem('accessToken');
return {
headers: {
...headers,
authorization: token ? `Bearer ${token}` : '',
},
};
});
const client = new ApolloClient({
link: authLink.concat(httpLink),
cache: new InMemoryCache(),
});
const root = ReactDOM.createRoot( const root = ReactDOM.createRoot(
document.getElementById('root') as HTMLElement, document.getElementById('root') as HTMLElement,
); );
root.render( root.render(
<BrowserRouter> <Auth0Provider
<App /> domain={process.env.REACT_APP_AUTH0_DOMAIN || ''}
</BrowserRouter>, clientId={process.env.REACT_APP_AUTH0_CLIENT_ID || ''}
authorizationParams={{
redirect_uri: process.env.REACT_APP_AUTH0_CALLBACK_URL || '',
audience: process.env.REACT_APP_AUTH0_AUDIENCE || '',
}}
>
<ApolloProvider client={client}>
<BrowserRouter>
<App />
</BrowserRouter>
</ApolloProvider>
</Auth0Provider>,
); );

View File

@ -9,12 +9,18 @@ const StyledLayout = styled.div`
type OwnProps = { type OwnProps = {
children: JSX.Element; children: JSX.Element;
user?: {
email: string;
first_name: string;
last_name: string;
tenant: { id: string; name: string };
};
}; };
function AppLayout({ children }: OwnProps) { function AppLayout({ children, user }: OwnProps) {
return ( return (
<StyledLayout> <StyledLayout>
<Navbar /> <Navbar user={user} />
<div>{children}</div> <div>{children}</div>
</StyledLayout> </StyledLayout>
); );

View File

@ -1,50 +1,68 @@
import styled from '@emotion/styled'; import styled from '@emotion/styled';
import { useMatch, useResolvedPath } from 'react-router-dom'; import { useMatch, useResolvedPath } from 'react-router-dom';
import NavItem from './NavItem'; import NavItem from './NavItem';
import ProfileContainer from './ProfileContainer';
const NavbarContainer = styled.div` const NavbarContainer = styled.div`
display: flex; display: flex;
flex-direction: row; flex-direction: row;
align-items: stretch; justify-content: space-between;
padding-left: 12px; padding-left: 12px;
height: 58px; height: 58px;
border-bottom: 2px solid #eaecee; border-bottom: 2px solid #eaecee;
`; `;
function Navbar() { const NavItemsContainer = styled.div`
display: flex;
flex-direction: row;
`;
type OwnProps = {
user?: {
email: string;
first_name: string;
last_name: string;
tenant: { id: string; name: string };
};
};
function Navbar({ user }: OwnProps) {
return ( return (
<> <>
<NavbarContainer> <NavbarContainer>
<NavItem <NavItemsContainer>
label="Inbox" <NavItem
to="/" label="Inbox"
active={ to="/"
!!useMatch({ active={
path: useResolvedPath('/').pathname, !!useMatch({
end: true, path: useResolvedPath('/').pathname,
}) end: true,
} })
/> }
<NavItem />
label="Contacts" <NavItem
to="/contacts" label="Contacts"
active={ to="/contacts"
!!useMatch({ active={
path: useResolvedPath('/contacts').pathname, !!useMatch({
end: true, path: useResolvedPath('/contacts').pathname,
}) end: true,
} })
/> }
<NavItem />
label="Insights" <NavItem
to="/insights" label="Insights"
active={ to="/insights"
!!useMatch({ active={
path: useResolvedPath('/insights').pathname, !!useMatch({
end: true, path: useResolvedPath('/insights').pathname,
}) end: true,
} })
/> }
/>
</NavItemsContainer>
<ProfileContainer user={user} />
</NavbarContainer> </NavbarContainer>
</> </>
); );

View File

@ -0,0 +1,74 @@
import styled from '@emotion/styled';
type OwnProps = {
user?: {
email: string;
first_name: string;
last_name: string;
tenant: { id: string; name: string };
};
};
const StyledContainer = styled.button`
display: flex;
height: 60px;
background: inherit;
align-items: center;
padding-left: 10px;
padding-right: 10px;
margin-left: 10px;
margin-right: 10px;
font-size: 14px;
margin-bottom: -2px;
cursor: pointer;
border: 0;
`;
const StyledInfoContainer = styled.div`
display: flex;
flex-direction: column;
`;
const StyledEmail = styled.div`
display: flex;
`;
const StyledTenant = styled.div`
display: flex;
text-transform: capitalize;
font-weight: bold;
`;
const StyledAvatar = styled.div`
display: flex;
width: 40px;
height: 40px;
border-radius: 40px;
background: black;
font-size: 20px;
color: white;
align-items: center;
justify-content: center;
font-weight: bold;
margin-right: 16px;
flex-shrink: 0;
`;
function ProfileContainer({ user }: OwnProps) {
return (
<StyledContainer>
<StyledAvatar>
{user?.first_name
.split(' ')
.map((n) => n[0])
.join('')}
</StyledAvatar>
<StyledInfoContainer>
<StyledEmail>{user?.email}</StyledEmail>
<StyledTenant>{user?.tenant.name}</StyledTenant>
</StyledInfoContainer>
</StyledContainer>
);
}
export default ProfileContainer;

View File

@ -9,6 +9,13 @@ export default {
export const NavbarOnInsights = () => ( export const NavbarOnInsights = () => (
<MemoryRouter initialEntries={['/insights']}> <MemoryRouter initialEntries={['/insights']}>
<Navbar /> <Navbar
user={{
email: 'charles@twenty.com',
first_name: 'Charles',
last_name: 'Bochet',
tenant: { id: '1', name: 'Twenty' },
}}
/>
</MemoryRouter> </MemoryRouter>
); );

View File

@ -0,0 +1,18 @@
import React, { useEffect } from 'react';
import AuthService from '../hooks/AuthenticationHooks';
function AuthCallback() {
const { useGetAccessToken } = AuthService;
const { token } = useGetAccessToken();
useEffect(() => {
if (token) {
window.location.href = '/';
}
}, [token]);
return <div></div>;
}
export default AuthCallback;

View File

@ -1,5 +1,3 @@
import styled from '@emotion/styled';
function PluginHistory() { function PluginHistory() {
return <div></div>; return <div></div>;
} }

6
infra/dev/.env.example Normal file
View File

@ -0,0 +1,6 @@
HASURA_GRAPHQL_JWT_SECRET=REPLACE_ME
HASURA_GRAPHQL_ADMIN_SECRET=hasura_secret
REACT_APP_AUTH0_DOMAIN=twenty-dev.eu.auth0.com
REACT_APP_AUTH0_CLIENT_ID=REPLACE_ME
REACT_APP_AUTH0_CALLBACK_URL=http://localhost:3001/auth/callback
REACT_APP_AUTH0_AUDIENCE=hasura-dev

View File

@ -1,5 +1,17 @@
version: "3.9" version: "3.9"
services: services:
twenty-front:
build:
context: ../..
dockerfile: ./infra/dev/twenty-front/Dockerfile
ports:
- "3001:3001"
- "6006:6006"
volumes:
- ../../front:/app/front
- ../../infra:/app/infra
depends_on:
- postgres
twenty-server: twenty-server:
build: build:
context: ../.. context: ../..
@ -20,6 +32,8 @@ services:
PG_DATABASE_URL: postgres://postgres:postgrespassword@postgres:5432/twenty PG_DATABASE_URL: postgres://postgres:postgrespassword@postgres:5432/twenty
HASURA_GRAPHQL_ENABLE_CONSOLE: "true" HASURA_GRAPHQL_ENABLE_CONSOLE: "true"
HASURA_GRAPHQL_DEV_MODE: "true" HASURA_GRAPHQL_DEV_MODE: "true"
HASURA_GRAPHQL_ADMIN_SECRET: ${HASURA_GRAPHQL_ADMIN_SECRET}
HASURA_GRAPHQL_JWT_SECRET: ${HASURA_GRAPHQL_JWT_SECRET}
HASURA_GRAPHQL_ENABLED_LOG_TYPES: startup, http-log, webhook-log, websocket-log, query-log HASURA_GRAPHQL_ENABLED_LOG_TYPES: startup, http-log, webhook-log, websocket-log, query-log
postgres: postgres:
build: ./postgres build: ./postgres

View File

@ -0,0 +1,10 @@
FROM node:18-alpine as app
WORKDIR /app
COPY ../.. .
WORKDIR /app/front
RUN npm install
RUN npm run build
CMD ["npm", "run", "start"]

View File

@ -3,10 +3,6 @@ FROM node:18-alpine as app
WORKDIR /app WORKDIR /app
COPY ../.. . COPY ../.. .
WORKDIR /app/front
RUN npm install
RUN npm run build
WORKDIR /app/server WORKDIR /app/server
RUN npm install RUN npm install
RUN npm run build RUN npm run build