Back
Chapters

Adding logout feature

Search icon
Search Book
⌘K

Features

Until now, users once logged in, had no way of logging out of our application. Let us implement the logout feature:

  • We can add a logout button in our navbar.

  • Clicking the logout button should terminate the session and redirect the user to the login page.

  • From now onwards, we should keep track of who created which task. Later, we can use this to limit access to a task to its owner and its assignee only.

  • Task detail page should also contain the name of task owner.

Technical design

We will do the following to implement the given requirements:

  • We will create a destroy action in SessionsController. We use this action to clean up the session on the backend when the user logs out. We can map this action to the DELETE request to the /session URL.

  • After calling the API, we will clear our browser's localStorage and reset the headers from axios configuration.

  • Now, to add information about the task owner, we will need a new column task_owner_id in tasks table.

  • We will add a foreign_key constraint on task_owner_id to create a relationship between a task and its corresponding user who is the owner of the task.

  • We will declare a has_many association with a custom name of created_tasks in the User model and a belongs_to association for task_owner in the Task model.

  • Inside the TasksController, we will get the currently logged-in user details via the current_user method in the ApplicationController. We can use the current user's created_tasks association to create a new task.

  • To send the task owner's name in JSON response, we will have to update the show action's corresponding Jbuilder view builder.

  • To display the task creator's name in task details page, we will need to update the ShowTask component to render task owner's name received from backend along with other task details.

Sessions controller

Open app/controllers/sessions_controller.rb and add the following lines:

1class SessionsController < ApplicationController
2  skip_before_action :authenticate_user_using_x_auth_token, only: :create
3
4  def create
5    @user = User.find_by!(email: login_params[:email].downcase)
6    unless @user.authenticate(login_params[:password])
7      respond_with_error(t("session.incorrect_credentials"), :unauthorized)
8    end
9  end
10
11  def destroy
12    @current_user = nil
13    # any other session cleanup tasks can be done here...
14  end
15
16  private
17
18    def login_params
19      params.require(:login).permit(:email, :password)
20    end
21end

In the above code, along with adding a destroy action, we have also updated the skip_before_action filter to be effective only when the request is for create action.

Session routes

Let's modify routes.rb and add a destroy action for session:

1Rails.application.routes.draw do
2   constraints(lambda { |req| req.format == :json }) do
3    resources :tasks, except: %i[new edit], param: :slug
4    resources :users, only: %i[create index]
5    resource :session, only: [:create, :destroy]
6  end
7
8  root "home#index"
9  get '*path', to: 'home#index', via: :all
10end

Frontend changes

Open apis/auth.js and add following lines:

1import axios from "axios";
2
3const login = payload => axios.post("/session", payload);
4
5const logout = () => axios.delete(`/session`);
6
7const signup = payload => axios.post("/users", payload);
8
9const authApi = {
10  login,
11  logout,
12  signup,
13};
14
15export default authApi;

Let's open apis/axios.js and create a function to clear the default Axios headers when the user is logged out. Add the resetAuthTokens method at the end of the file and also update the export statement like so:

1import axios from "axios";
2import Toastr from "components/Common/Toastr";
3import { setToLocalStorage, getFromLocalStorage } from "utils/storage";
4
5const DEFAULT_ERROR_NOTIFICATION = "Something went wrong!";
6
7axios.defaults.baseURL = "/";
8
9const setAuthHeaders = (setLoading = () => null) => {
10  axios.defaults.headers = {
11    Accept: "application/json",
12    "Content-Type": "application/json",
13    "X-CSRF-TOKEN": document
14      .querySelector('[name="csrf-token"]')
15      .getAttribute("content"),
16  };
17  const token = getFromLocalStorage("authToken");
18  const email = getFromLocalStorage("authEmail");
19  if (token && email) {
20    axios.defaults.headers["X-Auth-Email"] = email;
21    axios.defaults.headers["X-Auth-Token"] = token;
22  }
23  setLoading(false);
24};
25
26const handleSuccessResponse = response => {
27  if (response) {
28    response.success = response.status === 200;
29    if (response.data.notice) {
30      Toastr.success(response.data.notice);
31    }
32  }
33  return response;
34};
35
36const handleErrorResponse = axiosErrorObject => {
37  if (axiosErrorObject.response?.status === 401) {
38    setToLocalStorage({ authToken: null, email: null, userId: null });
39    setTimeout(() => (window.location.href = "/"), 2000);
40  }
41  Toastr.error(
42    axiosErrorObject.response?.data?.error || DEFAULT_ERROR_NOTIFICATION
43  );
44  if (axiosErrorObject.response?.status === 423) {
45    window.location.href = "/";
46  }
47  return Promise.reject(axiosErrorObject);
48};
49
50const registerIntercepts = () => {
51  axios.interceptors.response.use(handleSuccessResponse, error =>
52    handleErrorResponse(error)
53  );
54};
55
56const resetAuthTokens = () => {
57  delete axios.defaults.headers["X-Auth-Email"];
58  delete axios.defaults.headers["X-Auth-Token"];
59};
60
61export { setAuthHeaders, registerIntercepts, resetAuthTokens };

