diff --git a/package-lock.json b/package-lock.json index a4a1d8ba1..f3412eb92 100644 --- a/package-lock.json +++ b/package-lock.json @@ -11,6 +11,7 @@ "@fortawesome/react-fontawesome": "^0.1.16", "@tabler/icons": "^1.46.0", "@tippyjs/react": "^4.2.6", + "axios": "^0.26.0", "classnames": "^2.3.1", "codemirror": "^5.64.0", "codemirror-graphql": "^1.2.5", @@ -22,7 +23,7 @@ "graphql": "^16.2.0", "graphql-request": "^3.7.0", "idb": "^7.0.0", - "immer": "^9.0.7", + "immer": "^9.0.12", "lodash": "^4.17.21", "markdown-it": "^12.2.0", "mousetrap": "^1.6.5", @@ -5101,6 +5102,14 @@ "integrity": "sha512-xh1Rl34h6Fi1DC2WWKfxUTVqRsNnr6LsKz2+hfwDxQJWmrx8+c7ylaqBMcHfl1U1r2dsifOvKX3LQuLNZ+XSvA==", "dev": true }, + "node_modules/axios": { + "version": "0.26.0", + "resolved": "https://registry.npmjs.org/axios/-/axios-0.26.0.tgz", + "integrity": "sha512-lKoGLMYtHvFrPVt3r+RBMp9nh34N0M8zEfCWqdWZx6phynIEhQqAdydpyBAAG211zlhX9Rgu08cOamy6XjE5Og==", + "dependencies": { + "follow-redirects": "^1.14.8" + } + }, "node_modules/babel-loader": { "version": "8.2.3", "resolved": "https://registry.npmjs.org/babel-loader/-/babel-loader-8.2.3.tgz", @@ -8157,6 +8166,25 @@ "node": ">=4" } }, + "node_modules/follow-redirects": { + "version": "1.14.9", + "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.14.9.tgz", + "integrity": "sha512-MQDfihBQYMcyy5dhRDJUHcw7lb2Pv/TuE6xP1vyraLukNDHKbDxDNaOE3NbCAdKQApno+GPRyo1YAp89yCjK4w==", + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/RubenVerborgh" + } + ], + "engines": { + "node": ">=4.0" + }, + "peerDependenciesMeta": { + "debug": { + "optional": true + } + } + }, "node_modules/forever-agent": { "version": "0.6.1", "resolved": "https://registry.npmjs.org/forever-agent/-/forever-agent-0.6.1.tgz", @@ -20118,6 +20146,14 @@ "integrity": "sha512-xh1Rl34h6Fi1DC2WWKfxUTVqRsNnr6LsKz2+hfwDxQJWmrx8+c7ylaqBMcHfl1U1r2dsifOvKX3LQuLNZ+XSvA==", "dev": true }, + "axios": { + "version": "0.26.0", + "resolved": "https://registry.npmjs.org/axios/-/axios-0.26.0.tgz", + "integrity": "sha512-lKoGLMYtHvFrPVt3r+RBMp9nh34N0M8zEfCWqdWZx6phynIEhQqAdydpyBAAG211zlhX9Rgu08cOamy6XjE5Og==", + "requires": { + "follow-redirects": "^1.14.8" + } + }, "babel-loader": { "version": "8.2.3", "resolved": "https://registry.npmjs.org/babel-loader/-/babel-loader-8.2.3.tgz", @@ -22491,6 +22527,11 @@ "locate-path": "^2.0.0" } }, + "follow-redirects": { + "version": "1.14.9", + "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.14.9.tgz", + "integrity": "sha512-MQDfihBQYMcyy5dhRDJUHcw7lb2Pv/TuE6xP1vyraLukNDHKbDxDNaOE3NbCAdKQApno+GPRyo1YAp89yCjK4w==" + }, "forever-agent": { "version": "0.6.1", "resolved": "https://registry.npmjs.org/forever-agent/-/forever-agent-0.6.1.tgz", diff --git a/package.json b/package.json index d3a960e6a..0333cb804 100644 --- a/package.json +++ b/package.json @@ -22,6 +22,7 @@ "@fortawesome/react-fontawesome": "^0.1.16", "@tabler/icons": "^1.46.0", "@tippyjs/react": "^4.2.6", + "axios": "^0.26.0", "classnames": "^2.3.1", "codemirror": "^5.64.0", "codemirror-graphql": "^1.2.5", @@ -33,7 +34,7 @@ "graphql": "^16.2.0", "graphql-request": "^3.7.0", "idb": "^7.0.0", - "immer": "^9.0.7", + "immer": "^9.0.12", "lodash": "^4.17.21", "markdown-it": "^12.2.0", "mousetrap": "^1.6.5", diff --git a/renderer/api/base.js b/renderer/api/base.js new file mode 100644 index 000000000..b3e599581 --- /dev/null +++ b/renderer/api/base.js @@ -0,0 +1,27 @@ +import axios from "axios"; + +const apiClient = axios.create({ + baseURL: process.env.NEXT_PUBLIC_API +}); + +apiClient.interceptors.request.use((config) => { + const headers = { + 'Content-Type': 'application/json' + }; + + return ({ + ...config, + headers: headers + }); +}, error => Promise.reject(error)); + +apiClient.interceptors.response.use((response) => + response, + async (error) => { + return Promise.reject(error.response ? error.response.data : error); + } +); + +const { get, post, put, delete: destroy } = apiClient; + +export { get, post, put, destroy }; diff --git a/renderer/api/identity.js b/renderer/api/identity.js new file mode 100644 index 000000000..53431d539 --- /dev/null +++ b/renderer/api/identity.js @@ -0,0 +1,13 @@ +import { get, post, put } from './base'; + +const IdentityApi = { + whoami: () =>get('v1/user/whoami'), + signup: (params) =>post('v1/user/register', params), + login: (params) =>post('v1/user/login', params), + signout: () => post('v1/user/logout'), + getProfile: () =>get('v1/user/profile'), + updateProfile: (params) =>put('v1/user/profile', params), + updateUsername: (params) =>put('v1/user/username', params) +}; + +export default IdentityApi; \ No newline at end of file diff --git a/renderer/components/Sidebar/MenuBar/StyledWrapper.js b/renderer/components/Sidebar/MenuBar/StyledWrapper.js index e9980f196..1880b92bb 100644 --- a/renderer/components/Sidebar/MenuBar/StyledWrapper.js +++ b/renderer/components/Sidebar/MenuBar/StyledWrapper.js @@ -3,6 +3,7 @@ import styled from 'styled-components'; const Wrapper = styled.div` background-color: rgb(44, 44, 44); color: rgba(255, 255, 255, 0.5); + min-height: 100vh; .menu-item { padding: 0.6rem; diff --git a/renderer/components/Sidebar/MenuBar/index.js b/renderer/components/Sidebar/MenuBar/index.js index f4cda1545..21a78be8f 100644 --- a/renderer/components/Sidebar/MenuBar/index.js +++ b/renderer/components/Sidebar/MenuBar/index.js @@ -1,4 +1,5 @@ import React from 'react'; +import Link from 'next/link'; import { IconCode, IconStack, IconGitPullRequest, IconUser, IconUsers, IconSettings,IconBuilding } from '@tabler/icons'; import StyledWrapper from './StyledWrapper'; @@ -7,7 +8,9 @@ const MenuBar = () => {
- + + +
@@ -25,7 +28,9 @@ const MenuBar = () => {
*/}
- + + +
diff --git a/renderer/jsconfig.json b/renderer/jsconfig.json index f0c3aa3ad..320b6ad6f 100644 --- a/renderer/jsconfig.json +++ b/renderer/jsconfig.json @@ -5,6 +5,7 @@ "baseUrl": "./", "paths": { "components/*": ["src/components/*"], + "api/*": ["src/api/*"], "pageComponents/*": ["src/pageComponents/*"], "providers/*": ["src/providers/*"] } diff --git a/renderer/pageComponents/Login/StyledWrapper.js b/renderer/pageComponents/Login/StyledWrapper.js new file mode 100644 index 000000000..05ca16a88 --- /dev/null +++ b/renderer/pageComponents/Login/StyledWrapper.js @@ -0,0 +1,41 @@ +import styled from 'styled-components'; + +const Wrapper = styled.div` + display: flex; + flex-direction: column; + width: 100%; + height: 100%; + min-height: 100vh; + + .form-container { + max-width: 350px; + border-radius: 4px; + border: 1px #ddd solid; + + button.continue-btn { + font-size: 16px; + padding-top: 8x; + padding-bottom: 8px; + min-height: 38px; + align-items: center; + color: #212529; + background: #e2e6ea; + border: solid 1px #dae0e5; + } + + .field-error { + font-size: 0.875rem; + } + + a { + color: var(--color-text-link); + } + + .error-msg { + font-size: 15px; + color: rgb(192 69 8); + } + } +`; + +export default Wrapper; diff --git a/renderer/pageComponents/Login/index.js b/renderer/pageComponents/Login/index.js new file mode 100644 index 000000000..53093d3cd --- /dev/null +++ b/renderer/pageComponents/Login/index.js @@ -0,0 +1,153 @@ +import React, { useState } from "react"; +import * as Yup from 'yup'; +import Link from 'next/link'; +import { useRouter } from 'next/router'; +import { useAuth } from 'providers/Auth'; +import IdentityApi from 'api/identity'; +import { useFormik } from 'formik'; +import StyledWrapper from './StyledWrapper'; + +const Login = () => { + const router = useRouter(); + const [authState, authDispatch] = useAuth(); + + const { + currentUser + } = authState; + + const [loggingIn, setLoggingIn] = useState(false); + const [loginError, setLoginError] = useState(false); + + const formik = useFormik({ + initialValues: { + email: '', + password: '', + }, + validationSchema: Yup.object({ + email: Yup.string() + .required('Email is required'), + password: Yup.string() + .required('Password is required') + }), + onSubmit: (values, { resetForm }) => { + setLoggingIn(true); + IdentityApi + .login({ + email: values.email, + password: values.password + }) + .then((response) => { + authDispatch({ + type: 'LOGIN_SUCCESS', + user: response.data + }); + }) + .catch((error) => { + setLoggingIn(false); + setLoginError(true); + }); + }, + }); + + if(authState.isLoading) { + return null; + }; + + if(currentUser) { + router.push('/home'); + return null; + }; + + return ( + +
+
+ + + + + + + + + + + + + + + + + + + + + + +
+
grafnode
+
Opensource API Collection Collaboration
+
+
+
+
Login
+ +
+ + setLoginError(false)} + onBlur={formik.handleBlur} + value={formik.values.email} + /> + {formik.touched.email && formik.errors.email ? ( +
{formik.errors.email}
+ ) : null} +
+ +
+ + setLoginError(false)} + onBlur={formik.handleBlur} + value={formik.values.password} + /> + {formik.touched.password && formik.errors.password ? ( +
{formik.errors.password}
+ ) : null} +
+ +
+ { loggingIn ? ( + + ) : +
+ +
+ } + {loginError ? ( +
Invalid Credentials
+ ) : null} +
+ +
+
+
+ No account? Create one! +
+
+
+
+ ) +}; + +export default Login; diff --git a/renderer/pages/_app.js b/renderer/pages/_app.js index 37393a64a..e9932bfd3 100644 --- a/renderer/pages/_app.js +++ b/renderer/pages/_app.js @@ -1,5 +1,6 @@ import { StoreProvider } from 'providers/Store'; import { HotkeysProvider } from 'providers/Hotkeys'; +import { AuthProvider } from 'providers/Auth'; import '../styles/globals.css' import 'tailwindcss/dist/tailwind.min.css'; @@ -20,11 +21,13 @@ function SafeHydrate({ children }) { function MyApp({ Component, pageProps }) { return ( - - - - - + + + + + + + ); } diff --git a/renderer/pages/index.js b/renderer/pages/index.js index bdc18b8f7..56ad0092e 100644 --- a/renderer/pages/index.js +++ b/renderer/pages/index.js @@ -16,5 +16,5 @@ export default function Home() {
- ) -} + ); +}; diff --git a/renderer/pages/login.js b/renderer/pages/login.js new file mode 100644 index 000000000..c997a2d2e --- /dev/null +++ b/renderer/pages/login.js @@ -0,0 +1,26 @@ +import Head from 'next/head'; +import Login from 'pageComponents/Login'; +import MenuBar from 'components/Sidebar/MenuBar'; +import GlobalStyle from '../globalStyles'; + +export default function Home() { + return ( +
+ + grafnode + + + + + +
+
+ +
+ +
+
+
+
+ ); +}; diff --git a/renderer/providers/Auth/index.js b/renderer/providers/Auth/index.js new file mode 100644 index 000000000..33f3258a9 --- /dev/null +++ b/renderer/providers/Auth/index.js @@ -0,0 +1,63 @@ +import React, { useEffect, useReducer } from 'react'; +import { useRouter } from 'next/router'; +import IdentityApi from 'api/identity'; +import reducer from './reducer'; + +const AuthContext = React.createContext(); + +const initialState = { + isLoading: true, + lastStateTransition: null, + currentUser: null +}; + +export const AuthProvider = props => { + const router = useRouter(); + const [state, dispatch] = useReducer(reducer, initialState); + + useEffect(() => { + IdentityApi + .whoami() + .then((response) => { + let data = response.data; + dispatch({ + type: 'WHOAMI_SUCCESS', + user : { + id: data.id, + name: data.name, + username: data.username + } + }); + }) + .catch((error) => { + dispatch({ + type: 'WHOAMI_ERROR', + error: error + }); + }); + }, []); + + useEffect(() => { + if(state.lastStateTransition === 'LOGIN_SUCCESS') { + router.push('/home'); + } + if(state.lastStateTransition === 'WHOAMI_ERROR') { + // Todo: decide action + // router.push('/login'); + } + }, [state]); + + return ; +}; + +export const useAuth = () => { + const context = React.useContext(AuthContext); + + if (context === undefined) { + throw new Error(`useAuth must be used within a AuthProvider`); + } + + return context; +}; + +export default AuthProvider; diff --git a/renderer/providers/Auth/reducer.js b/renderer/providers/Auth/reducer.js new file mode 100644 index 000000000..80dba541c --- /dev/null +++ b/renderer/providers/Auth/reducer.js @@ -0,0 +1,43 @@ +import produce from 'immer'; + +const reducer = (state, action) => { + switch (action.type) { + case 'WHOAMI_SUCCESS': { + return produce(state, (draft) => { + draft.isLoading = false; + draft.currentUser = action.user; + draft.lastStateTransition = 'WHOAMI_SUCCESS'; + }); + } + + case 'WHOAMI_ERROR': { + return produce(state, (draft) => { + draft.isLoading = false; + draft.currentUser = null; + draft.lastStateTransition = 'WHOAMI_ERROR'; + }); + } + + case 'LOGIN_SUCCESS': { + return produce(state, (draft) => { + draft.isLoading = false; + draft.currentUser = action.user; + draft.lastStateTransition = 'LOGIN_SUCCESS'; + }); + } + + case 'LOGOUT_SUCCESS': { + return produce(state, (draft) => { + draft.isLoading = false; + draft.currentUser = null; + draft.lastStateTransition = 'LOGOUT_SUCCESS'; + }); + } + + default: { + return state; + } + } +}; + +export default reducer; \ No newline at end of file diff --git a/renderer/styles/globals.css b/renderer/styles/globals.css index d51a6c934..a69faf43d 100644 --- a/renderer/styles/globals.css +++ b/renderer/styles/globals.css @@ -10,6 +10,7 @@ --color-layout-border: #dedede; --color-codemirror-border: #efefef; --color-codemirror-background: rgb(243, 243, 243); + --color-text-link: #1663bb; } html, body {