├── src ├── styling │ ├── pages │ │ ├── accounts │ │ │ ├── login.scss │ │ │ └── register.scss │ │ └── profiles.scss │ ├── index.scss │ ├── components │ │ ├── button.scss │ │ ├── nav.scss │ │ ├── form.scss │ │ ├── card.scss │ │ └── layout.scss │ └── images │ │ └── header.svg ├── components │ ├── nav.js │ ├── form.js │ ├── card.js │ └── layout.js ├── index.js ├── util │ └── error-handling.js ├── context │ └── session.js ├── data │ ├── udf.fql │ └── fauna-queries.js ├── schema.gql ├── pages │ ├── register.js │ ├── login.js │ └── profiles.js ├── app.js └── service-worker.js ├── .env.local.example ├── public ├── robots.txt ├── favicon.ico ├── favicon-16x16.png ├── favicon-32x32.png ├── images │ ├── person1.png │ ├── person10.png │ ├── person2.png │ ├── person3.png │ ├── person4.png │ ├── person5.png │ ├── person6.png │ ├── person7.png │ ├── person8.png │ ├── person9.png │ ├── dino-error.png │ ├── dino-loading.gif │ ├── dino-profile.png │ ├── dino-noresults.png │ └── dino-notloggedin.png ├── manifest.json └── index.html ├── scripts ├── create-keys.js ├── create-collections.js ├── create-indexes.js ├── create-register-udf.js ├── create-login-udf.js ├── destroy.js ├── populate.js ├── setup.js └── create-roles.js ├── .gitignore ├── package.json └── README.md /src/styling/pages/accounts/login.scss: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /.env.local.example: -------------------------------------------------------------------------------- 1 | REACT_APP_BOOTSTRAP_FAUNADB_KEY= 2 | -------------------------------------------------------------------------------- /public/robots.txt: -------------------------------------------------------------------------------- 1 | # https://www.robotstxt.org/robotstxt.html 2 | User-agent: * 3 | -------------------------------------------------------------------------------- /public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/fauna-labs/professional-network-graphql/HEAD/public/favicon.ico -------------------------------------------------------------------------------- /public/favicon-16x16.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/fauna-labs/professional-network-graphql/HEAD/public/favicon-16x16.png -------------------------------------------------------------------------------- /public/favicon-32x32.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/fauna-labs/professional-network-graphql/HEAD/public/favicon-32x32.png -------------------------------------------------------------------------------- /public/images/person1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/fauna-labs/professional-network-graphql/HEAD/public/images/person1.png -------------------------------------------------------------------------------- /public/images/person10.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/fauna-labs/professional-network-graphql/HEAD/public/images/person10.png -------------------------------------------------------------------------------- /public/images/person2.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/fauna-labs/professional-network-graphql/HEAD/public/images/person2.png -------------------------------------------------------------------------------- /public/images/person3.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/fauna-labs/professional-network-graphql/HEAD/public/images/person3.png -------------------------------------------------------------------------------- /public/images/person4.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/fauna-labs/professional-network-graphql/HEAD/public/images/person4.png -------------------------------------------------------------------------------- /public/images/person5.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/fauna-labs/professional-network-graphql/HEAD/public/images/person5.png -------------------------------------------------------------------------------- /public/images/person6.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/fauna-labs/professional-network-graphql/HEAD/public/images/person6.png -------------------------------------------------------------------------------- /public/images/person7.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/fauna-labs/professional-network-graphql/HEAD/public/images/person7.png -------------------------------------------------------------------------------- /public/images/person8.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/fauna-labs/professional-network-graphql/HEAD/public/images/person8.png -------------------------------------------------------------------------------- /public/images/person9.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/fauna-labs/professional-network-graphql/HEAD/public/images/person9.png -------------------------------------------------------------------------------- /public/images/dino-error.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/fauna-labs/professional-network-graphql/HEAD/public/images/dino-error.png -------------------------------------------------------------------------------- /public/images/dino-loading.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/fauna-labs/professional-network-graphql/HEAD/public/images/dino-loading.gif -------------------------------------------------------------------------------- /public/images/dino-profile.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/fauna-labs/professional-network-graphql/HEAD/public/images/dino-profile.png -------------------------------------------------------------------------------- /public/images/dino-noresults.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/fauna-labs/professional-network-graphql/HEAD/public/images/dino-noresults.png -------------------------------------------------------------------------------- /public/images/dino-notloggedin.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/fauna-labs/professional-network-graphql/HEAD/public/images/dino-notloggedin.png -------------------------------------------------------------------------------- /src/styling/pages/accounts/register.scss: -------------------------------------------------------------------------------- 1 | .register-link, .register-link:hover { 2 | color: #6464ce; 3 | } 4 | 5 | .register { 6 | background-color: rgba(100, 100, 206, 0.9); 7 | border: 1px solid #6464ce; 8 | } 9 | 10 | .register:hover { 11 | background-color: rgba(80, 80, 206, 0.9); 12 | border: 1px solid #5050ce; 13 | } -------------------------------------------------------------------------------- /public/manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "short_name": "React App", 3 | "name": "Create React App Sample", 4 | "icons": [ 5 | { 6 | "src": "favicon.ico", 7 | "sizes": "64x64 32x32 24x24 16x16", 8 | "type": "image/x-icon" 9 | } 10 | ], 11 | "start_url": ".", 12 | "display": "standalone", 13 | "theme_color": "#000000", 14 | "background_color": "#ffffff" 15 | } 16 | -------------------------------------------------------------------------------- /scripts/create-keys.js: -------------------------------------------------------------------------------- 1 | const faunadb = require('faunadb') 2 | const { CreateKey, Role } = faunadb.query 3 | 4 | // Create the key for tbe bootstrap role 5 | // This key will only be able to call the 'login' and 'register' UDF functions, nothing more. 6 | const createBootstrapKey = CreateKey({ 7 | role: Role('keyrole_calludfs') 8 | }) 9 | 10 | module.exports = { createBootstrapKey } 11 | -------------------------------------------------------------------------------- /src/styling/index.scss: -------------------------------------------------------------------------------- 1 | 2 | // CSS 3 | @import "./components/button"; 4 | @import "./components/form"; 5 | @import "./components/layout"; 6 | @import "./components/card"; 7 | @import "./components/nav"; 8 | 9 | @import "./pages/profiles"; 10 | @import "./pages/accounts/login"; 11 | @import "./pages/accounts/register"; 12 | 13 | // CSS from libraries 14 | @import "react-toastify/dist/ReactToastify"; -------------------------------------------------------------------------------- /scripts/create-collections.js: -------------------------------------------------------------------------------- 1 | const faunadb = require('faunadb') 2 | const { CreateCollection } = faunadb.query 3 | 4 | // A FaunaDB collection is like an SQL table. 5 | // Creating a collection in FaunaDB is easy. 6 | const createAccounts = CreateCollection({ name: 'accounts' }) 7 | const createProfiles = CreateCollection({ name: 'profiles' }) 8 | 9 | module.exports = { createAccounts, createProfiles } 10 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files. 2 | 3 | # dependencies 4 | /node_modules 5 | /.pnp 6 | .pnp.js 7 | 8 | # testing 9 | /coverage 10 | 11 | # production 12 | /build 13 | 14 | # misc 15 | .DS_Store 16 | .env.local 17 | .env.development.local 18 | .env.test.local 19 | .env.production.local 20 | 21 | npm-debug.log* 22 | yarn-debug.log* 23 | yarn-error.log* 24 | 25 | /junk -------------------------------------------------------------------------------- /src/components/nav.js: -------------------------------------------------------------------------------- 1 | import React from "react" 2 | import { Link } from "react-router-dom" 3 | 4 | const links = [{ href: "/", label: "Profiles" }] 5 | 6 | const Nav = () => ( 7 | 16 | ) 17 | 18 | export default Nav 19 | -------------------------------------------------------------------------------- /src/index.js: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import ReactDOM from 'react-dom' 3 | 4 | import App from './app' 5 | import * as serviceWorker from './service-worker' 6 | 7 | import './styling/index.scss' 8 | 9 | ReactDOM.render(, document.getElementById('root')) 10 | 11 | // If you want your app to work offline and load faster, you can change 12 | // unregister() to register() below. Note this comes with some pitfalls. 13 | // Learn more about service workers: https://bit.ly/CRA-PWA 14 | serviceWorker.unregister() 15 | -------------------------------------------------------------------------------- /src/util/error-handling.js: -------------------------------------------------------------------------------- 1 | export const safelyExtractErrorCode = err => { 2 | try { 3 | console.log(err.requestResult.responseContent.errors[0].cause[0]) 4 | return err.requestResult.responseContent.errors[0].cause[0].code 5 | } catch (err) { 6 | return "Woops, unknown error" 7 | } 8 | } 9 | 10 | export const errorCodeToRegisterErrorMessage = errCode => { 11 | switch (errCode) { 12 | case "instance not unique": 13 | return "An account with this email already exists" 14 | default: 15 | return "Registration failed" 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /src/styling/components/button.scss: -------------------------------------------------------------------------------- 1 | button { 2 | background-color: #44c767; 3 | border-radius: 20px; 4 | border: 1px solid #18ab29; 5 | display: inline-block; 6 | cursor: pointer; 7 | color: #ffffff; 8 | font-size: 17px; 9 | padding: 10px 31px; 10 | margin-left: 20px; 11 | text-decoration: none; 12 | text-shadow: 0px 1px 0px #2f6627; 13 | } 14 | 15 | button:hover { 16 | background-color: #5cbf2a; 17 | } 18 | 19 | button:active { 20 | position: relative; 21 | top: 1px; 22 | } 23 | 24 | button:focus { 25 | outline: none; 26 | } 27 | -------------------------------------------------------------------------------- /src/context/session.js: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | 3 | const SessionContext = React.createContext({}) 4 | 5 | export const sessionReducer = (state, action) => { 6 | switch (action.type) { 7 | case 'login': { 8 | return { user: action.data } 9 | } 10 | case 'logout': { 11 | return { user: null } 12 | } 13 | default: { 14 | throw new Error(`Unhandled action type: ${action.type}`) 15 | } 16 | } 17 | } 18 | 19 | export const SessionProvider = SessionContext.Provider 20 | export const SessionConsumer = SessionContext.Consumer 21 | export default SessionContext 22 | -------------------------------------------------------------------------------- /src/data/udf.fql: -------------------------------------------------------------------------------- 1 | // Call with: 2 | // Call(Function('login'), '', '') 3 | 4 | Query(Lambda(['email', 'password'], 5 | Select( 6 | ['secret'], 7 | Login(Match(Index('accountsByEmail'), Var('email')), { 8 | password: Var('password') 9 | }) 10 | ) 11 | )) 12 | 13 | 14 | 15 | // Call with: 16 | // Call(Function('login'), '', '') 17 | 18 | Query(Lambda(['email', 'password'], 19 | Create(Collection('Account'), { 20 | credentials: { password: Var('password') }, 21 | data: { 22 | email: Var('email') 23 | } 24 | }) 25 | )) -------------------------------------------------------------------------------- /src/schema.gql: -------------------------------------------------------------------------------- 1 | 2 | type Account { 3 | email: String! @unique 4 | } 5 | 6 | type Profile { 7 | name: String! 8 | icon: String! 9 | account: Account! @relation 10 | skills: [ Skill ! ] @relation 11 | projects: [ Project! ] @relation 12 | } 13 | 14 | type Project { 15 | name: String! 16 | profile: [ Profile! ] @relation 17 | } 18 | 19 | type Skill { 20 | profiles: [ Profile! ] @relation 21 | name: String! 22 | } 23 | 24 | type Query { 25 | allProfiles: [Profile!] 26 | accountsByEmail(email: String!): [Account!]! 27 | skillsByName(name: String!): [Skill!]! 28 | } 29 | 30 | type Mutation { 31 | register(email: String!, password: String!): Account! @resolver 32 | login(email: String!, password: String!): String! @resolver 33 | } 34 | 35 | -------------------------------------------------------------------------------- /src/pages/register.js: -------------------------------------------------------------------------------- 1 | import React from "react" 2 | import { toast } from "react-toastify" 3 | import { register } from "./../data/fauna-queries" 4 | import { 5 | safelyExtractErrorCode, 6 | errorCodeToRegisterErrorMessage 7 | } from "./../util/error-handling" 8 | 9 | // Components 10 | import Form from "./../components/form" 11 | 12 | const handleRegister = (event, username, password) => { 13 | register(username, password) 14 | .then(e => { 15 | toast.success("User registered") 16 | }) 17 | .catch(err => { 18 | console.error("error on register", err) 19 | const errorCode = safelyExtractErrorCode(err) 20 | toast.error(errorCodeToRegisterErrorMessage(errorCode)) 21 | }) 22 | event.preventDefault() 23 | } 24 | 25 | const Login = () => { 26 | return
27 | } 28 | 29 | export default Login 30 | -------------------------------------------------------------------------------- /src/styling/components/nav.scss: -------------------------------------------------------------------------------- 1 | nav { 2 | display: flex; 3 | margin-top: 50px; 4 | text-align: center; 5 | height: 50px; 6 | border-bottom: 1px solid rgba(0, 0, 0, 0.1); 7 | width: 100%; 8 | } 9 | 10 | nav ul { 11 | display: flex; 12 | flex-direction: row; 13 | width: 100%; 14 | justify-content: space-between; 15 | margin: 0; 16 | } 17 | 18 | nav ul li { 19 | display: flex; 20 | padding: 6px 8px; 21 | justify-content: center; 22 | align-items: center; 23 | } 24 | 25 | nav a { 26 | border-radius: 20px; 27 | border: 1px solid #18ab29; 28 | display: inline-block; 29 | cursor: pointer; 30 | color: #ffffff; 31 | font-size: 17px; 32 | padding: 10px 31px; 33 | text-decoration: none; 34 | text-shadow: 0px 1px 0px #2f6627; 35 | background-color: #44c767; 36 | } 37 | 38 | nav a:hover { 39 | color: white; 40 | } 41 | -------------------------------------------------------------------------------- /scripts/create-indexes.js: -------------------------------------------------------------------------------- 1 | const faunadb = require('faunadb') 2 | const { CreateIndex, Collection } = faunadb.query 3 | 4 | // FaunaDB protects you from inefficient access, in FaunaDB you can't query without an index. 5 | // In order to login with the e-mail we need to be able to retrieve users by e-mail. 6 | 7 | const createIndexAccountsByEmail = CreateIndex({ 8 | name: 'accounts_by_email', 9 | source: Collection('Account'), 10 | // We will search on email 11 | terms: [ 12 | { 13 | field: ['data', 'email'] 14 | } 15 | ], 16 | // if no values are added, the index will just return the reference. 17 | values: [], 18 | // Prevent that accounts with duplicate e-mails are made. 19 | // uniqueness works on the combination of terms/values 20 | unique: true 21 | }) 22 | 23 | const createDefaultProfilesIndex = CreateIndex({ 24 | name: 'all_profiles', 25 | source: Collection('profiles'), 26 | // this is a default index to paginate over in 'ref' order. 27 | terms: [], 28 | values: [] 29 | }) 30 | 31 | module.exports = { createIndexAccountsByEmail, createDefaultProfilesIndex } 32 | -------------------------------------------------------------------------------- /src/pages/login.js: -------------------------------------------------------------------------------- 1 | import React, { useContext } from 'react' 2 | import { useHistory } from 'react-router-dom' 3 | import { toast } from 'react-toastify' 4 | 5 | import SessionContext from './../context/session' 6 | import { login } from '../data/fauna-queries' 7 | 8 | // Components 9 | import Form from '../components/form' 10 | 11 | const handleLogin = (event, username, password, history, sessionContext) => { 12 | login(username, password) 13 | .then(() => { 14 | toast.success('Login successful') 15 | sessionContext.dispatch({ type: 'login', data: { user: username } }) 16 | history.push('/') 17 | }) 18 | .catch(err => { 19 | console.error('error on login', err) 20 | toast.error('Login failed') 21 | }) 22 | event.preventDefault() 23 | } 24 | 25 | const Login = props => { 26 | const history = useHistory() 27 | const sessionContext = useContext(SessionContext) 28 | 29 | return ( 30 |
handleLogin(event, username, password, history, sessionContext)} 33 | >
34 | ) 35 | } 36 | 37 | export default Login 38 | -------------------------------------------------------------------------------- /src/styling/components/form.scss: -------------------------------------------------------------------------------- 1 | .account-form-container { 2 | display: flex; 3 | justify-content: center; 4 | align-items: center; 5 | width: 100%; 6 | height: 600px; 7 | } 8 | 9 | .account-form { 10 | display: flex; 11 | flex-direction: column; 12 | justify-content: center; 13 | align-items: center; 14 | width: 60%; 15 | height: 400px; 16 | background-color: rgba(225, 223, 249, 0.1); 17 | border: 1px solid #e1dff9; 18 | border-radius: 10px; 19 | padding: 20px; 20 | } 21 | 22 | .input-row { 23 | display: flex; 24 | margin: 20px; 25 | max-width: 600px; 26 | min-width: 250px; 27 | width: calc(100% - 20px); 28 | justify-content: center; 29 | align-items: center; 30 | } 31 | 32 | .input-row-column { 33 | display: flex; 34 | flex: 1 1 0; 35 | width: 0px; 36 | align-items: center; 37 | font-size: 20px; 38 | } 39 | 40 | input[type="submit"], input[type="file"], input[type="text"], input[type="password"], input:not([type]), select, textarea { 41 | border: 1px solid #aaaaaa; 42 | color: #000000; 43 | height: 20px; 44 | padding: 10px; 45 | border-radius: 20px; 46 | outline: none; 47 | font-size: 15px; 48 | } 49 | 50 | .align-right { 51 | justify-content: flex-end; 52 | } -------------------------------------------------------------------------------- /src/app.js: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import { BrowserRouter as Router, Route, Switch } from 'react-router-dom' 3 | 4 | import Profiles from './pages/profiles' 5 | import Login from './pages/login' 6 | import Register from './pages/register' 7 | import Layout from './components/layout' 8 | import { SessionProvider, sessionReducer } from './context/session' 9 | 10 | const App = () => { 11 | const [state, dispatch] = React.useReducer(sessionReducer, { user: null }) 12 | 13 | // Return the header and either show an error or render the loaded profiles. 14 | return ( 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | {/* 29 | 30 | 31 | 32 | 33 | */} 34 | 35 | 36 | 37 | 38 | ) 39 | } 40 | 41 | export default App 42 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "professional-network", 3 | "version": "0.1.0", 4 | "private": true, 5 | "dependencies": { 6 | "@testing-library/jest-dom": "^5.13.0", 7 | "@testing-library/react": "^11.2.7", 8 | "@testing-library/user-event": "^13.1.9", 9 | "custom-env": "^2.0.1", 10 | "dotenv": "^10.0.0", 11 | "faker": "^5.5.3", 12 | "faunadb": "^4.3.0", 13 | "graphql-request": "^3.4.0", 14 | "node-fetch": "^2.6.0", 15 | "react": "^17.0.2", 16 | "react-dom": "^17.0.2", 17 | "react-router-dom": "^5.1.2", 18 | "react-scripts": "^4.0.3", 19 | "react-toastify": "^7.0.4", 20 | "readline-promise": "^1.0.4", 21 | "sass": "^1.34.1" 22 | }, 23 | "scripts": { 24 | "start": "react-scripts start", 25 | "build": "react-scripts build", 26 | "test": "react-scripts test", 27 | "eject": "react-scripts eject", 28 | "setup": "node ./scripts/setup.js", 29 | "destroy": "node ./scripts/destroy.js", 30 | "populate": "node ./scripts/populate.js" 31 | }, 32 | "eslintConfig": { 33 | "extends": "react-app" 34 | }, 35 | "browserslist": { 36 | "production": [ 37 | ">0.2%", 38 | "not dead", 39 | "not op_mini all" 40 | ], 41 | "development": [ 42 | "last 1 chrome version", 43 | "last 1 firefox version", 44 | "last 1 safari version" 45 | ] 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /scripts/create-register-udf.js: -------------------------------------------------------------------------------- 1 | const faunadb = require('faunadb') 2 | const { CreateFunction, Create, Query, Lambda, Var, Role, Collection } = faunadb.query 3 | 4 | // Create an FQL Lambda (a fancy name for function) with two parameters 5 | // This function creates a new account document in the database that is 6 | // secured by credentials. For more info: https://docs.fauna.com/fauna/current/tutorials/authentication/user.html#create 7 | const registerLambda = Lambda( 8 | ['email', 'password'], 9 | Create(Collection('Account'), { 10 | credentials: { password: Var('password') }, 11 | data: { 12 | email: Var('email') 13 | } 14 | }) 15 | ) 16 | 17 | // UDF stands for User Defined Function. 18 | // This is a function that we will enter in FaunaDB, it can later 19 | // be called using: Call(Function('login'), '', '') 20 | const createRegisterUDF = CreateFunction({ 21 | name: 'register', 22 | // The lambda itself is in a separate JavaScript variable so that we can unit test it separately. 23 | // Since FQL is constructed using plain JavaScript functions, this kind of composition is possible 24 | body: Query(registerLambda), 25 | // Note that a function receives a role. 26 | // Roles define what the function is allowed to do, 27 | // in this case, the function should be able to create an account! 28 | role: Role('functionrole_register') 29 | }) 30 | 31 | module.exports = { createRegisterUDF, registerLambda } 32 | -------------------------------------------------------------------------------- /src/styling/pages/profiles.scss: -------------------------------------------------------------------------------- 1 | .profiles { 2 | display: flex; 3 | width: 100%; 4 | min-height: 500px; 5 | flex-direction: row; 6 | flex-wrap: wrap; 7 | } 8 | 9 | .no-results-container { 10 | display: flex; 11 | flex-direction: column; 12 | width: 100%; 13 | justify-content: center; 14 | align-items: center; 15 | margin: 50px; 16 | } 17 | 18 | .no-results-image { 19 | height: 200px; 20 | margin-top: 50px; 21 | } 22 | 23 | .no-results-text { 24 | font-size: 30px; 25 | color: rgba(0, 0, 0, 0.5); 26 | font-weight: 300; 27 | } 28 | 29 | .no-results-subtext { 30 | font-size: 19px; 31 | color: rgba(0, 0, 0, 0.5); 32 | font-weight: 300; 33 | } 34 | 35 | .spacer { 36 | display: flex; 37 | flex: 1 1 auto; 38 | } 39 | .previous-button, .next-button{ 40 | background-color: rgba(0,0,0,0.8); 41 | color: rgba(255,255,255,0.8); 42 | padding: 7px; 43 | padding-left: 12px; 44 | padding-right: 12px; 45 | border-radius: 20px; 46 | margin: 10px; 47 | margin-left: 50px; 48 | margin-right: 50px; 49 | cursor: pointer; 50 | } 51 | 52 | 53 | .search { 54 | width: 30%; 55 | display: flex; 56 | } 57 | 58 | .searchTerm { 59 | width: 100%; 60 | border: 3px solid #00B4CC; 61 | border-right: none; 62 | padding: 5px; 63 | margin: 20px; 64 | height: 20px; 65 | border-radius: 5px 0 0 5px; 66 | outline: none; 67 | color: #9DBFAF; 68 | } 69 | 70 | .searchTerm:focus{ 71 | color: #00B4CC; 72 | } 73 | -------------------------------------------------------------------------------- /scripts/create-login-udf.js: -------------------------------------------------------------------------------- 1 | const faunadb = require('faunadb') 2 | const { CreateFunction, Query, Lambda, Login, Match, Index, Var, Get, Select, Role, Let } = faunadb.query 3 | 4 | // Create an FQL Lambda (a fancy name for function) with two parameters 5 | // This function retrieves the id of the account document by e-mail and since that 6 | // document is secured with credentials we can use the 'Login' to get a token that assumes that documents 7 | // identity. Afterwards we get the account and return both the account and the token. 8 | // secured by credentials. For more info: https://docs.fauna.com/fauna/current/tutorials/authentication/user.html#create 9 | const loginLambda = Lambda( 10 | ['email', 'password'], 11 | Select( 12 | ['secret'], 13 | Login(Match(Index('accountsByEmail'), Var('email')), { 14 | password: Var('password') 15 | }) 16 | ) 17 | ) 18 | 19 | // UDF stands for User Defined Function. 20 | // This is a function that we will enter in FaunaDB, it can later 21 | // be called using: Call(Function('login'), '', '') 22 | const createLoginUDF = CreateFunction({ 23 | name: 'login', 24 | // The lambda itself is in a separate JavaScript variable so that we can unit test it separately. 25 | // Since FQL is constructed using plain JavaScript functions, this kind of composition is possible 26 | body: Query(loginLambda), 27 | // Note that a function receives a role. 28 | // Roles define what the function is allowed to do, 29 | // in this case it will need to use the accounts_by_email index so the role provides access to it. 30 | role: Role('functionrole_login') 31 | }) 32 | 33 | module.exports = { createLoginUDF, loginLambda } 34 | -------------------------------------------------------------------------------- /public/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 12 | 13 | 17 | 18 | 27 | React App 28 | 29 | 30 | 31 |
32 | 42 | 43 | 44 | -------------------------------------------------------------------------------- /src/components/form.js: -------------------------------------------------------------------------------- 1 | import React, { useState } from "react" 2 | import { Link } from "react-router-dom" 3 | 4 | const Form = props => { 5 | const [username, setUsername] = useState("") 6 | const [password, setPassword] = useState("") 7 | 8 | const handleChangeUserName = (setPassword, event) => { 9 | setUsername(event.target.value) 10 | } 11 | 12 | const handleChangePassword = (setPassword, event) => { 13 | setPassword(event.target.value) 14 | } 15 | 16 | const linkInfo = props.isLogin 17 | ? { linkText: "No account yet? Register here!", link: "register" } 18 | : { linkText: "Already an account? Login here!", link: "login" } 19 | 20 | return ( 21 | 22 |
23 |
props.handleSubmit(e, username, password)} 26 | > 27 | {renderInputField("Email", username, "text", e => 28 | handleChangeUserName(setUsername, e) 29 | )} 30 | {renderInputField("Password", password, "password", e => 31 | handleChangePassword(setPassword, e) 32 | )} 33 |
34 | {linkInfo.linkText} 35 | 39 |
40 |
41 |
42 |
43 | ) 44 | } 45 | 46 | const renderInputField = (name, value, type, fun) => { 47 | const lowerCaseName = name.toLowerCase() 48 | return ( 49 |
50 | {name} 51 | 60 |
61 | ) 62 | } 63 | 64 | export default Form 65 | -------------------------------------------------------------------------------- /src/components/card.js: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import PropTypes from 'prop-types' 3 | // import { Link } from "react-router-dom" 4 | 5 | const Card = props => { 6 | const icon = props.icon ? props.icon.toLowerCase() : 'person1' 7 | 8 | return ( 9 | // 14 |
15 |
16 | profile 17 |
18 |
19 |
20 |