Now, let's open the NavBar/index.jsx component and make use of the logout API that we added before to make the logout logic complete. Replace the whole content of NavBar/index.jsx with this code:

1import React from "react";
2import NavItem from "./NavItem";
3import authApi from "apis/auth";
4import { resetAuthTokens } from "src/apis/axios";
5import { getFromLocalStorage, setToLocalStorage } from "utils/storage";
6
7const NavBar = () => {
8  const userName = getFromLocalStorage("authUserName");
9  const handleLogout = async () => {
10    try {
11      await authApi.logout();
12      setToLocalStorage({
13        authToken: null,
14        email: null,
15        userId: null,
16        userName: null,
17      });
18      resetAuthTokens();
19      window.location.href = "/";
20    } catch (error) {
21      logger.error(error);
22    }
23  };
24
25  return (
26    <nav className="bg-white shadow">
27      <div className="px-2 mx-auto max-w-7xl sm:px-4 lg:px-8">
28        <div className="flex justify-between h-16">
29          <div className="flex px-2 lg:px-0">
30            <div className="hidden lg:flex">
31              <NavItem name="Todos" path="/dashboard" />
32              <NavItem
33                name="Add"
34                iconClass="ri-add-fill"
35                path="/tasks/create"
36              />
37            </div>
38          </div>
39          <div className="flex items-center justify-end gap-x-4">
40            <span
41              className="inline-flex items-center px-2 pt-1 text-sm
42              font-regular leading-5 text-bb-gray-600 text-opacity-50
43              transition duration-150 ease-in-out border-b-2
44              border-transparent focus:outline-none
45              focus:text-bb-gray-700"
46            >
47              {userName}
48            </span>
49
50            <a
51              onClick={handleLogout}
52              className="inline-flex items-center px-1 pt-1 text-sm
53              font-semibold leading-5 text-bb-gray-600 text-opacity-50
54              transition duration-150 ease-in-out border-b-2
55              border-transparent hover:text-bb-gray-600 focus:outline-none
56              focus:text-bb-gray-700 cursor-pointer"
57            >
58              LogOut
59            </a>
60          </div>
61        </div>
62      </div>
63    </nav>
64  );
65};
66
67export default NavBar;

We are making use of the setToLocalStorage helper method created in the previous section to clear the localStorage. The user will be then redirected to the login page if the logout was successfully done in the server too, where we destroy the @current_user instance variable.

Now let's commit these changes:

1git add -A
2git commit -m "Added ability to logout"

Storing information about who created the task

As discussed earlier, let's add a new column task_owner_id to tasks table using a new migration. We will also add a foreign key constraint on task_owner_id column which will contain the referenced primary key from a record in the users table.

Run the following command to generate the migration:

1bundle exec rails g migration AddTaskOwnerIdToTask
1class AddTaskOwnerIdToTask < ActiveRecord::Migration[6.1]
2  def change
3    add_column :tasks, :task_owner_id, :integer
4    add_foreign_key :tasks, :users, column: :task_owner_id, on_delete: :cascade
5  end
6end

The on_delete: :cascade option makes sure that the referencing rows, which here are the rows of the task table, also get deleted when deleting the rows of the referenced table, which is the user table in this case.

In our case, when deleting users, all the tasks created by them will also get deleted.

Note that rows of users table won't be affected when tasks are deleted. The foreign key relation is unidirectional.

Again, once we create a new migration, let's persist that into our database:

1bundle exec rails db:migrate

Adding association for task owner

Every task in the database is associated with the user who created it and a user can create multiple such tasks. Hence we should declare appropriate associations in the User and Task models.

Add the following line of code inside app/models/user.rb to add a has_many association called created_tasks for the User model:

1class User < ApplicationRecord
2  has_many :created_tasks, foreign_key: :task_owner_id, class_name: "Task"
3
4  # previous code
5
6  private
7
8    # previous code
9end

Similarly we should update the Task model and add a belongs_to association called task_owner like so:

1class Task < ApplicationRecord
2  belongs_to :task_owner, foreign_key: "task_owner_id", class_name: "User"
3
4  # previous code
5
6  private
7
8    # previous code
9end

Now we have created associations for both task owner and the assigned user. We know that all tasks will be deleted when the user who created those tasks is deleted.

But what happens when the assigned user is deleted? Ideally the task should be assigned back to the task owner but in our case the assigned user will be set to nil when that happens.

To fix this, let us add a method inside the User model to assign back the tasks to the task owner in the event of assigned user getting deleted. Update app/models/user.rb with the following lines of code:

1class User < ApplicationRecord
2  # previous code
3  before_destroy :assign_tasks_to_task_owners
4
5  private
6
7    # previous code
8
9    def assign_tasks_to_task_owners
10      tasks_whose_owner_is_not_current_user = assigned_tasks.select { |task| task.task_owner_id != id }
11      tasks_whose_owner_is_not_current_user.each do |task|
12        task.update(assigned_user_id: task.task_owner_id)
13      end
14    end
15end

When assign_tasks_to_task_owners is invoked on a user object, all the assigned_tasks for that user are fetched and out of those tasks we are selecting only those tasks which are created by another user and saving the result in tasks_to_be_reassigned variable.

Tasks owned by the user should be deleted and it doesn't make sense for us to perform the reassigning operation on a task which is about to be deleted from the database. Besides if the task_owner and the assigned_user are same then reassigning isn't required.

By default before_destroy callback method which is assign_tasks_to_task_owner will be invoked every time, right before a user record gets deleted.

Creating a task using Task owner

In our application all tasks are associated to users. We cannot create a task unless there is no user. Each task is created by a user who is related to the task using the task_owner association and the task is also assigned to a user. Task assignee is related to a task using the assigned_user association.

So far we had only been saving tasks with an assigned_user. We also need to add a task owner for tasks. To do so, we can pass the task_owner_id in the task_params while creating a new task. While this works, we'd have to explicitly pass the task_owner_id as parameter.

We know that the task_owner will always be the currently logged in user. In our application, current_user refers to the currently logged in user. We can use the current_user.created_tasks association to create a task. This way we do not have to specify the task_owner_id in task_params. When we use current_user.created_tasks, Rails knows that the new task's task_owner_id is equal to current user's id.

Rails knows this because we have specified the task_owner_id foreign key for created_tasks association in the User model. This saves time, and makes code more fluent as well as adhering to the business logic.

It's generally frowned upon to use Model.new for creating new records. Whenever possible use the already available association or instance variables itself.

Update the create method of TasksController like so:

1class TasksController < ApplicationController
2  # previous code
3
4  def create
5    task = current_user.created_tasks.new(task_params)
6    task.save!
7    respond_with_success(t("successfully_created", entity: "Task"))
8  end
9
10  # previous code
11end

Before moving on, we should delete the existing task records in our database as they do not have a task_owner and this can lead to errors later on. Use the following command to delete all the existing task records and their associations from the database.

1Task.destroy_all

Showing task owner

task_owner can be accessed in the show action's corresponding Jbuilder template file where we can include it as a key in the response JSON along with other task attributes.

Let's update the show.json.jbuilder view template for tasks and add the task_owner key to the response JSON. To do so, add the following lines to /app/views/tasks/show.json.jbuilder:

1json.task do
2  json.extract! @task,
3    :id,
4    :slug,
5    :title
6
7  json.assigned_user do
8    json.id @task.assigned_user.id
9    json.name @task.assigned_user.name
10  end
11
12  json.task_owner do
13    json.extract! @task.task_owner,
14      :name
15  end
16end

Now, update the ShowTask component to display the task owner name along with other task details. Add the following lines of code to the ShowTask component:

1// previous imports
2
3const ShowTask = () => {
4  // previous code
5
6  return (
7    <Container>
8      // previous code
9      <h2 className="pb-3 mb-3 text-md leading-5 text-bb-gray-600 text-opacity-50">
10        <span>Created By : </span>
11        {task?.task_owner?.name}
12      </h2>
13    </Container>
14  );
15};
16
17export default ShowTask;

Now let's commit these changes:

1git add -A
2git commit -m "Added task owner to tasks"