Refactored - now includes fullstack AUTH

This commit is contained in:
Ben Elferink
2021-09-28 18:53:02 +03:00
parent c349907802
commit 2f40cf797c
34 changed files with 6684 additions and 695 deletions

1396
client/package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,8 +1,6 @@
{
"homepage": "",
"name": "client",
"version": "0.1.0",
"description": "",
"private": true,
"main": "index.js",
"license": "ISC",
@@ -12,15 +10,18 @@
"eject": "react-scripts eject"
},
"dependencies": {
"@emotion/react": "^11.4.1",
"@emotion/styled": "^11.3.0",
"@fontsource/roboto": "^4.5.1",
"@mui/material": "^5.0.1",
"axios": "^0.21.1",
"react": "^17.0.1",
"react-dom": "^17.0.1",
"react": "^17.0.2",
"react-dom": "^17.0.2",
"react-scripts": "4.0.3"
},
"eslintConfig": {
"extends": [
"react-app",
"react-app/jest"
"react-app"
]
},
"browserslist": {

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 40 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 665 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 KiB

BIN
client/public/favicon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

View File

@@ -1,28 +1,24 @@
import './styles/styles.css';
import { useAuth } from './contexts/AuthContext';
import {useAuth} from './contexts/AuthContext'
import Header from './components/Header'
export default function App() {
const { isLoggedIn } = useAuth();
const {isLoggedIn} = useAuth()
return (
<div className='App'>
<h1>{isLoggedIn ? <LoggedInText /> : <LoggedOutText />}</h1>
<Header />
{isLoggedIn ? <LoggedInText /> : <LoggedOutText />}
</div>
);
)
}
const LoggedInText = () => (
<>
You are (not really) logged in,
<br />
check your console.log()
</>
);
const LoggedInText = () => {
const {account} = useAuth()
return <p>Hey, {account.username}! I'm happy to let you know: you are authenticated!</p>
}
const LoggedOutText = () => (
<>
Don't forget to start your backend server,
<br />
then hit refresh and see what happens...
</>
);
<p>Don't forget to start your backend server, then authenticate yourself.</p>
)

View File

@@ -1,9 +1,9 @@
import axios from 'axios';
import axios from 'axios'
// api url (where your serve is hosted at)
export const backendUrl = 'http://localhost:8080';
export const backendUrl = 'http://localhost:8080'
// axios configuration
export default axios.create({
baseURL: backendUrl,
});
})

View File

@@ -0,0 +1,124 @@
import {Fragment, useState} from 'react'
import {Dialog, DialogTitle, TextField, Button, CircularProgress} from '@mui/material'
import axios from '../api'
import {useAuth} from '../contexts/AuthContext'
const textFieldSx = {mx: 2, my: 0.5}
export default function AuthModal({open, close, register, toggleRegister}) {
const {setIsLoggedIn, setToken, setAccount} = useAuth()
const [formData, setFormData] = useState({})
const [loading, setLoading] = useState(false)
const [error, setError] = useState('')
const handleChange = (e) => {
const {name, value} = e.target
setFormData((prev) => ({...prev, [name]: value}))
}
const clickSubmit = async () => {
setLoading(true)
setError('')
try {
const requestPath = register ? '/auth/register' : '/auth/login'
const response = await axios.post(requestPath, formData)
setToken(response.data.token)
setAccount(response.data.data)
setIsLoggedIn(true)
close()
} catch (error) {
console.error(error)
setError(error?.response?.data?.message ?? error.message)
}
setLoading(false)
}
const disabledLoginButton = !formData['username'] || !formData['password']
const disabledRegisterButton = !formData['username'] || !formData['password']
return (
<Dialog open={open} onClose={close}>
{register ? (
<RegisterForm formData={formData} handleChange={handleChange} />
) : (
<LoginForm formData={formData} handleChange={handleChange} />
)}
{error && <span className='error'>{error}</span>}
{loading ? (
<center>
<CircularProgress color='inherit' />
</center>
) : (
<Button
onClick={clickSubmit}
disabled={register ? disabledRegisterButton : disabledLoginButton}>
{register ? 'Register' : 'Login'}
</Button>
)}
<Button onClick={toggleRegister}>
{register ? 'I already have an account' : "I don't have an account"}
</Button>
</Dialog>
)
}
function LoginForm({formData, handleChange}) {
return (
<Fragment>
<DialogTitle>Login to your account</DialogTitle>
<TextField
label='Username'
name='username'
value={formData['username'] ?? ''}
onChange={handleChange}
variant='filled'
sx={textFieldSx}
required
/>
<TextField
label='Password'
name='password'
value={formData['password'] ?? ''}
onChange={handleChange}
variant='filled'
sx={textFieldSx}
required
/>
</Fragment>
)
}
function RegisterForm({formData, handleChange}) {
return (
<Fragment>
<DialogTitle>Create a new account</DialogTitle>
<TextField
label='Username'
name='username'
value={formData['username'] ?? ''}
onChange={handleChange}
variant='filled'
sx={textFieldSx}
required
/>
<TextField
label='Password'
name='password'
value={formData['password'] ?? ''}
onChange={handleChange}
variant='filled'
sx={textFieldSx}
required
/>
</Fragment>
)
}