{props.name}

21 |

Skills

22 |
    {listSkills(props.skills.data, props.profileKey)}
23 |

Projects

24 |
    {listProjects(props.projects.data, props.profileKey)}
25 |
26 |
27 |
28 | // 29 | ) 30 | } 31 | 32 | const listSkills = (skills, profileKey) => { 33 | return skills ? ( 34 | skills.map((skill, index) => ( 35 |
  • 36 | {' '} 37 | {skill.name}{' '} 38 |
  • 39 | )) 40 | ) : ( 41 |

    42 | ) 43 | } 44 | 45 | const listProjects = (projects, profileKey) => { 46 | return projects ? ( 47 | projects.map((skill, index) => ( 48 |
  • 49 | {' '} 50 | {skill.name}{' '} 51 |
  • 52 | )) 53 | ) : ( 54 |

    55 | ) 56 | } 57 | 58 | Card.propTypes = { 59 | profileKey: PropTypes.number, 60 | name: PropTypes.string, 61 | icon: PropTypes.string, 62 | description: PropTypes.string, 63 | skills: PropTypes.object, 64 | projects: PropTypes.object 65 | } 66 | 67 | export default Card 68 | -------------------------------------------------------------------------------- /src/styling/components/card.scss: -------------------------------------------------------------------------------- 1 | .profile-card { 2 | --card-width: 200px; 3 | --card-height: 400px; 4 | --picture-width: 80px; 5 | --picture-height: 80px; 6 | position: relative; 7 | display: flex; 8 | flex-direction: column; 9 | font-size: 13px; 10 | width: var(--card-width); 11 | margin: 30px; 12 | cursor: pointer; 13 | height: var(--card-height); 14 | } 15 | 16 | .profile-card:hover .profile-content { 17 | border: 1px solid rgba(0, 0, 0, 0.4); 18 | } 19 | 20 | .profile-picture { 21 | position: absolute; 22 | display: flex; 23 | width: var(--picture-width); 24 | height: var(--picture-height); 25 | top: 0xp; 26 | left: calc(var(--card-width) / 2 - var(--picture-width) / 2); 27 | z-index: 10; 28 | } 29 | 30 | .profile-picture img { 31 | width: var(--picture-width); 32 | } 33 | 34 | .profile-card-text-container { 35 | position: absolute; 36 | display: flex; 37 | width: 100%; 38 | height: calc(100% - var(--picture-height) / 2); 39 | top: calc(var(--picture-height) / 2); 40 | left: 0px; 41 | border-radius: 10px; 42 | justify-content: center; 43 | background-color: rgba(0, 0, 0, 0.03); 44 | border: 1px solid rgba(0, 0, 0, 0.03); 45 | font-size: 13px; 46 | } 47 | 48 | .profile-card-text { 49 | display: flex; 50 | flex-direction: column; 51 | margin-top: 30px; 52 | height: calc(90% - 40px); 53 | width: 85%; 54 | } 55 | 56 | .profile-name { 57 | display: flex; 58 | justify-content: center; 59 | font-size: 20px; 60 | width: 100%; 61 | } 62 | 63 | .profile-description { 64 | display: flex; 65 | font-size: 16px; 66 | margin-top: 10px; 67 | } 68 | 69 | .profile-languages { 70 | display: flex; 71 | font-size: 15px; 72 | margin: 0px; 73 | } 74 | 75 | .profile-language-list { 76 | display: flex; 77 | flex-direction: column; 78 | font-size: 15px; 79 | margin: 0px; 80 | } 81 | 82 | .profile-projects-list { 83 | display: flex; 84 | flex-direction: column; 85 | font-size: 15px; 86 | margin: 0px; 87 | padding: 0px; 88 | } 89 | 90 | .profile-project { 91 | background-color: rgba(0,0,0,0.9); 92 | list-style-type: none; 93 | border-radius: 5px; 94 | color: rgba(255,255,255,0.8); 95 | margin: 5px; 96 | padding: 5px; 97 | 98 | } -------------------------------------------------------------------------------- /scripts/destroy.js: -------------------------------------------------------------------------------- 1 | const faunadb = require('faunadb') 2 | const readline = require('readline-promise').default 3 | 4 | const { Map, Collections, Documents, Paginate, Lambda, Functions, Roles, Indexes, Delete, Var, Tokens } = faunadb.query 5 | 6 | const keyQuestion = `----- Please provide the FaunaDB admin key) ----- 7 | An admin key is powerful, it should only be used for the setup script, not to run your application! 8 | At the end of the script a key with limited privileges will be returned that should be used to run your application 9 | Enter your key:` 10 | 11 | const main = async () => { 12 | let serverKey = '' 13 | const interactiveSession = readline.createInterface({ 14 | input: process.stdin, 15 | output: process.stdout 16 | }) 17 | await interactiveSession.questionAsync(keyQuestion).then(key => { 18 | serverKey = key 19 | interactiveSession.close() 20 | }) 21 | const client = new faunadb.Client({ secret: serverKey }) 22 | return deleteAll(client) 23 | } 24 | 25 | const deleteAll = async client => { 26 | try { 27 | const collections = await deleteAllCollections(client) 28 | const functions = await deleteAllFunctions(client) 29 | const roles = await deleteAllRoles(client) 30 | const indexes = await deleteIndexes(client) 31 | const tokens = await deleteTokens(client) 32 | 33 | console.log(`Deleted: 34 | 1. collections: ${collections.data.length} 35 | 2. functions: ${functions.data.length} 36 | 3. roles: ${roles.data.length}, 37 | 4. indexes: ${indexes.data.length}, 38 | 5. tokens: ${tokens.data.length}`) 39 | } catch (err) { 40 | console.log('Error', err) 41 | } 42 | } 43 | 44 | const deleteAllCollections = async client => { 45 | return client.query(Map(Paginate(Collections()), Lambda('ref', Delete(Var('ref'))))) 46 | } 47 | 48 | const deleteAllFunctions = async client => { 49 | return client.query(Map(Paginate(Functions()), Lambda('ref', Delete(Var('ref'))))) 50 | } 51 | 52 | const deleteAllRoles = async client => { 53 | return client.query(Map(Paginate(Roles()), Lambda('ref', Delete(Var('ref'))))) 54 | } 55 | 56 | const deleteIndexes = async client => { 57 | return client.query(Map(Paginate(Indexes()), Lambda('ref', Delete(Var('ref'))))) 58 | } 59 | 60 | const deleteTokens = async client => { 61 | return client.query(Map(Paginate(Documents(Tokens())), Lambda('ref', Delete(Var('ref'))))) 62 | } 63 | 64 | main() 65 | -------------------------------------------------------------------------------- /src/components/layout.js: -------------------------------------------------------------------------------- 1 | import React, { useContext } from 'react' 2 | import Nav from './nav' 3 | import PropTypes from 'prop-types' 4 | import { ToastContainer, toast } from 'react-toastify' 5 | import { Link } from 'react-router-dom' 6 | import SessionContext from './../context/session' 7 | // import { logout } from '../data/fauna-queries' 8 | 9 | const Layout = props => { 10 | const sessionContext = useContext(SessionContext) 11 | 12 | return ( 13 |
    14 | 15 | 19 | {renderHeader(sessionContext)} 20 |
    21 |
    24 |
    25 | ) 26 | } 27 | 28 | const renderHeader = sessionContext => { 29 | return ( 30 |
    31 |
    {renderLoginLink(sessionContext)}
    32 | 33 |
    34 |
    35 |
    36 |
    37 |
    38 | ) 39 | } 40 | 41 | const renderLoginLink = sessionContext => { 42 | const { user } = sessionContext.state 43 | 44 | if (user) { 45 | return ( 46 |
    47 | {/* Logout onClick={event => handleLogout(event, sessionContext)} */} 48 |
    49 | login 50 | {/*
    {user.email}
    */} 51 |
    52 |
    53 | ) 54 | } else { 55 | return ( 56 | 57 | Login/Register 58 | 59 | ) 60 | } 61 | } 62 | 63 | // const handleLogout = (event, sessionContext) => { 64 | // return logout().then(() => { 65 | // toast.success('logged out') 66 | // sessionContext.dispatch({ type: 'logout', data: null }) 67 | // event.preventDefault() 68 | // }) 69 | // } 70 | Layout.propTypes = { 71 | children: PropTypes.node 72 | } 73 | 74 | export default Layout 75 | -------------------------------------------------------------------------------- /src/styling/images/header.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | -------------------------------------------------------------------------------- /src/data/fauna-queries.js: -------------------------------------------------------------------------------- 1 | 2 | let secret = process.env.REACT_APP_BOOTSTRAP_FAUNADB_KEY 3 | 4 | // Queries 5 | 6 | const getProfiles = async function(toLoad, prevBefore, prevAfter) { 7 | let cursor = `` 8 | if (toLoad === 'prev') { 9 | cursor = `, _cursor: "${prevBefore}"` 10 | } else if (toLoad === 'next') { 11 | cursor = `, _cursor: "${prevAfter}"` 12 | } 13 | 14 | const query = ` 15 | ... to be filled in ... 16 | ` 17 | return executeQuery(query).then(result => { 18 | if(result.errors){ 19 | console.error(result.errors) 20 | return [] 21 | } 22 | else { 23 | return result.data.allProfiles 24 | } 25 | }).catch((err) => { 26 | console.error('error executing query', err) 27 | }) 28 | } 29 | 30 | const getProfilesBySkill = function(skill, toLoad, prevBefore, prevAfter) { 31 | let cursor = `` 32 | if (toLoad === 'prev') { 33 | cursor = `, _cursor: "${prevBefore}"` 34 | } else if (toLoad === 'next') { 35 | cursor = `, _cursor: "${prevAfter}"` 36 | } 37 | 38 | const query = ` 39 | ... to be filled in ... 40 | ` 41 | 42 | return executeQuery(query).then(result => { 43 | if(result.errors){ 44 | console.error(result.errors) 45 | return [] 46 | } 47 | else if (result.data && result.data.skillsByName && result.data.skillsByName.data) { 48 | if (result.data.skillsByName.data.length === 0) { 49 | return [] 50 | } 51 | return { 52 | data: result.data.skillsByName.data.map(el => el.profiles.data).flat(), 53 | before: result.before, 54 | after: result.after 55 | } 56 | } else { 57 | return [] 58 | } 59 | }) 60 | .catch((err) => { 61 | console.error('error executing query', err) 62 | }) 63 | } 64 | 65 | /* In the FaunaDB console at www.fauna.com 66 | * we have defined two User Defined Functions (UDF) called login and register. 67 | * These can only be called by a user that is not registered yet (has the UnregisteredRole) 68 | * Once Login is called, it will return a secret which will be used further on to query. 69 | */ 70 | const register = function(email, password) { 71 | const query = ` ... to be filled in ... ` 72 | return executeQuery(query).then(result => { 73 | console.log(result) 74 | 75 | return result.data.register 76 | }) 77 | } 78 | 79 | const login = async function(email, password) { 80 | const query = ` ... to be filled in ... ` 81 | return executeQuery(query).then(result => { 82 | console.log('login result', result) 83 | secret = result.data.login 84 | return secret 85 | }) 86 | } 87 | 88 | const executeQuery = async function(query) { 89 | return fetch('https://graphql.fauna.com/graphql', { 90 | method: 'POST', 91 | headers: { 92 | Authorization: 'Bearer ' + secret, 93 | 'Content-Type': 'application/json', 94 | Accept: 'application/json' 95 | }, 96 | body: JSON.stringify({ query: query }) 97 | }).then(el => { 98 | const res = el.json() 99 | return res 100 | }) 101 | .catch((err) => { 102 | console.log(err) 103 | }) 104 | } 105 | 106 | export { getProfiles, getProfilesBySkill, register, login } 107 | -------------------------------------------------------------------------------- /scripts/populate.js: -------------------------------------------------------------------------------- 1 | const faker = require('faker') 2 | const faunadb = require('faunadb') 3 | const readline = require('readline-promise').default 4 | const fetch = require('node-fetch') 5 | 6 | const keyQuestion = `----- Please provide the FaunaDB admin key) ----- 7 | An admin key is powerful, it should only be used for the setup script, not to run your application! 8 | At the end of the script a key with limited privileges will be returned that should be used to run your application 9 | Enter your key:` 10 | 11 | const programmingLanguages = [ 12 | 'Elixir', 13 | 'Haskell', 14 | 'Erlang', 15 | 'Lisp', 16 | 'Prolog', 17 | 'FQL', 18 | 'Python', 19 | 'JavaScript', 20 | 'Scala', 21 | 'Java', 22 | 'C#', 23 | 'C++', 24 | 'C' 25 | ] 26 | 27 | const images = [ 28 | 'person1', 29 | 'person2', 30 | 'person3', 31 | 'person4', 32 | 'person5', 33 | 'person6', 34 | 'person7', 35 | 'person8', 36 | 'person9', 37 | 'person10' 38 | ] 39 | 40 | const numberOfProfiles = 20 41 | const main = async () => { 42 | let serverKey = '' 43 | 44 | const executeQuery = async function(query) { 45 | return fetch('https://graphql.fauna.com/graphql', { 46 | method: 'POST', 47 | headers: { 48 | Authorization: 'Bearer ' + serverKey, 49 | 'Content-Type': 'application/json', 50 | Accept: 'application/json' 51 | }, 52 | body: JSON.stringify({ query: query }) 53 | }).then(el => el.json()) 54 | } 55 | const interactiveSession = readline.createInterface({ 56 | input: process.stdin, 57 | output: process.stdout 58 | }) 59 | await interactiveSession.questionAsync(keyQuestion).then(key => { 60 | serverKey = key 61 | interactiveSession.close() 62 | }) 63 | const client = new faunadb.Client({ secret: serverKey }) 64 | 65 | const list = [] 66 | for (var i = 0; i < numberOfProfiles; i++) { 67 | list.push(i) 68 | } 69 | 70 | return Promise.all( 71 | list.map(() => { 72 | const avatar = getRandom(images, 1)[0] 73 | const skills = getRandom(programmingLanguages, Math.floor(3 * Math.random())) 74 | const projects = randomProjects(2) 75 | const query = `mutation CreateProfile { 76 | createProfile(data: { 77 | name: "${faker.name.findName()}" 78 | icon: "${avatar}" 79 | projects:{ 80 | create: [${projects 81 | .map(el => { 82 | return `{ name: "${el}"}` 83 | }) 84 | .join(',')}] 85 | } 86 | skills: { 87 | create:[${skills 88 | .map(el => { 89 | return `{ name: "${el}"}` 90 | }) 91 | .join(',')}] 92 | } 93 | } 94 | ) 95 | { _id } 96 | }` 97 | console.log(query) 98 | return executeQuery(query).then(result => { 99 | console.log(result) 100 | return result 101 | }) 102 | }) 103 | ).catch(err => { 104 | console.log(err) 105 | }) 106 | } 107 | 108 | const randomProjects = n => { 109 | const arr = [] 110 | const amount = Math.random(n) 111 | for (var i = 0; i < amount; i++) { 112 | arr.push(faker.commerce.productName()) 113 | } 114 | return arr 115 | } 116 | 117 | const getRandom = (arr, n) => { 118 | var result = new Array(n), 119 | len = arr.length, 120 | taken = new Array(len) 121 | if (n > len) throw new RangeError('getRandom: more elements taken than available') 122 | while (n--) { 123 | var x = Math.floor(Math.random() * len) 124 | result[n] = arr[x in taken ? taken[x] : x] 125 | taken[x] = --len in taken ? taken[len] : len 126 | } 127 | return result 128 | } 129 | 130 | main() 131 | -------------------------------------------------------------------------------- /scripts/setup.js: -------------------------------------------------------------------------------- 1 | // This script sets up the database to be used for this example application. 2 | // Look at the code to see what is behind the magic 3 | const faunadb = require('faunadb') 4 | const readline = require('readline-promise').default 5 | 6 | // We separated all the different things in separate files to keep an overview. 7 | // const { createAccounts, createProfiles } = require('./create-collections') 8 | // const { createIndexAccountsByEmail } = require('./create-indexes') 9 | const { createFnRoleRegister, createFnRoleLogin, createBootstrapRole, createLoggedInRole } = require('./create-roles') 10 | const { createBootstrapKey } = require('./create-keys') 11 | 12 | const keyQuestion = `----- 1. Please provide the FaunaDB admin key) ----- 13 | An admin key is powerful, it should only be used for the setup script, not to run your application! 14 | At the end of the script a key with limited privileges will be returned that should be used to run your application 15 | Enter your key:` 16 | 17 | const explanation = ` 18 | Thanks! 19 | This script will (Do not worry! It will all do this for you): 20 | - Setup the user defined functions 'login and register' 21 | - Create roles that the user defined functions will assume 22 | - Create a role for the initial key which can only call login/register 23 | - Create a role for an account to assume (database entities can assume roles, using Login a key can be retrieved for such an entity) 24 | (take a look at scripts/setup.js if it interests you what it does) 25 | ` 26 | 27 | const main = async () => { 28 | // In order to set up a database, we need a admin key, so let's ask the user for a key. 29 | let serverKey = '' 30 | const interactiveSession = readline.createInterface({ 31 | input: process.stdin, 32 | output: process.stdout 33 | }) 34 | await interactiveSession.questionAsync(keyQuestion).then(key => { 35 | serverKey = key 36 | interactiveSession.close() 37 | }) 38 | console.log(explanation) 39 | const client = new faunadb.Client({ secret: serverKey }) 40 | 41 | try { 42 | // console.log('1. Creating collections') 43 | // await handleError(client.query(createAccounts), 'accounts collection') 44 | // await handleError(client.query(createProfiles), 'profiles collection') 45 | 46 | // console.log('2. Creating indexes') 47 | // await handleError(client.query(createIndexAccountsByEmail), 'accounts by email index') 48 | // await handleError(client.query(createDefaultProfilesIndex), 'all profiles index') 49 | 50 | console.log('1. Creating security roles to be assumed by the functions') 51 | await handleError(client.query(createFnRoleLogin), 'function role - login') 52 | await handleError(client.query(createFnRoleRegister), 'function role - register') 53 | 54 | console.log('2. Creating security role that can call the functions') 55 | await handleError(client.query(createBootstrapRole), 'function role - bootstrap') 56 | 57 | console.log('3. Give all accounts access to read profiles') 58 | await handleError(client.query(createLoggedInRole), 'membership role - logged in role') 59 | 60 | const clientKey = await handleError(client.query(createBootstrapKey), 'token - bootstrap') 61 | if (clientKey) { 62 | console.log( 63 | '\x1b[32m', 64 | 'The client token, place it in your .env with the key REACT_APP_BOOTSTRAP_FAUNADB_KEY, react will load the .env vars' 65 | ) 66 | console.log('\x1b[33m%s\x1b[0m', clientKey.secret) 67 | } 68 | } catch (err) { 69 | console.error('Unexpected error', err) 70 | } 71 | } 72 | 73 | const handleError = (promise, entity) => { 74 | return promise 75 | .then(data => { 76 | console.log(` [ Created ] '${entity}'`) 77 | return data 78 | }) 79 | .catch(error => { 80 | if (error && error.message === 'instance already exists') { 81 | console.warn(` [ Skipped ] '${entity}', it already exists`) 82 | } else { 83 | console.error(` [ Failed ] '${entity}', with error:`, error) 84 | } 85 | }) 86 | } 87 | 88 | main() 89 | -------------------------------------------------------------------------------- /src/styling/components/layout.scss: -------------------------------------------------------------------------------- 1 | body { 2 | margin: 0; 3 | --header-height: 200px; 4 | --header-title-height: 50px; 5 | font-family: 'Roboto'; 6 | font-weight: 300; 7 | font-size: 15px; 8 | } 9 | 10 | .page { 11 | display: flex; 12 | width: 100%; 13 | flex-direction: column; 14 | } 15 | 16 | .header { 17 | display: flex; 18 | width: 100%; 19 | position: relative; 20 | height: var(--header-height); 21 | max-height: var(--header-height); 22 | color: rgba(255, 255, 255, 0.7); 23 | } 24 | 25 | .header .header-image { 26 | display: block; 27 | min-width: 100%; 28 | height: var(--header-height); 29 | max-height: var(--header-height); 30 | background-image: url(/styling/images/header.svg); 31 | background-size: 100% 200px; 32 | background-repeat: no-repeat; 33 | position: absolute; 34 | z-index: 20; 35 | } 36 | 37 | .header .header-title-container { 38 | display: flex; 39 | flex-direction: row; 40 | justify-content: center; 41 | position: absolute; 42 | height: var(--header-title-height); 43 | top: 5%; 44 | min-width: 100%; 45 | z-index: 30; 46 | } 47 | 48 | .header .header-title-container .header-title { 49 | display: flex; 50 | z-index: 20; 51 | font-size: 40px; 52 | font-weight: 100; 53 | font-size: 40px; 54 | align-items: center; 55 | margin-right: 20px; 56 | } 57 | 58 | .header .header-profile-container { 59 | display: flex; 60 | justify-content: flex-end; 61 | align-items: center; 62 | right: 5%; 63 | top: 5%; 64 | width: 20%; 65 | height: var(--header-title-height); 66 | position: absolute; 67 | z-index: 30; 68 | } 69 | 70 | .header .header-profile-container a, 71 | .header .header-profile-container a:link, 72 | .header .header-profile-container a:visited, 73 | .header .header-profile-container a:active, 74 | .header .header-profile-container a:hover { 75 | text-decoration: none; 76 | display: flex; 77 | justify-content: center; 78 | align-items: center; 79 | color: rgba(255,255,255,0.8); 80 | } 81 | 82 | .header .header-profile-container a span { 83 | margin-right: 20px; 84 | } 85 | 86 | .header .header-profile-container .header-profile a:hover { 87 | display: flex; 88 | color: white; 89 | } 90 | 91 | 92 | .header .header-profile-container img { 93 | height: 25px; 94 | margin: 0; 95 | } 96 | 97 | 98 | .header .header-profile-container .profile-pic-container { 99 | display: flex; 100 | justify-content: center; 101 | align-items: center; 102 | background-color: rgba(255,255,255,0.9); 103 | border: 2px solid rgba(0,0,0,0.7); 104 | border-radius: 50%; 105 | height: 35px; 106 | width: 35px; 107 | position: relative; 108 | margin-left: 20px; 109 | } 110 | 111 | .header .header-profile-container .profile-pic-and-text { 112 | display: flex; 113 | flex-direction: row; 114 | justify-content: center; 115 | align-items: center; 116 | cursor: pointer; 117 | } 118 | 119 | .header .header-profile-container .profile-pic-container-email { 120 | position: absolute; 121 | top: 110%; 122 | color: rgba(0,0,0,0.7) 123 | } 124 | 125 | .header .header-bg-container { 126 | display: flex; 127 | width: 90%; 128 | left: 5%; 129 | position: absolute; 130 | margin-bottom: 0; 131 | margin-top: 0; 132 | z-index: 10; 133 | } 134 | 135 | .header .header-bg-container .header-bg { 136 | display: flex; 137 | width: 100%; 138 | background-color: rgba(0, 0, 0, 0.02); 139 | height: var(--header-height); 140 | top: 0px; 141 | } 142 | 143 | .body-container { 144 | display: flex; 145 | flex-direction: column; 146 | margin: 5%; 147 | margin-top: 0; 148 | background-color: rgba(0, 0, 0, 0.02); 149 | } 150 | 151 | .body-container .body-content { 152 | display: flex; 153 | flex-direction: row; 154 | flex-wrap: wrap; 155 | } 156 | -------------------------------------------------------------------------------- /scripts/create-roles.js: -------------------------------------------------------------------------------- 1 | const faunadb = require('faunadb') 2 | const { CreateRole, Collection, Index } = faunadb.query 3 | 4 | // The register function only needs to be able to create accounts. 5 | const createFnRoleRegister = CreateRole({ 6 | name: 'functionrole_register', 7 | privileges: [ 8 | { 9 | resource: Collection('Account'), 10 | actions: { create: true } // write is to update, create to create new instances 11 | } 12 | ] 13 | }) 14 | 15 | // The login function only needs to be able to Login into accounts with the 'Login' FQL function. 16 | // That FQL function requires a reference and we will get the account reference with an index. 17 | // Therefore it needs read access to the 'accounts_by_email' index. Afterwards it will return the 18 | // account so the frontend has the email of the user so we also need read access to the 'accounts' collection 19 | const createFnRoleLogin = CreateRole({ 20 | name: 'functionrole_login', 21 | privileges: [ 22 | { 23 | resource: Index('accountsByEmail'), 24 | actions: { read: true } 25 | }, 26 | { 27 | resource: Collection('Account'), 28 | actions: { read: true } 29 | } 30 | ] 31 | }) 32 | 33 | // When a user first arrives to the application, he should only be able to create a new account (register UDF) and login with a given account (login UDF) 34 | // This role will be used to generate a key to bootstrap this process. 35 | const createBootstrapRole = CreateRole({ 36 | name: 'keyrole_calludfs', 37 | privileges: [ 38 | { 39 | resource: faunadb.query.Function('login'), 40 | actions: { 41 | call: true 42 | } 43 | }, 44 | { 45 | resource: faunadb.query.Function('register'), 46 | actions: { 47 | call: true 48 | } 49 | } 50 | ] 51 | }) 52 | 53 | // Finally, the last role will not be assumed by a function or used to create a key. 54 | // It will be assumed by a database Entity! 55 | // We can assign a role to a collection or a subset of a collection using the 'membership' field. 56 | // This will make sure that the database entity has the privileges from this role. 57 | // We can then use these privileges by using the 'Login' token on such a database entity. 58 | // (which we do in src/data/fauna-queries > login) 59 | // The result will be a token for that database entity that has the privileges below. 60 | const createLoggedInRole = CreateRole({ 61 | name: 'membershiprole_loggedin', 62 | privileges: [ 63 | { 64 | resource: Collection('Profile'), 65 | actions: { 66 | read: true 67 | } 68 | }, 69 | { 70 | resource: Collection('Project'), 71 | actions: { 72 | read: true 73 | } 74 | }, 75 | { 76 | resource: Collection('Skill'), 77 | actions: { 78 | read: true 79 | } 80 | }, 81 | { 82 | resource: Collection('profile_projects'), 83 | actions: { 84 | read: true 85 | } 86 | }, 87 | { 88 | resource: Collection('profile_skills'), 89 | actions: { 90 | read: true 91 | } 92 | }, 93 | { 94 | resource: Index('allProfiles'), 95 | actions: { 96 | read: true 97 | } 98 | }, 99 | { 100 | resource: Index('profile_projects_by_profile'), 101 | actions: { 102 | read: true 103 | } 104 | }, 105 | { 106 | resource: Index('profile_projects_by_profile_and_project'), 107 | actions: { 108 | read: true 109 | } 110 | }, 111 | { 112 | resource: Index('profile_projects_by_project'), 113 | actions: { 114 | read: true 115 | } 116 | }, 117 | { 118 | resource: Index('profile_skills_by_profile'), 119 | actions: { 120 | read: true 121 | } 122 | }, 123 | { 124 | resource: Index('profile_skills_by_profile_and_skill'), 125 | actions: { 126 | read: true 127 | } 128 | }, 129 | { 130 | resource: Index('profile_skills_by_skill'), 131 | actions: { 132 | read: true 133 | } 134 | }, 135 | { 136 | resource: Index('skillsByName'), 137 | actions: { 138 | read: true 139 | } 140 | }, 141 | { 142 | resource: Index('unique_Account_email'), 143 | actions: { 144 | read: true 145 | } 146 | } 147 | ], 148 | membership: [ 149 | { 150 | resource: Collection('Account') 151 | } 152 | ] 153 | }) 154 | 155 | module.exports = { createFnRoleRegister, createFnRoleLogin, createBootstrapRole, createLoggedInRole } 156 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # About this repository 2 | This project is an example application that was built specifically to do GraphQL demos and is definitely not a complete application yet. We aim to finish it and write complete example applications with GraphQL in the near future just like we did with the Fauna Query Language [here](https://css-tricks.com/rethinking-twitter-as-a-serverless-app/) 3 | In the meantime we'll release this simple demo since many users asked about it. 4 | 5 | This repository was part of a workshop which you can watch here: https://www.youtube.com/watch?v=_kEDBitNbnY and which can be followed by running through this document: https://docs.google.com/document/d/10HrtNsaQH0MBTsRKJIQldEr9XMiTLWkEsJ0HYEIrmSU/edit where the answers are provided in white text. 6 | 7 | # Disclaimer 8 | Since the repository is used as a workshop repository, the default branch **does not contain** all the code. To set up the project with all code included you will need to go to the final branch: 9 | 10 | ``` 11 | git checkout final 12 | ``` 13 | 14 | ## What it shows 15 | This app shows how to set up a GraphQL endpoint in minutes and further goes into authentication with FaunaDB without a backend and implements some simple queries with pagination. FaunaDB's security roles are extremely flexible and can be applied on GraphQL, we can also combine GraphQL with FQL via User Defined Functions (UDFs) by using the @resolver tag in our GraphQL schema. 16 | This example demonistrates this by using two custom User Defined Functions to bootstrap the security for our GraphQL endpoint. We start off with a token that can only call two User Defined Functions (like stored procedures) functions (register, login). Once the user logs in, the token is swapped with a 'login token' which has access to view profiles. The queries to get the data are pure GraphQL queries that were automatically generated by Fauna when we imported the schema. 17 | 18 | ## Setup the project 19 | 20 | ### Create a database 21 | Log in to the FaunaDB [dashboard](https://dashboard.fauna.com/) and create a new database by clicking on *New Database* 22 | Give it a name and click *Save* 23 | 24 | ### Import the schema 25 | Setting up a GraphQL endpoint in FaunaDB is all about importing the schema which you can find in this repository under src/ 26 | The schema looks a follows: 27 | 28 | ``` 29 | type Account { 30 | email: String! @unique 31 | } 32 | 33 | type Profile { 34 | name: String! 35 | icon: String! 36 | account: Account! @relation 37 | skills: [ Skill ! ] @relation 38 | projects: [ Project! ] @relation 39 | } 40 | 41 | type Project { 42 | name: String! 43 | profile: [ Profile! ] @relation 44 | } 45 | 46 | type Skill { 47 | profiles: [ Profile! ] @relation 48 | name: String! 49 | } 50 | 51 | type Query { 52 | allProfiles: [Profile!] 53 | accountsByEmail(email: String!): [Account!]! 54 | skillsByName(name: String!): [Skill!]! 55 | } 56 | 57 | type Mutation { 58 | register(email: String!, password: String!): Account! @resolver 59 | login(email: String!, password: String!): String! @resolver 60 | } 61 | ``` 62 | 63 | Go to the GraphQL tab in the FaunaDB [dashboard](https://dashboard.fauna.com/) and click import schema and select the schema.gql file. 64 | You now have a GraphQL endpoint and should get a playground to play around with it. 65 | 66 | ### Run the setup scripts 67 | 68 | We have added scripts to set up all the security roles, collections, indexes to make this work. 69 | The scripts are meant to get you started easily and to document the process. Take a peek in the scripts/setup.js script to see how this is setup. To run the script, create an Admin key on https://dashboard.fauna.com/ via the Security tab, copy the key's secret and run: 70 | 71 | `npm run setup` 72 | 73 | Paste the admin key's secret when prompted by the script. Do not use the admin key for anything other than the setup script. Admin keys are powerful and meant to manipulate all aspects of the database (create/drop collections/indexes/roles). 74 | 75 | The script creates a login key and outputs that key's secret. Copy the secret and place it in a .env.local file: 76 | ` 77 | REACT_APP_BOOTSTRAP_FAUNADB_KEY= 78 | ` 79 | 80 | ### Update the UDFs 81 | When you use a @resolver, it will create a UDF stub for you but since a resolver is custom FQL code, that FQL can't be generated for you. 82 | 83 | Go to the Fauna dashboard again, click on Functions and then change the body of the *login* function to the following: 84 | 85 | ``` 86 | Query(Lambda(['email', 'password'], 87 | Select( 88 | ['secret'], 89 | Login(Match(Index('accountsByEmail'), Var('email')), { 90 | password: Var('password') 91 | }) 92 | ) 93 | )) 94 | ``` 95 | 96 | Select the *register* function and update the body to the following: 97 | 98 | ``` 99 | Query(Lambda(['email', 'password'], 100 | Create(Collection('Account'), { 101 | credentials: { password: Var('password') }, 102 | data: { 103 | email: Var('email') 104 | } 105 | }) 106 | )) 107 | ``` 108 | 109 | Note: make sure not to create the UDF before you upload the GraphQL schema, it has to be created with the GraphQL schema since this will include metadata for the UDF to set it up that it can be called from GraphQL. 110 | 111 | ## Run the project 112 | To run the project, enter `npm start` on the command line. 113 | -------------------------------------------------------------------------------- /src/service-worker.js: -------------------------------------------------------------------------------- 1 | // This optional code is used to register a service worker. 2 | // register() is not called by default. 3 | 4 | // This lets the app load faster on subsequent visits in production, and gives 5 | // it offline capabilities. However, it also means that developers (and users) 6 | // will only see deployed updates on subsequent visits to a page, after all the 7 | // existing tabs open on the page have been closed, since previously cached 8 | // resources are updated in the background. 9 | 10 | // To learn more about the benefits of this model and instructions on how to 11 | // opt-in, read https://bit.ly/CRA-PWA 12 | 13 | const isLocalhost = Boolean( 14 | window.location.hostname === 'localhost' || 15 | // [::1] is the IPv6 localhost address. 16 | window.location.hostname === '[::1]' || 17 | // 127.0.0.0/8 are considered localhost for IPv4. 18 | window.location.hostname.match( 19 | /^127(?:\.(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)){3}$/ 20 | ) 21 | ); 22 | 23 | export function register(config) { 24 | if (process.env.NODE_ENV === 'production' && 'serviceWorker' in navigator) { 25 | // The URL constructor is available in all browsers that support SW. 26 | const publicUrl = new URL(process.env.PUBLIC_URL, window.location.href); 27 | if (publicUrl.origin !== window.location.origin) { 28 | // Our service worker won't work if PUBLIC_URL is on a different origin 29 | // from what our page is served on. This might happen if a CDN is used to 30 | // serve assets; see https://github.com/facebook/create-react-app/issues/2374 31 | return; 32 | } 33 | 34 | window.addEventListener('load', () => { 35 | const swUrl = `${process.env.PUBLIC_URL}/service-worker.js`; 36 | 37 | if (isLocalhost) { 38 | // This is running on localhost. Let's check if a service worker still exists or not. 39 | checkValidServiceWorker(swUrl, config); 40 | 41 | // Add some additional logging to localhost, pointing developers to the 42 | // service worker/PWA documentation. 43 | navigator.serviceWorker.ready.then(() => { 44 | console.log( 45 | 'This web app is being served cache-first by a service ' + 46 | 'worker. To learn more, visit https://bit.ly/CRA-PWA' 47 | ); 48 | }); 49 | } else { 50 | // Is not localhost. Just register service worker 51 | registerValidSW(swUrl, config); 52 | } 53 | }); 54 | } 55 | } 56 | 57 | function registerValidSW(swUrl, config) { 58 | navigator.serviceWorker 59 | .register(swUrl) 60 | .then(registration => { 61 | registration.onupdatefound = () => { 62 | const installingWorker = registration.installing; 63 | if (installingWorker == null) { 64 | return; 65 | } 66 | installingWorker.onstatechange = () => { 67 | if (installingWorker.state === 'installed') { 68 | if (navigator.serviceWorker.controller) { 69 | // At this point, the updated precached content has been fetched, 70 | // but the previous service worker will still serve the older 71 | // content until all client tabs are closed. 72 | console.log( 73 | 'New content is available and will be used when all ' + 74 | 'tabs for this page are closed. See https://bit.ly/CRA-PWA.' 75 | ); 76 | 77 | // Execute callback 78 | if (config && config.onUpdate) { 79 | config.onUpdate(registration); 80 | } 81 | } else { 82 | // At this point, everything has been precached. 83 | // It's the perfect time to display a 84 | // "Content is cached for offline use." message. 85 | console.log('Content is cached for offline use.'); 86 | 87 | // Execute callback 88 | if (config && config.onSuccess) { 89 | config.onSuccess(registration); 90 | } 91 | } 92 | } 93 | }; 94 | }; 95 | }) 96 | .catch(error => { 97 | console.error('Error during service worker registration:', error); 98 | }); 99 | } 100 | 101 | function checkValidServiceWorker(swUrl, config) { 102 | // Check if the service worker can be found. If it can't reload the page. 103 | fetch(swUrl, { 104 | headers: { 'Service-Worker': 'script' } 105 | }) 106 | .then(response => { 107 | // Ensure service worker exists, and that we really are getting a JS file. 108 | const contentType = response.headers.get('content-type'); 109 | if ( 110 | response.status === 404 || 111 | (contentType != null && contentType.indexOf('javascript') === -1) 112 | ) { 113 | // No service worker found. Probably a different app. Reload the page. 114 | navigator.serviceWorker.ready.then(registration => { 115 | registration.unregister().then(() => { 116 | window.location.reload(); 117 | }); 118 | }); 119 | } else { 120 | // Service worker found. Proceed as normal. 121 | registerValidSW(swUrl, config); 122 | } 123 | }) 124 | .catch(() => { 125 | console.log( 126 | 'No internet connection found. App is running in offline mode.' 127 | ); 128 | }); 129 | } 130 | 131 | export function unregister() { 132 | if ('serviceWorker' in navigator) { 133 | navigator.serviceWorker.ready.then(registration => { 134 | registration.unregister(); 135 | }); 136 | } 137 | } 138 | -------------------------------------------------------------------------------- /src/pages/profiles.js: -------------------------------------------------------------------------------- 1 | import React, { useState, useEffect, useContext } from 'react' 2 | import { toast } from 'react-toastify' 3 | 4 | import ProfileCard from './../components/card' 5 | import SessionContext from './../context/session' 6 | import { getProfiles, getProfilesBySkill } from '../data/fauna-queries' 7 | 8 | const Profiles = () => { 9 | const sessionContext = useContext(SessionContext) 10 | // const { user } = sessionContext.state 11 | const user = true 12 | 13 | 14 | const [state, setState] = useState({ 15 | profiles: [], 16 | loaded: false, 17 | toLoad: 'initial', // initial, next, previous 18 | error: false, 19 | after: null, 20 | before: null, 21 | skill: '' 22 | }) 23 | // Fetch the profiles after load time. 24 | useEffect(() => { 25 | if (!state.loaded && user) { 26 | if (state.skill && state.skill.length > 0) { 27 | getProfilesBySkill(state.skill, state.toLoad, state.before, state.after) 28 | .then(result => { 29 | console.log('search skills', state.skill, result) 30 | setState({ 31 | before: result.before, 32 | after: result.after, 33 | profiles: result.data ? result.data : [], 34 | loaded: true, 35 | skill: state.skill 36 | }) 37 | }) 38 | .catch(err => { 39 | console.log(err) 40 | setState({ error: err, profiles: [], loaded: true }) 41 | toast.error('No data permissions') 42 | }) 43 | } else { 44 | getProfiles(state.toLoad, state.before, state.after) 45 | .then(result => { 46 | setState({ 47 | before: result.before, 48 | after: result.after, 49 | profiles: result.data, 50 | loaded: true, 51 | skill: state.skill 52 | }) 53 | }) 54 | .catch(err => { 55 | console.log(err) 56 | setState({ error: err, profiles: [], loaded: true }) 57 | toast.error('No data permissions') 58 | }) 59 | } 60 | } 61 | }, [state, user]) 62 | 63 | // Return the header and either show an error or render the loaded profiles. 64 | return ( 65 | 66 |
    {' '} 67 |
    68 | { 73 | setState({ 74 | toLoad: '', 75 | loaded: false, 76 | profiles: state.profiles, 77 | after: state.after, 78 | before: state.before, 79 | skill: e.target.value 80 | }) 81 | }} 82 | /> 83 |
    84 |
    {' '} 85 |
    {generateProfilesOrMessage(state.profiles, state.error, state.loaded, user)}
    86 |
    89 | setState({ 90 | toLoad: 'prev', 91 | loaded: false, 92 | profiles: state.profiles, 93 | after: state.after, 94 | before: state.before, 95 | skill: state.skill 96 | }) 97 | } 98 | > 99 | {' '} 100 | previous{' '} 101 |
    {' '} 102 |
    {' '} 103 |
    106 | setState({ 107 | toLoad: 'next', 108 | loaded: false, 109 | profiles: state.profiles, 110 | after: state.after, 111 | before: state.before, 112 | skill: state.skill 113 | }) 114 | } 115 | > 116 | {' '} 117 | Next{' '} 118 |
    119 |
    120 | ) 121 | } 122 | 123 | const generateProfilesOrMessage = (profiles, error, loaded, user) => { 124 | // Unexpected error 125 | if (error) { 126 | return generateUserError(error) 127 | } 128 | // We are not logged in yet 129 | else if (!user) { 130 | return generateNotLoggedIn(profiles) 131 | } else if (!loaded) { 132 | return generateLoading() 133 | } 134 | // We received an empty list of profiles (e.g. they are all private or our filtering is too aggressive) 135 | else if (profiles && profiles.length === 0) { 136 | return generateNotFound() 137 | } 138 | // Or we just received profiles 139 | else { 140 | return generateProfiles(profiles) 141 | } 142 | } 143 | 144 | const generateLoading = error => { 145 | return ( 146 |
    147 |

    148 |

    Loading

    149 | no results 150 |
    151 | ) 152 | } 153 | 154 | const generateUserError = error => { 155 | return ( 156 |
    157 |

    400

    158 |

    {error.message}

    159 | no results 160 |
    161 | ) 162 | } 163 | 164 | const generateNotLoggedIn = () => { 165 | return ( 166 |
    167 |

    Hi anonymous

    168 |

    You should log in first

    169 | no results 170 |
    171 | ) 172 | } 173 | 174 | const generateNotFound = () => { 175 | return ( 176 |
    177 |

    No Results Found

    178 |

    Looking for a white raven?

    179 | no results 180 |
    181 | ) 182 | } 183 | 184 | const generateProfiles = profiles => { 185 | if(profiles){ 186 | return profiles.map((profile, index) => { 187 | return ( 188 | 196 | ) 197 | }) 198 | } 199 | else { 200 | return null 201 | } 202 | 203 | } 204 | 205 | export default Profiles 206 | --------------------------------------------------------------------------------