Search
⌘K
    to navigateEnterto select Escto close

    Adding logout feature

    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 /sessions 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 instance variable. We can pass the @current_user.id value as task_owner_id while creating tasks.

    • 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  before_action :authenticate_user_using_x_auth_token, only: [:destroy]
    3
    4  def create
    5    @user = User.find_by(email: login_params[:email].downcase)
    6    unless @user.present? && @user.authenticate(login_params[:password])
    7      render status: :unauthorized, json: { error: t("session.incorrect_credentials") }
    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

    Session routes

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

    1Rails.application.routes.draw do
    2  defaults format: :json do
    3    resources :tasks, except: %i[new edit], param: :slug
    4    resources :users, only: %i[create index]
    5    resource :sessions, only: %i[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("/sessions", payload);
    4
    5const logout = () => axios.delete(`/sessions`);
    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 "helpers/storage.js";
    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 "helpers/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="/" />
    32              <NavItem
    33                name="Create"
    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.

    Setting the task owner id of a task

    We always want the task_owner_id of a task to be set as the id of the current_user, because the current user is the one who creates the task.

    To implement that, we need to change the create action of Tasks controller, as follows:

    1class TasksController < ApplicationController
    2  before_action :authenticate_user_using_x_auth_token
    3  before_action :load_task, only: %i[show update destroy]
    4
    5  # ----- previous code ------
    6
    7  def create
    8    task = Task.new(task_params.merge(task_owner_id: @current_user.id))
    9    if task.save
    10      render status: :ok, json: { notice: t('successfully_created', entity: 'Task') }
    11    else
    12      errors = @task.errors.full_messages.to_sentence
    13      render status: :unprocessable_entity, json: { error: errors }
    14    end
    15  end
    16
    17  # ----- previous code ------
    18
    19  private
    20
    21    def task_params
    22      params.require(:task).permit(:title, :assigned_user_id)
    23    end
    24
    25  # ----- previous code ------
    26end

    With this change, the created task always has its task_owner_id set to that of the current_user.

    Before moving on, we should delete the existing task records in our database as they do not have a task_owner_id 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"
    Previous
    Next