Hi everyone ;).
As a reminder, I'm doing this Tweeter challenge
Before starting to code, I'd like to discuss about the way I will implement the authentication for now. I really wanted to focus on GraphQL for this project so I didn't want to take too much time on the authentication. But I changed my mind a little bit. For now, I will save the token in the localStorage and then pass it as an authorization header. However, I think I will write an article about this specific issue and discuss about my use case to have feedback from more experienced people. I read a lot about spa's authentication and it's pretty complex. That's why I'd like to discuss that in its own article ;). Be aware that localStorage is vulnerable to XSS attacks.
Register page
src/pages/Register.tsx
import { useMutation } from '@apollo/client'
import { yupResolver } from '@hookform/resolvers/yup'
import React, { useState } from 'react'
import { useForm } from 'react-hook-form'
import { MdEmail, MdLock, MdPeople } from 'react-icons/md'
import { useHistory } from 'react-router-dom'
import { useSetRecoilState } from 'recoil'
import Alert from '../components/Alert'
import Button from '../components/Button'
import Input from '../components/Input'
import Layout from '../components/Layout'
import { REGISTER } from '../graphql/auth/mutations'
import { userState } from '../state/userState'
import { handleErrors } from '../utils/utils'
import { registerSchema } from '../validations/auth/schema'
const Register = () => {
const setUser = useSetRecoilState(userState)
const [registerMutation, { loading }] = useMutation(REGISTER)
const { register, handleSubmit, errors } = useForm({
resolver: yupResolver(registerSchema),
})
const [serverErrors, setServerErrors] = useState<any>([])
const history = useHistory()
const registerUser = async (formData: any) => {
setServerErrors([])
try {
const res = await registerMutation({
variables: {
input: formData,
},
})
const { token, user } = res.data.register
localStorage.setItem('token', token)
setUser(user)
history.push('/')
} catch (e) {
setServerErrors(handleErrors(e))
}
}
return (
<Layout>
<h1 className="text-3xl mb-4 font-bold">Register</h1>
<form className="w-full" onSubmit={handleSubmit(registerUser)}>
{serverErrors.length > 0 && (
<div className="mb-4">
{serverErrors.map((e: any) => (
<Alert variant="danger" message={e.message} />
))}
</div>
)}
<Input
label="Enter your username"
name="username"
icon={<MdPeople />}
ref={register}
error={errors.username?.message}
/>
<Input
label="Enter your Display Name"
name="display_name"
icon={<MdPeople />}
ref={register}
error={errors.display_name?.message}
/>
<Input
label="Enter your email"
name="email"
type="email"
icon={<MdEmail />}
ref={register}
error={errors.email?.message}
/>
<Input
label="Enter your password"
name="password"
type="password"
icon={<MdLock />}
ref={register}
error={errors.password?.message}
/>
<Button
disabled={loading}
type="submit"
text="Register"
variant="primary"
/>
</form>
</Layout>
)
}
export default Register
There are a lot of things going on here. Let's start with my custom Input and Button components
src/components/Input.tsx
import { forwardRef, InputHTMLAttributes } from 'react'
type InputProps = {
icon?: JSX.Element
error?: string
label?: string
} & InputHTMLAttributes<HTMLInputElement>
const Input = forwardRef(
({ icon, error, label, ...rest }: InputProps, ref: any) => {
return (
<div className="mb-4">
{label && (
<label className="text-sm" htmlFor={rest.name}>
{label}
</label>
)}
<div className="bg-gray1 flex items-center border px-2 py-1 border-gray2 rounded-lg ">
{icon}
<input
id={rest.name}
style={{ minWidth: 0 }}
className="bg-transparent placeholder-gray4 ml-2 w-full h-full p-2 rounded-lg"
{...rest}
ref={ref}
/>
</div>
{error && <p className="text-red-500 text-sm">{error}</p>}
</div>
)
}
)
export default Input
I need to create a forwardRef since I will use the react-hook-form to control the input. So I need to pass the ref to register the input.
src/components/Button.tsx
import { ButtonHTMLAttributes } from 'react'
type ButtonProps = {
text: string
variant: string
icon?: JSX.Element
alignment?: 'left' | 'right'
} & ButtonHTMLAttributes<HTMLButtonElement>
const classes: any = {
primary: 'bg-primary text-white hover:bg-primary_hover',
}
const Button = ({
text,
variant,
icon,
alignment = 'left',
...rest
}: ButtonProps) => {
return (
<button
className={`${classes[variant]} flex items-center justify-center px-4 py-2 rounded transition-colors duration-300`}
{...rest}
>
{icon && alignment === 'left' && <div className="mr-2">{icon}</div>}
{text}
{icon && alignment === 'right' && <div className="ml-2">{icon}</div>}
</button>
)
}
export default Button
I also extended the tailwindcss.config.js to define some colors and other variables.
For the validation's rules, I will use yup with the yup resolver from react-hook-form. Here is the schema that I will use. Note that I also modified the pattern in the backend for the Display Name.
src/validations/auth/schema.ts
import * as yup from 'yup'
export const registerSchema = yup.object().shape({
username: yup
.string()
.trim()
.matches(
/^[\w]{2,30}$/,
'The username should only contains alphanumeric characters, underscores, and should have a length between 2 to 30'
)
.required(),
email: yup.string().trim().email().required(),
display_name: yup
.string()
.trim()
.matches(
/^[\w\s]{2,30}$/,
'The display name should only contains alphanumeric characters, spaces, underscores and should have a length between 2 to 30'
)
.required(),
password: yup.string().min(6).required(),
})
One thing that I don't like is how the validation errors are formatted from the class-validator library. I created two utility functions for now.
src/utils/utils.ts
import { ApolloError } from '@apollo/client'
export const formatValidationErrors = (errors: any) => {
let newErrors: any = []
if (errors[0].message !== 'Argument Validation Error') {
return errors[0]
}
const validationErrors = errors[0].extensions.exception?.validationErrors
validationErrors.forEach((error: any) => {
const field = error.property
const constraints = error.constraints
newErrors.push({
field,
message: Object.values(constraints)[0],
})
})
return newErrors
}
export const handleErrors = (e: any) => {
let errors = []
if (e instanceof ApolloError) {
if (
e.graphQLErrors &&
e.graphQLErrors[0].message === 'Argument Validation Error'
) {
errors.push(formatValidationErrors(e.graphQLErrors))
} else {
errors.push(e)
}
} else {
errors.push(e)
}
return errors
}
src/graphql/auth/mutations.ts
export const REGISTER = gql`
mutation($input: RegisterPayload!) {
register(input: $input) {
token
user {
id
username
display_name
email
created_at
updated_at
}
}
}
`
Otherwise, nothing too particular for the registration. I make use of the useMutation from @apollo/client and handle the result. If the request is successful, I save the token in localStorage and also set the user in my global store. For that, I use the recoil library. It's really easy to setup.
index.tsx
import { ApolloProvider } from '@apollo/client'
import React from 'react'
import ReactDOM from 'react-dom'
import App from './App'
import client from './client'
import { RecoilRoot } from 'recoil'
import './styles/index.css'
ReactDOM.render(
<RecoilRoot>
<ApolloProvider client={client}>
<App />
</ApolloProvider>
</RecoilRoot>,
document.getElementById('root')
)
I wrap everything in the component. I will then create a user atom to save my user.
src/state/userState.ts
export const userState = atom({
key: 'userState',
default: null,
})
When I need to store the user, I use the useSetRecoilState(userState) hook from recoil. You also have the useRecoilValue if you just want to read the value. And finally, if you need to read or write the value, you have to use the useRecoilState.
Once the registration is complete, I redirect the user to my Home page.
Here is what the Register Page looks like:
Login Page
src/pages/Login
import { ApolloError, useMutation } from '@apollo/client'
import { yupResolver } from '@hookform/resolvers/yup'
import React, { useState } from 'react'
import { useForm } from 'react-hook-form'
import { MdPeople, MdEmail, MdLock } from 'react-icons/md'
import { useHistory } from 'react-router-dom'
import { useSetRecoilState } from 'recoil'
import Alert from '../components/Alert'
import Button from '../components/Button'
import Input from '../components/Input'
import Layout from '../components/Layout'
import { LOGIN } from '../graphql/auth/mutations'
import { userState } from '../state/userState'
import { handleErrors } from '../utils/utils'
import { loginSchema } from '../validations/auth/schema'
const Login = () => {
const setUser = useSetRecoilState(userState)
const [loginMutation, { loading }] = useMutation(LOGIN)
const { register, handleSubmit, errors } = useForm({
resolver: yupResolver(loginSchema),
})
const [serverErrors, setServerErrors] = useState<any>([])
const history = useHistory()
const loginUser = async (formData: any) => {
console.log('formData', formData)
setServerErrors([])
try {
const res = await loginMutation({
variables: {
input: formData,
},
})
const { token, user } = res.data.login
localStorage.setItem('token', token)
setUser(user)
history.push('/')
} catch (e) {
if (e instanceof ApolloError) {
setServerErrors(handleErrors(e))
}
}
}
return (
<Layout>
<h1 className="text-3xl mb-4 font-bold">Login</h1>
<form className="w-full" onSubmit={handleSubmit(loginUser)}>
{serverErrors.length > 0 && (
<div className="mb-4">
{serverErrors.map((e: any) => (
<Alert variant="danger" message={e.message} />
))}
</div>
)}
<Input
label="Enter your email"
name="email"
type="email"
icon={<MdEmail />}
ref={register}
error={errors.email?.message}
/>
<Input
label="Enter your password"
name="password"
type="password"
icon={<MdLock />}
ref={register}
error={errors.password?.message}
/>
<Button
disabled={loading}
type="submit"
text="Login"
variant="primary"
/>
</form>
</Layout>
)
}
export default Login
It's really similar to the Register Page...
src/graphql/mutations
export const LOGIN = gql`
mutation($input: LoginPayload!) {
login(input: $input) {
token
user {
id
username
display_name
email
created_at
updated_at
}
}
}
`
Private / Public Page
In the application, the login/register pages should only be accessible if a user is not connected. Conversely, the home page should only be accessible if I have a connected user. To do that, I will create two components.
src/components/PublicRoute.tsx
import { Redirect, Route, RouteProps } from 'react-router-dom'
import { useRecoilValue } from 'recoil'
import { userState } from '../state/userState'
type PublicRouteProps = {
children: React.ReactNode
} & RouteProps
const PublicRoute = ({ children, ...rest }: PublicRouteProps) => {
const user = useRecoilValue(userState)
return (
<Route
{...rest}
render={() => (!user ? children : <Redirect to={{ pathname: '/' }} />)}
/>
)
}
export default PublicRoute
src/components/PrivateRoute.tsx
import { Redirect, Route, RouteProps } from 'react-router-dom'
import { useRecoilValue } from 'recoil'
import { userState } from '../state/userState'
type PrivateRouteProps = {
children: React.ReactNode
} & RouteProps
const PrivateRoute = ({ children, ...rest }: PrivateRouteProps) => {
const user = useRecoilValue(userState)
return (
<Route
{...rest}
render={() =>
user ? children : <Redirect to={{ pathname: '/login' }} />
}
/>
)
}
export default PrivateRoute
I then need to wrap my Route using these two components:
App.tsx
import React from 'react'
import { BrowserRouter as Router, Switch } from 'react-router-dom'
import Navbar from './components/Navbar'
import PrivateRoute from './components/PrivateRoute'
import PublicRoute from './components/PublicRoute'
import { useInitAuth } from './hooks/useInitAuth'
import Home from './pages/Home'
import Login from './pages/Login'
import Register from './pages/Register'
function App() {
const { init } = useInitAuth()
if (init) return <div>Loading...</div>
return (
<Router>
<Switch>
<PublicRoute exact path="/login">
<Login />
</PublicRoute>
<PublicRoute exact path="/register">
<Register />
</PublicRoute>
<PrivateRoute exact path="/">
<Home />
</PrivateRoute>
</Switch>
</Router>
)
}
export default App
You can also see a custom Hook here. The useInitAuth will initialize the app making a request if I have a token in the localStorage to fetch the user and then redirecting the user to the correct path.
src/hooks/useInitAuth.ts ( I added comments to explain what's going on )
import { useLazyQuery } from '@apollo/client'
import { useCallback, useEffect, useState } from 'react'
import { useRecoilState } from 'recoil'
import { ME } from '../graphql/auth/queries'
import { userState } from '../state/userState'
export const useInitAuth = () => {
const [user, setUser] = useRecoilState(userState)
const [init, setInit] = useState(true)
const [me, { data, loading, error }] = useLazyQuery(ME)
const fetchUser = useCallback(async () => {
const token = localStorage.getItem('token')
// If I have a token, I fetch the user
// else I stop here and redirect to the login page
if (token) {
me()
} else {
setInit(false)
}
}, [])
// Launch the fetchUser function when the component is mounted
useEffect(() => {
fetchUser()
}, [])
// If I receive data from the "me" query, I set the user
useEffect(() => {
if (data) {
setUser(data.me)
}
}, [data])
// I check if the user is set before redirecting to avoid ui to flicker.
// setState being asynchrone
useEffect(() => {
if (user) {
setInit(false)
}
}, [user])
// If I receive an error, I remove the token from the localStorage
// and it will then be handle by the PrivateRoute/PublicRoute component
useEffect(() => {
if (error) {
localStorage.removeItem('token')
setInit(false)
}
}, [error])
return { init }
}
But for this to work, I will have to set the token as an authorization header. Therefore, I need to change my client a little bit.
src/client/index.ts
import { ApolloClient, from, HttpLink, InMemoryCache } from '@apollo/client'
import { setContext } from '@apollo/client/link/context'
// I add the token as an authorization header
const authLink = setContext((_, { headers }) => {
const token = localStorage.getItem('token')
if (token) {
return {
headers: {
authorization: 'Bearer ' + token,
},
}
}
})
const httpLink = new HttpLink({
uri: process.env.REACT_APP_BACKEND_URL || 'http://localhost:4000',
})
const client = new ApolloClient({
link: authLink.concat(httpLink),
cache: new InMemoryCache(),
})
export default client
src/graphql/auth/queries.ts
import { gql } from '@apollo/client'
export const ME = gql`
query {
me {
id
username
display_name
email
created_at
updated_at
}
}
`
I think I talked about pretty much everything I did to implement this simple authentication. If you have some questions, feel free to ask ;)
Bye everyone ;).
Take care! ;)
Top comments (0)