View File

@@ -0,0 +1,77 @@
import {Fragment, useState} from 'react'
import {IconButton, Avatar, Popover, List, ListSubheader, ListItemButton} from '@mui/material'
import OnlineIndicator from './OnlineIndicator'
import AuthModal from './AuthModal'
import {useAuth} from '../contexts/AuthContext'
export default function Header() {
const {isLoggedIn, account, logout} = useAuth()
const [anchorEl, setAnchorEl] = useState(null)
const [popover, setPopover] = useState(false)
const [authModal, setAuthModal] = useState(false)
const [register, setRegister] = useState(false)
const openPopover = (e) => {
setPopover(true)
setAnchorEl(e.currentTarget)
}
const closePopover = () => {
setPopover(false)
setAnchorEl(null)
}
const clickLogin = () => {
setRegister(false)
setAuthModal(true)
closePopover()
}
const clickRegister = () => {
setRegister(true)
setAuthModal(true)
closePopover()
}
return (
<header className='header'>
<h1>Web App</h1>
<IconButton onClick={openPopover}>
<OnlineIndicator online={isLoggedIn}>
<Avatar src={account?.username ?? ''} alt={account?.username ?? ''} />
</OnlineIndicator>
</IconButton>
<Popover
anchorEl={anchorEl}
open={popover}
onClose={closePopover}
anchorOrigin={{vertical: 'bottom', horizontal: 'right'}}
transformOrigin={{vertical: 'top', horizontal: 'right'}}>
<List style={{minWidth: '100px'}}>
<ListSubheader style={{textAlign: 'center'}}>
Hello, {isLoggedIn ? 'Ben' : 'Guest'}
</ListSubheader>
{isLoggedIn ? (
<ListItemButton onClick={logout}>Logout</ListItemButton>
) : (
<Fragment>
<ListItemButton onClick={clickLogin}>Login</ListItemButton>
<ListItemButton onClick={clickRegister}>Reigster</ListItemButton>
</Fragment>
)}
</List>
</Popover>
<AuthModal
open={authModal}
close={() => setAuthModal(false)}
register={register}
toggleRegister={() => setRegister((prev) => !prev)}
/>
</header>
)
}

View File

@@ -0,0 +1,63 @@
import {styled} from '@mui/material/styles'
import {Badge, Avatar} from '@mui/material'
const StyledBadge = styled(Badge)(({theme}) => ({
'& .MuiBadge-badge': {
backgroundColor: 'black',
color: 'black',
boxShadow: `0 0 0 2px ${theme.palette.background.paper}`,
'&::after': {
position: 'absolute',
top: 0,
left: 0,
width: '100%',
height: '100%',
borderRadius: '50%',
animation: 'ripple 1.2s infinite ease-in-out',
border: '1px solid currentColor',
content: '""',
},
},
'@keyframes ripple': {
'0%': {
transform: 'scale(.8)',
opacity: 1,
},
'100%': {
transform: 'scale(2.4)',
opacity: 0,
},
},
}))
const OnlineBadge = styled(StyledBadge)(({theme}) => ({
'& .MuiBadge-badge': {
backgroundColor: 'var(--online)',
color: 'var(--online)',
},
}))
const OfflineBadge = styled(StyledBadge)(({theme}) => ({
'& .MuiBadge-badge': {
backgroundColor: 'var(--offline)',
color: 'var(--offline)',
},
}))
export default function OnlineIndicator({online = false, children = <Avatar src='' alt='' />}) {
return online ? (
<OnlineBadge
variant='dot'
overlap='circular'
anchorOrigin={{vertical: 'bottom', horizontal: 'right'}}>
{children}
</OnlineBadge>
) : (
<OfflineBadge
variant='dot'
overlap='circular'
anchorOrigin={{vertical: 'bottom', horizontal: 'right'}}>
{children}
</OfflineBadge>
)
}

