Skip to content

a

Login in frontend

In the last two parts, we have mainly concentrated on the backend. The frontend that we developed in part 2 does not yet support the user management we implemented to the backend in part 4.

At the moment the frontend shows existing tasks and lets users change the state of a task from important to not important and vice versa. Unfortunately, new tasks can't be added anymore because our backend expects that a token verifying a user's identity be sent with new tasks.

We'll now implement a part of the required user management functionality in the frontend. Let's begin with the user login. Throughout this part, we will assume that new users will not be added from the frontend.

Handling login

Let's add a login form to the top of the page. That code will be shown below. Let's also move the form for adding tasks, so that it sits just below the login form We'll task you with doing this, which you can continue on from the previous reading frontend located in your lab2.

browser showing user login for tasks

Our new code for App.jsx in our reading folder is below:

const App = () => {
  const [tasks, setTasks] = useState([]) 
  const [newTask, setNewTask] = useState('')
  const [showAll, setShowAll] = useState(true)
  const [errorMessage, setErrorMessage] = useState(null)
  const [username, setUsername] = useState('')   const [password, setPassword] = useState('') 
  useEffect(() => {
    taskService
      .getAll().then(initialTasks => {
        setTasks(initialTasks)
      })
  }, [])

  // ...

  const handleLogin = (event) => {    event.preventDefault()    console.log('logging in with', username, password)  }
  return (
    <div>
      <h1>Tasks</h1>

      <Notification message={errorMessage} />

      <form onSubmit={handleLogin}>        <div>          username            <input            type="text"            value={username}            name="Username"            onChange={({ target }) => setUsername(target.value)}          />        </div>        <div>          password            <input            type="password"            value={password}            name="Password"            onChange={({ target }) => setPassword(target.value)}          />        </div>        <button type="submit">login</button>      </form>      <hr/>
      <!--- add form and other tasks code here --->
    </div>
  )
}

export default App

The current application code can be found on Github, branch part5-1.

Notice If you clone the repo, don't forget to run npm i before attempting to run the frontend.

The frontend will not display any tasks if it's not connected to the backend. You'll need to open up both repositories and start them both.

repository command port
backend npm run dev 3001
frontend npm run dev 5173

After starting both, you will see the tasks that are saved in your MongoDB database from Part 4.

You'll need to start both from now on if you want full functionality

The login form is handled the same way we handled forms in part 2. The app state has fields for username and password to store the data from the form. The form fields have event handlers, which synchronize changes in the field to the state of the App component. The event handlers are simple: An object is given to them as a parameter, and they destructure the field target from the object and save its value to the state.

({ target }) => setUsername(target.value)

The method handleLogin, which is responsible for handling the data in the form, is yet to be implemented.

Logging in is done by sending an HTTP POST request to the server address api/login. Let's separate the code responsible for this request on the frontend into its own module, to the file services/login.js.

We'll use async/await syntax instead of promises for the HTTP request:

import axios from 'axios'
const baseUrl = '/api/login'

const login = async credentials => {
  const response = await axios.post(baseUrl, credentials)
  return response.data
}

export default { login }

The handleLogin function and the other parts of App.jsx can be changed as follows:

import loginService from './services/login'
const App = () => {
  // ...
  const [username, setUsername] = useState('') 
  const [password, setPassword] = useState('') 
  const [user, setUser] = useState(null)  
  const handleLogin = async (event) => {    event.preventDefault()
    
    try {      const user = await loginService.login({        username, password,      })      setUser(user)      setUsername('')      setPassword('')    } catch (exception) {      setErrorMessage('Wrong credentials')      setTimeout(() => {        setErrorMessage(null)      }, 5000)    }  }

  // ...
}

If the login is successful, the form fields are emptied and the server response (including a token and the user details) is saved to the user field of the application's state.

If the login fails or the function loginService.login throws an error, the user is notified.

The user is not notified about a successful login in any way. Let's modify the application to show the login form only if the user is not logged in so when user === null. The form for adding new tasks is shown only if the user is logged in, so user contains the user details.

Let's add two helper functions to the App component for generating the forms:

const App = () => {
  // ...

  const loginForm = () => (
    <form onSubmit={handleLogin}>
      <div>
        username
          <input
          type="text"
          value={username}
          name="Username"
          onChange={({ target }) => setUsername(target.value)}
        />
      </div>
      <div>
        password
          <input
          type="password"
          value={password}
          name="Password"
          onChange={({ target }) => setPassword(target.value)}
        />
      </div>
      <button type="submit">login</button>
    </form>      
  )

  const taskForm = () => (
    <form onSubmit={addTask}>
      <input
        value={newTask}
        onChange={handleTaskChange}
      />
      <button type="submit">save</button>
    </form>  
  )

  return (
    // ...
  )
}

and conditionally render them:

const App = () => {
  // ...

  const loginForm = () => (
    // ...
  )

  const taskForm = () => (
    // ...
  )

  return (
    <div>
      <h1>Tasks</h1>

      <Notification message={errorMessage} />

      {user === null && loginForm()}      {user !== null && taskForm()}
      <div>
        <button onClick={() => setShowAll(!showAll)}>
          show {showAll ? 'important' : 'all'}
        </button>
      </div>
      <ul>
        {tasksToShow.map((task, i) => 
          <Task
            key={i}
            task={task} 
            toggleImportance={() => toggleImportanceOf(task.id)}
          />
        )}
      </ul>

      <Footer />
    </div>
  )
}

A slightly odd looking, but commonly used React trick is used to render the forms conditionally:

{
  user === null && loginForm()
}

If the first statement evaluates to false or is falsy, the second statement (generating the form) is not executed at all.

We can make this even more straightforward by using the conditional operator:

return (
  <div>
    <h1>Tasks</h1>

    <Notification message={errorMessage}/>
    {user === null ?      loginForm() :      taskForm()    }    // ...

  </div>
)

If user === null evaluates to true, the code calls loginForm(). Otherwise, it calls taskForm().

Let's do one more modification. If the user is logged in, their name is shown on the screen:

return (
  <div>
    <h1>Tasks</h1>

    <Notification message={errorMessage} />

    {!user && loginForm()}    {user &&       <div>        <p>{user.name} logged in</p>        {taskForm()}      </div>    }
    // ...

  </div>
)

The solution isn't perfect, but we'll leave it for now.

Our main component App is at the moment way too large. The changes we just made can make us realize that the forms should be refactored into separate components. However, we will leave that for an optional exercise.

The current application code can be found on GitHub, branch part5-2.

Creating new tasks

Let's fix creating new tasks so it works with the backend. This means we'll need to include a user's token when adding tasks.

Currently, the token returned with a successful login is saved to the application's state - the user's field token:

const handleLogin = async (event) => {
  event.preventDefault()
  try {
    const user = await loginService.login({
      username, password,
    })

    setUser(user)    setUsername('')
    setPassword('')
  } catch (exception) {
    // ...
  }
}

We'll then need to access and include this user's token in the Authorization header of the HTTP request.

To include the token, we'll modify services/tasks.js:

import axios from 'axios'
const baseUrl = '/api/tasks'

let token = null
const setToken = newToken => {  token = `Bearer ${newToken}`}
const getAll = () => {
  const request = axios.get(baseUrl)
  return request.then(response => response.data)
}

const create = async newObject => {  const config = {    headers: { Authorization: token },  }  const response = await axios.post(baseUrl, newObject, config)  return response.data}
const update = (id, newObject) => {
  const request = axios.put(`${ baseUrl }/${id}`, newObject)
  return request.then(response => response.data)
}

export default { getAll, create, update, setToken }

This taskService module contains a private variable token. Its value can be changed with a function setToken, which is exported by the module. create, now with async/await syntax, sets the token to the Authorization header. The header is given to axios as the third parameter of the post method.

In addition to the changes in taskService, we'll need to change App.jsx's handleLogin so that it saves the user's token (taskService.setToken(user.token)) with a successful login:

const handleLogin = async (event) => {
  event.preventDefault()
  try {
    const user = await loginService.login({
      username, password,
    })

    taskService.setToken(user.token)    setUser(user)
    setUsername('')
    setPassword('')
  } catch (exception) {
    // ...
  }
}

And now adding new tasks works again!

Saving the token to the browser's local storage

While we should celebrate our progress, you may also notice that our application has a bug.

Don't see the bug yet? Try to refresh the page (F5).

Notice how *the user's login information disappeared after refreshing the page. This bug also slows down development, because we have to keep logging in everytime...even when testing task creation.

This problem is easily solved by saving the login details to local storage. Local Storage is a key-value database in the browser.

It is very easy to use. A value corresponding to a certain key is saved to the database with the method setItem. For example:

window.localStorage.setItem('name', 'slim shady')

saves the string given as the second parameter as the value of the key name.

The value of a key can be found with the method getItem:

window.localStorage.getItem('name')

and removeItem removes a key.

Values in the local storage are persisted even when the page is re-rendered. The storage is origin-specific so each web application has its separate storage.

Let's extend our application so that it saves the details of a logged-in user to local storage.

Values saved to storage are DOMstrings, so we cannot save a JavaScript object as it is. The object has to be parsed to JSON first, with the method JSON.stringify. Correspondingly, when a JSON object is read from the local storage, it has to be parsed back to JavaScript with JSON.parse.

Our handleLogin method now adds code to set the local storage:

  const handleLogin = async (event) => {
    event.preventDefault()
    try {
      const user = await loginService.login({
        username, password,
      })

      window.localStorage.setItem(        'loggedTaskappUser', JSON.stringify(user)      )      taskService.setToken(user.token)
      setUser(user)
      setUsername('')
      setPassword('')
    } catch (exception) {
      // ...
    }
  }

The details of a logged-in user are now saved to the local storage, and they can be viewed on the console (by typing window.localStorage to the console):

browser showing someone logged into tasks

You can also inspect the local storage using the developer tools. On Chrome, go to the Application tab and select Local Storage (details here). On Firefox go to the Storage tab and select Local Storage (details here).

We still have to modify our application so that when we enter the page, the application checks if local storage has details for a logged-in user. If there is, the details are saved to the state of the application and to taskService.

The right way to do this is with an effect hook: a mechanism we first encountered in part 2, and used to fetch tasks from the server.

We can have multiple effect hooks, so let's create an additional hook to handle the initial loading of the page:

const App = () => {
  const [tasks, setTasks] = useState([]) 
  const [newTask, setNewTask] = useState('')
  const [showAll, setShowAll] = useState(true)
  const [errorMessage, setErrorMessage] = useState(null)
  const [username, setUsername] = useState('') 
  const [password, setPassword] = useState('') 
  const [user, setUser] = useState(null) 

  useEffect(() => {
    taskService
      .getAll().then(initialTasks => {
        setTasks(initialTasks)
      })
  }, [])

  useEffect(() => {    const loggedUserJSON = window.localStorage.getItem('loggedTaskappUser')    if (loggedUserJSON) {      const user = JSON.parse(loggedUserJSON)      setUser(user)      taskService.setToken(user.token)    }  }, [])
  // ...
}

The empty array [] as the parameter of the effect ensures that the effect is executed only when the component is rendered for the first time.

Now a user stays logged in to the application forever.

Pertinent: We should probably add a way to logout, which can be done by removing the login details from the local storage.

We will leave that as an exercise, as it uhhhh... "builds character" 🧐.

It's possible to log out a user using the console, and that is enough for now. You can log out with the command:

window.localStorage.removeItem('loggedTaskappUser')

or with the command which empties localStorage:

window.localStorage.clear()

The current application code can be found on GitHub, branch part5-3.

A notice about using local storage

At the end of the last part, we mentioned that the challenge of token-based authentication is how to cope with the situation when the API access of the token holder to the API needs to be revoked.

There are two solutions to the problem. The first one is to limit the validity period of a token. This forces the user to re-login to the app once the token has expired. The other approach is to save the validity information of each token to the backend database. This solution is often called a server-side session.

No matter how the validity of tokens is checked and ensured, saving a token in the local storage might contain a security risk if the application has a security vulnerability that allows Cross-Site Scripting (XSS) attacks. An XSS attack is possible if the application allows a user to inject arbitrary JavaScript code (e.g. using a form) that the app would then execute. When using React sensibly it should not be possible since React sanitizes all text that it renders, meaning that it is not executing the rendered content as JavaScript.

If one wants to play it safe, the best option is to not store a token in local storage. This might be an option in situations where leaking a token might have tragic consequences.

It has been suggested that the identity of a signed-in user should be saved as httpOnly cookies, so that JavaScript code could not have any access to the token. The drawback of this solution is that it would make implementing SPA applications a bit more complex. One would need at least to implement a separate page for logging in.

However, it is good to notice that even the use of httpOnly cookies does not guarantee anything. It has even been suggested that httpOnly cookies are not any safer than the use of local storage.

So no matter the used solution the most important thing is to minimize the risk of XSS attacks altogether.