[ PART 15 ][Frontend] Create a Twitter clone with GraphQL, Typescript, and React ( Authentication )

[ PART 15 ][Frontend] Create a Twitter clone with GraphQL, Typescript, and React ( Authentication )

Hi everyone ;).

As a reminder, I'm doing this Tweeter challenge

Github repository

Db diagram

Before starting to code, I'd like to discuss the way I will implement the authentication for now. I 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 my use case to have feedback from more experienced people. I read a lot about spa authentication and it's pretty complex. That's why I'd like to discuss that in its 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 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 about 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 set up.

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:

Register Page

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 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
      }
    }
  }
`

Login Page

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 requesting if I have a token in the localStorage to fetch the user and then redirect 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! ;)