View File

@@ -1,37 +1,60 @@
import { createContext, useContext, useState, useEffect } from 'react';
import axios, { backendUrl } from '../api';
import {createContext, useContext, useState, useEffect} from 'react'
import axios from '../api'
// init context
const AuthContext = createContext();
const AuthContext = createContext()
// export the consumer
export function useAuth() {
return useContext(AuthContext);
return useContext(AuthContext)
}
// export the provider (handle all the logic here)
export function AuthProvider({ children }) {
const [isLoggedIn, setIsLoggedIn] = useState(false);
export function AuthProvider({children}) {
const [token, setToken] = useState(localStorage.getItem('token') ?? null)
const [account, setAccount] = useState(null)
const [isLoggedIn, setIsLoggedIn] = useState(false)
//
const logout = () => {
setToken(null)
setAccount(null)
setIsLoggedIn(false)
}
// This side effect keeps local storage updated with recent token value,
// making sure it can be re-used upon refresh or re-open browser
useEffect(() => {
(async () => {
try {
const response = await axios.get('/auth/account');
setIsLoggedIn(true);
if (token) {
localStorage.setItem('token', token)
} else {
localStorage.removeItem('token')
}
}, [token])
// Did you know? You can use CSS in the console!
console.log(
`%cExample of using your backend routes %c(${backendUrl}/auth/account)`,
'color: lime;',
'color: unset;',
response,
);
} catch (error) {
console.error(error.message);
}
})();
}, []);
// This side effect runs only if we have a token, but no account or logged-in boolean.
// This "if" statement applies only when refreshed, or re-opened the browser,
// if true, it will then ask the backend for the account information (and will get them if the token hasn't expired)
useEffect(() => {
if (!isLoggedIn && !account && token) {
;(async () => {
try {
const headers = {headers: {authorization: `Bearer ${token}`}}
const response = await axios.get('/auth/account', headers)
return <AuthContext.Provider value={{ isLoggedIn }}>{children}</AuthContext.Provider>;
setAccount(response.data.data)
setIsLoggedIn(true)
} catch (error) {
console.error(error)
if (error?.response?.statusCode === 401) setToken(null)
}
})()
}
}, [isLoggedIn, account, token]) // eslint-disable-line react-hooks/exhaustive-deps
return (
<AuthContext.Provider
value={{isLoggedIn, setIsLoggedIn, token, setToken, account, setAccount, logout}}>
{children}
</AuthContext.Provider>
)
}

View File

@@ -1,13 +1,17 @@
import React from 'react';
import ReactDOM from 'react-dom';
import App from './App';
import { AuthProvider } from './contexts/AuthContext';
import React from 'react'
import ReactDOM from 'react-dom'
import App from './App'
import {AuthProvider} from './contexts/AuthContext'
import CssBaseline from '@mui/material/CssBaseline'
import '@fontsource/roboto'
import './styles/index.css'
ReactDOM.render(
<React.StrictMode>
<AuthProvider>
<CssBaseline />
<App />
</AuthProvider>
</React.StrictMode>,
document.getElementById('root'),
);
)

View File

@@ -1,9 +1,11 @@
:root {
--online: #44b700;
--offline: rgb(183, 68, 0);
}
body {
margin: 0;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen', 'Ubuntu',
'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue', sans-serif;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
font-family: 'Roboto';
font-size: 16px;
}
.App {
@@ -12,7 +14,19 @@ body {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
}
.header {
width: 100%;
padding: 0 1rem;
display: flex;
align-items: center;
justify-content: space-between;
}
.error {
margin: 0.5rem;
color: red;
text-align: center;
}