DEV Community

loading...
Cover image for [ PART 15 ][Frontend] Creating a Twitter clone with GraphQL, Typescript, and React ( Authentication )

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

ips-coding-challenge
On a journey to become a developer ;).
・8 min read

Hi everyone ;).

As a reminder, I'm doing this Tweeter challenge

Github repository

Db diagram

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

Enter fullscreen mode Exit fullscreen mode

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

Enter fullscreen mode Exit fullscreen mode

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

Enter fullscreen mode Exit fullscreen mode

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(),
})
Enter fullscreen mode Exit fullscreen mode

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
}

Enter fullscreen mode Exit fullscreen mode

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
      }
    }
  }
`
Enter fullscreen mode Exit fullscreen mode

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')
)

Enter fullscreen mode Exit fullscreen mode

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,
})

Enter fullscreen mode Exit fullscreen mode

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

Enter fullscreen mode Exit fullscreen mode

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
      }
    }
  }
`
Enter fullscreen mode Exit fullscreen mode

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

Enter fullscreen mode Exit fullscreen mode

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

Enter fullscreen mode Exit fullscreen mode

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

Enter fullscreen mode Exit fullscreen mode

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

Enter fullscreen mode Exit fullscreen mode

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

Enter fullscreen mode Exit fullscreen mode

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

Enter fullscreen mode Exit fullscreen mode

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! ;)

Discussion (0)

Forem Open with the Forem app