diff --git a/backend/areas/auth/auth.py b/backend/areas/auth/auth.py
index 5ea14d93a7721ac74bd976a2c3698df8f1b1b434..6cd27d343e77514b37f9147d93c31d42d9d775d0 100644
--- a/backend/areas/auth/auth.py
+++ b/backend/areas/auth/auth.py
@@ -1,4 +1,4 @@
-from flask import jsonify, request
+from flask import current_app, jsonify, request
 from flask_jwt_extended import create_access_token
 from flask_cors import cross_origin
 from datetime import timedelta
@@ -29,24 +29,22 @@ def hydra_callback():
 
     token = HydraOauth.get_token(state, code)
     user_info = HydraOauth.get_user_info()
-    # Match Kratos identity with Hydra
-    identities = KratosApi.get("/identities")
-    identity = None
-    for i in identities.json():
-        if i["traits"]["email"] == user_info["email"]:
-            identity = i
+    kratos_id = user_info["sub"]
 
-    # Short lifetime for token. If the session is still active, it will be
-    # automatically renewed via Hydra.
-    access_token = create_access_token(
-        identity=token, expires_delta=timedelta(hours=1), additional_claims={"user_id": identity["id"]}
-    )
+    # TODO: add a check to see if this a valid ID/active account
+
+    try:
+        access_token = create_access_token(
+            identity=token, expires_delta=timedelta(hours=1), additional_claims={"user_id": kratos_id}
+        )
+    except Exception as e:
+        raise BadRequest("Error with creating auth token between backend and frontend")
 
     apps = App.query.all()
     app_roles = []
     for app in apps:
         tmp_app_role = AppRole.query.filter_by(
-            user_id=identity["id"], app_id=app.id
+            user_id=kratos_id, app_id=app.id
         ).first()
         app_roles.append(
             {
@@ -59,7 +57,7 @@ def hydra_callback():
         {
             "accessToken": access_token,
             "userInfo": {
-                "id": identity["id"],
+                "id": kratos_id,
                 "email": user_info["email"],
                 "name": user_info["name"],
                 "preferredUsername": user_info["preferred_username"],
diff --git a/backend/areas/users/user_service.py b/backend/areas/users/user_service.py
index 75166d12474e31d8781e63e2eec703a43cb0b8da..ebb965a1b494d2d8d559c21d477e0307bb442f56 100644
--- a/backend/areas/users/user_service.py
+++ b/backend/areas/users/user_service.py
@@ -22,10 +22,18 @@ kratos_identity_api = identity_api.IdentityApi(kratos_client)
 class UserService:
     @staticmethod
     def get_users():
-        res = KratosApi.get("/admin/identities").json()
+        page = 1
         userList = []
-        for r in res:
-            userList.append(UserService.__insertAppRoleToUser(r["id"], r))
+        while page > 0:
+            res = KratosApi.get("/admin/identities?per_page=1000&page={}".format(page)).json()
+            for r in res:
+                # removed the app role assignment function, passing simple user data
+                # userList.append(UserService.__insertAppRoleToUser(r["id"], r))
+                userList.append(r)
+            if len(res) == 0:
+                page = -1
+            else:
+                page = page + 1
 
         return userList
 
@@ -135,6 +143,38 @@ class UserService:
 
         return UserService.get_user(id)
 
+    @staticmethod
+    def put_multiple_users(user_editing_id, data):
+        for user_data in data["users"]:
+            kratos_data = {
+                # "schema_id": "default",
+                "traits": {"email": user_data["email"]},
+            }
+            KratosApi.put("/admin/identities/{}".format(user_data["id"]), kratos_data)
+
+            is_admin = RoleService.is_user_admin(user_editing_id)
+
+            if is_admin and user_data["app_roles"]:
+                app_roles = user_data["app_roles"]
+                for ar in app_roles:
+                    app = App.query.filter_by(slug=ar["name"]).first()
+                    app_role = AppRole.query.filter_by(
+                        user_id=user_data["id"], app_id=app.id).first()
+
+                    if app_role:
+                        app_role.role_id = ar["role_id"] if "role_id" in ar else None
+                        db.session.commit()
+                    else:
+                        appRole = AppRole(
+                            user_id=user_Data["id"],
+                            role_id=ar["role_id"] if "role_id" in ar else None,
+                            app_id=app.id,
+                        )
+                        db.session.add(appRole)
+                        db.session.commit()
+
+            return UserService.get_user(user_data["id"])
+
     @staticmethod
     def delete_user(id):
         app_role = AppRole.query.filter_by(user_id=id).all()
diff --git a/backend/areas/users/users.py b/backend/areas/users/users.py
index 25d906e593b405cb9a6a19f21a44f123194c181a..a00e7d14671a0911c9064a990367f910529e9be6 100644
--- a/backend/areas/users/users.py
+++ b/backend/areas/users/users.py
@@ -7,7 +7,7 @@ from areas import api_v1
 from helpers import KratosApi
 from helpers.auth_guard import admin_required
 
-from .validation import schema, schema_multiple
+from .validation import schema, schema_multiple, schema_multi_edit
 from .user_service import UserService
 
 
@@ -83,6 +83,18 @@ def post_multiple_users():
     return jsonify(res)
 
 
+# multi-user editing of app roles
+@api_v1.route("/users-multi-edit", methods=["PUT"])
+@jwt_required()
+@cross_origin()
+@expects_json(schema_multi_edit)
+@admin_required()
+def put_multiple_users():
+    data = request.get_json()
+    user_id = __get_user_id_from_jwt()
+    res = UserService.put_multiple_users(user_id, data)
+    return jsonify(res)
+
 @api_v1.route("/me", methods=["GET"])
 @jwt_required()
 @cross_origin()
diff --git a/backend/areas/users/validation.py b/backend/areas/users/validation.py
index 4131c838a4e59cd6f9d3fc33b4e706cdbdf42d89..972a024373dfa19df3b426209ffdcecffb7b22ee 100644
--- a/backend/areas/users/validation.py
+++ b/backend/areas/users/validation.py
@@ -17,7 +17,7 @@ schema = {
                     "name": {
                         "type": "string",
                         "description": "Name of the app",
-                        "minLenght": 1,
+                        "minLength": 1,
                     },
                     "role_id": {
                         "type": ["integer", "null"],
@@ -40,3 +40,45 @@ schema_multiple = {
         }
     }
 }
+
+# Multiple app role edit of existing users
+schema_multi_edit = {
+    "users": {
+        "type": "array",
+        "items" : {
+            "type": "object",
+            "properties": {
+                "email": {
+                    "type": "string",
+                    "description": "Email of the user",
+                    "minLength": 1,
+                },
+                "id": {
+                    "type": "string",
+                    "description": "UUID of the user",
+                    "minLength": 1,
+                },
+                "app_roles": {
+                    "type": "array",
+                    "items": {
+                        "type": "object",
+                        "properties": {
+                            "name": {
+                                "type": "string",
+                                "description": "Name of the app",
+                                "minLength": 1,
+                            },
+                            "role_id": {
+                                "type": ["integer", "null"],
+                                "description": "Role of the user",
+                                "minimum": 1,
+                            },
+                        },
+                        # "required": ["name", "role_id"],
+                    },
+                },
+            },
+            # "required": ["email", "app_roles"],
+        }
+    }
+}
\ No newline at end of file
diff --git a/backend/helpers/kratos_user.py b/backend/helpers/kratos_user.py
index 3615809e0adcd29abcdd167a69bb4ca6cf7aafb0..894332f1e87fc387ebf2106ad021dc1d231c06a3 100644
--- a/backend/helpers/kratos_user.py
+++ b/backend/helpers/kratos_user.py
@@ -133,12 +133,18 @@ class KratosUser():
         kratos_id = None
 
         # Get out user ID by iterating over all available IDs
-        data = api.list_identities()
-        for kratos_obj in data:
-            # Unique identifier we use
-            if kratos_obj.traits['email'] == email:
-                kratos_id = str(kratos_obj.id)
-                return KratosUser(api, kratos_id)
+        page = 1
+        while page > 0:
+            data = api.list_identities(per_page=1000, page=page)
+            for kratos_obj in data:
+                # Unique identifier we use
+                if kratos_obj.traits['email'] == email:
+                    kratos_id = str(kratos_obj.id)
+                    return KratosUser(api, kratos_id)
+            if len(data) == 0:
+                page = -1
+            else:
+                page = page + 1
 
         return None
 
@@ -151,11 +157,17 @@ class KratosUser():
 
         kratos_id = None
         return_list = []
-        # Get out user ID by iterating over all available IDs
-        data = api.list_identities()
-        for kratos_obj in data:
-            kratos_id = str(kratos_obj.id)
-            return_list.append(KratosUser(api, kratos_id))
+        # Get out user ID by iterating over all available ID
+        page = 1
+        while page > 0:
+            data = api.list_identities(per_page=1000, page=page)
+            for kratos_obj in data:
+                kratos_id = str(kratos_obj.id)
+                return_list.append(KratosUser(api, kratos_id))
+            if len(data) == 0:
+                page = -1
+            else:
+                page = page + 1
 
         return return_list
 
diff --git a/backend/web/login/login.py b/backend/web/login/login.py
index e03b03ed3f07a347a40897f4b49aa0e485cb0647..2abbcc01e3bb35fea3b599c7ae08c4e5c27d3bb6 100644
--- a/backend/web/login/login.py
+++ b/backend/web/login/login.py
@@ -4,9 +4,10 @@ Hydra for OIDC sessions and MariaDB for application and role specifications.
 The application provides also several command line options to interact with
 the user entries in the database(s)"""
 
+import ast
+import json
 import urllib.parse
 import urllib.request
-import ast
 
 import ory_hydra_client
 # hydra v2
@@ -129,17 +130,23 @@ def login():
     """
 
     # Check if we are logged in:
-    identity = get_auth()
+    (identity, auth_response) = get_auth()
+    # We ignore the potential `auth_response` in this case: that's for telling
+    # the user they have to upgrade their session to include a second factor,
+    # but we're already on the login page so there's no use for that here --
+    # they'd be redirected by Kratos back to this same login page anyway,
+    # creating a redirect loop. Chances are that if `auth_response` is not
+    # None, we're actually serving or processing the TOTP form here.
 
     # List to contain messages pushed to the frontend
     messages = list()
 
-
     refresh = False
     flow = request.args.get("flow")
     if flow:
         cookies = request.headers['cookie']
         flow = kratos_public_frontend_api.get_login_flow(flow, cookie=cookies)
+        # current_app.logger.info("flow found in login: {}".format(flow))
         refresh = flow['refresh']
         if refresh:
             message = {
@@ -241,7 +248,12 @@ def auth():
         abort(400, description="Challenge required when requesting authorization")
 
     # Check if we are logged in:
-    identity = get_auth()
+    (identity, auth_response) = get_auth()
+
+    if auth_response is not None:
+        # According to `get_auth`, we have to send the user a response already,
+        # probably a redirect to let the user provide their second factor.
+        return auth_response
 
     # If the user is not logged in yet, we redirect to the login page
     # but before we do that, we set the "flow_state" cookie to auth.
@@ -464,7 +476,7 @@ def status():
     Show if there is an user is logged in. If not shows: not-auth
     """
 
-    auth_status = get_auth()
+    (auth_status, auth_response) = get_auth()
 
     if auth_status:
         return auth_status.id
@@ -475,27 +487,43 @@ def get_auth():
     """Checks if user is logged in
     Queries the cookies. If an authentication cookie is found, it
     checks with Kratos if the cookie is still valid. If so,
-    the profile is returned. Otherwise False is returned.
-    :return: Profile or False if not logged in
+    the profile is returned. Otherwise False is returned, possibly with a
+    response to send to the user, for redirecting them to the kratos-suggested
+    url, for providing 2FA in particular.
+    :return: (Profile, None) or (False, None) or (False, Response)
     """
 
     cookie = get_kratos_cookie()
     if not cookie:
-        return False
+        return False, None
 
     # Given a cookie, check if it is valid and get the profile
     try:
         api_response = kratos_public_frontend_api.to_session(cookie=cookie)
 
         # Get all traits from ID
-        return api_response.identity
+        return api_response.identity, None
 
     except ory_kratos_client.ApiException as ex:
+        # If it fails because the client needs to provide 2FA, we return a
+        # redirect response for use by the caller of this function.
+        if ex.body is not None:
+            body = json.loads(ex.body)
+            current_app.logger.info("Error in to_session: {}".format(body))
+            error_id = body.get('error', {}).get('id')
+            if error_id == 'session_aal2_required':
+                current_app.logger.info("2FA requested by Kratos. Redirecting the user.")
+                redirect_url = body.get('redirect_browser_to')
+                if redirect_url is None:
+                    response = None
+                else:
+                    response = redirect(redirect_url)
+                return False, response
         current_app.logger.error(
             f"Exception when calling to_session(): {ex}\n"
         )
 
-    return False
+    return False, None
 
 
 def get_kratos_cookie():
diff --git a/backend/web/static/base.js b/backend/web/static/base.js
index 61d08200d5409383f40d02e7aa7d32530eb10142..bf79733c8c7eaf990a84556bf5753d67430be9e9 100644
--- a/backend/web/static/base.js
+++ b/backend/web/static/base.js
@@ -16,8 +16,8 @@
 
 */
 
-// In default configuration the dashboed is on '/'. This can be overwritten
-// before calling the scripts (and configured by the flask app
+// In default configuration the dashboard is on '/'. This can be overwritten
+// before calling the scripts (and configured by the flask app).
 var dashboard_url = "";
 
 // Render a message by appending the data to the messages box. The message id is
@@ -42,6 +42,7 @@ function renderMessage(id, message, type) {
 // case.
 function check_flow_auth() {
 	var state = Cookies.get("flow_state");
+	window.console.log("check_flow_auth: flow_state=" + state);
 	var url = Cookies.get("auth_url");
 
 	// Redirect to the specified URL
@@ -51,6 +52,15 @@ function check_flow_auth() {
 		return;
 	}
 
+	if (state == "recovery") {
+		Cookies.set("flow_state", "");
+		// Set a custom cookie so the settings page knows we're in
+		// recovery context and can open the right tab.
+		Cookies.set("stackspin_context", "recovery");
+		window.location.href = api_url + '/self-service/settings/browser';
+		return;
+	}
+
 	// Some older stackspin releases, do not provide the dashboard_url,
 	// flask writes 'None' as string in that case, we want to cover those
 	// cases and revert to the default
@@ -76,7 +86,6 @@ function check_flow_expired() {
 function flow_login() {
 	var flow = $.urlParam("flow");
 	var uri = api_url + "self-service/login/flows?id=" + flow;
-
 	// Query the Kratos backend to know what fields to render for the
 	// current flow
 	$.ajax({
@@ -112,7 +121,6 @@ function flow_login() {
 function flow_settings_validate() {
 	var flow = $.urlParam("flow");
 	var uri = api_url + "self-service/settings/flows?id=" + flow;
-
 	$.ajax({
 		type: "GET",
 		url: uri,
@@ -153,6 +161,7 @@ function flow_settings() {
 		url: uri,
 		success: function (data) {
 			var state = Cookies.get("flow_state");
+			var context = Cookies.get("stackspin_context");
 
 			// If we have confirmation the settings are saved, show the
 			// notification
@@ -203,6 +212,12 @@ function flow_settings() {
 					},
 				});
 			});
+
+			// If we are in recovery context, switch to the password tab.
+			if (context == "recovery") {
+				$('#pills-password-tab').tab('show');
+				Cookies.set('stackspin_context', '');
+			}
 		},
 		complete: function (obj) {
 			// If we get a 410, the flow is expired, need to refresh the flow
@@ -211,6 +226,25 @@ function flow_settings() {
 				window.location.href = "settings";
 			}
 		},
+		error: function (xhr, textStatus, errorThrown) {
+			// Check if we got a 403 error from Kratos.
+			if (textStatus == "error" && xhr.status == 403) {
+				var response = $.parseJSON(xhr.responseText);
+				window.console.log(response);
+				if (response.error.id == "session_aal2_required") {
+					// Redirect so user can enter 2FA.
+					window.location.href = response.redirect_browser_to;
+					return;
+                                }
+			}
+			// There was another error, one we don't specifically prepared for.
+			$("#contentProfileSaveFailed").show();
+
+			// For now, this code assumes that only the password can fail
+			// validation. Other forms might need to be added in the future.
+			html = render_form(data, "password", "validation");
+			$("#contentPassword").html(html);
+		},
 	});
 }
 
diff --git a/frontend/package.json b/frontend/package.json
index daaa1f9a4e1ff6abe55fc7a6acd7a14494a599cf..a56e177575604dc3873831e890e7b01dc5dacf1a 100644
--- a/frontend/package.json
+++ b/frontend/package.json
@@ -10,6 +10,8 @@
     "@hookform/resolvers": "^2.6.1",
     "@tailwindcss/forms": "^0.3.3",
     "@tailwindcss/typography": "^0.4.1",
+    "@tanstack/match-sorter-utils": "^8.8.4",
+    "@tanstack/react-table": "^8.9.3",
     "@testing-library/jest-dom": "^5.11.4",
     "@testing-library/react": "^11.1.0",
     "@testing-library/user-event": "^12.1.10",
diff --git a/frontend/src/components/Form/Select/Select.tsx b/frontend/src/components/Form/Select/Select.tsx
index 06a5322f710e625ab06c5cbe4c2b13429fe1e8b9..aad51fa9d97789c95635086d21ff6655aabccda1 100644
--- a/frontend/src/components/Form/Select/Select.tsx
+++ b/frontend/src/components/Form/Select/Select.tsx
@@ -26,7 +26,7 @@ export const Select = ({ control, name, label, options, disabled = false }: Sele
         value={field.value ? field.value : ''} // input value
         name={name} // send down the input name
         ref={field.ref} // send input ref, so we can focus on input when error appear
-        className="block shadow-sm focus:ring-primary-500 focus:border-primary-500 block w-full sm:text-sm border-gray-300 rounded-md"
+        className="shadow-sm focus:ring-primary-500 focus:border-primary-500 block w-full sm:text-sm border-gray-300 rounded-md"
         disabled={disabled}
       >
         {options?.map((option) => (
diff --git a/frontend/src/components/MultiEditUserModal/MultiEditUserModal.tsx b/frontend/src/components/MultiEditUserModal/MultiEditUserModal.tsx
new file mode 100644
index 0000000000000000000000000000000000000000..37b5614ec5a71d9ef8a3bbc6a2e894d24fa512b9
--- /dev/null
+++ b/frontend/src/components/MultiEditUserModal/MultiEditUserModal.tsx
@@ -0,0 +1,310 @@
+import React, { useEffect, useState } from 'react';
+import _ from 'lodash';
+import { useFieldArray, useForm, useWatch } from 'react-hook-form';
+import { Banner, ConfirmationModal, Modal } from 'src/components';
+import { Select } from 'src/components/Form';
+import { User, UserRole, MultiEditUser, useUsers, NoChange } from 'src/services/users';
+import { useAuth } from 'src/services/auth';
+import { AppStatusEnum } from 'src/services/apps/types';
+
+import { HIDDEN_APPS } from 'src/modules/dashboard/consts';
+// import { initialUserForm } from './consts';
+import { TrashIcon } from '@heroicons/react/outline';
+
+import { MultiEditUserModalProps } from './types';
+
+export const MultiEditUserModal = ({ open, onClose, userIds, setUserId, apps }: MultiEditUserModalProps) => {
+  const [deleteModal, setDeleteModal] = useState(false);
+  const [isAdminRoleSelected, setAdminRoleSelected] = useState(true);
+  const {
+    user,
+    editUserById,
+    deleteUserById,
+    // editMultipleUsers,
+    userModalLoading,
+    clearSelectedUser,
+  } = useUsers();
+  const { currentUser, isAdmin } = useAuth();
+
+  // Extending the app list with "No Change" value, so that
+  // there is a sane default selection
+  // when doing multi-user edits
+
+  interface AppListInt {
+    name: string;
+    role: UserRole | NoChange;
+  }
+  const appList: AppListInt[] = [];
+  const initialAppRoleLatest = () => {
+    apps
+      .filter((app) => app.status !== AppStatusEnum.NotInstalled)
+      .map((app) => appList.push({ name: app.slug, role: NoChange.NoChange }));
+  };
+  initialAppRoleLatest();
+
+  const userIdsList: string[] = [];
+  const populateUserIdsList = () => {
+    userIds.map((id: any) => userIdsList.push(id.original.id));
+  };
+  populateUserIdsList();
+
+  const userNamesList: string[] = [];
+  const populateUserNamesList = () => {
+    userIds.map((id: any) => userNamesList.push(id.original.name));
+  };
+  populateUserNamesList();
+
+  const userEmailsList: string[] = [];
+  const populateUserEmailsList = () => {
+    userIds.map((id: any) => userEmailsList.push(id.original.email));
+  };
+  populateUserEmailsList();
+
+  const initialUserForm = {
+    userEmails: userEmailsList,
+    userIds: userIdsList,
+    app_roles: appList,
+    userNames: userNamesList,
+  };
+
+  // populate the initial "New User" window with installed apps and default roles
+  const { control, reset, handleSubmit } = useForm<MultiEditUser>({
+    defaultValues: initialUserForm,
+  });
+
+  const { fields, update } = useFieldArray({
+    control,
+    name: 'app_roles',
+  });
+
+  useEffect(() => {
+    if (!_.isEmpty(user)) {
+      reset(user);
+    }
+  }, [user, reset, open]);
+
+  const dashboardRole = useWatch({
+    control,
+    name: 'app_roles.0.role',
+  });
+
+  useEffect(() => {
+    const isAdminDashboardRoleSelected = dashboardRole === UserRole.Admin;
+    setAdminRoleSelected(isAdminDashboardRoleSelected);
+    if (isAdminDashboardRoleSelected) {
+      fields.forEach((field, index) => update(index, { name: field.name, role: UserRole.Admin }));
+    } else {
+      fields.forEach((field, index) => update(index, { name: field.name, role: NoChange.NoChange }));
+    }
+    // eslint-disable-next-line react-hooks/exhaustive-deps
+  }, [dashboardRole]);
+
+  function transformSubmitData(data: MultiEditUser) {
+    const userBatch: any = [];
+    const editedAppList: any = [];
+    const populateEditedAppList = () => {
+      data.app_roles
+        .filter((role) => role.role !== NoChange.NoChange)
+        .map((role) => editedAppList.push({ name: role.name, role: role.role }));
+    };
+    populateEditedAppList();
+    const populateUserBatch = () => {
+      data.userIds.map((userId, index) =>
+        userBatch.push({
+          email: data.userEmails[index],
+          name: data.userNames[index],
+          id: userId,
+          app_roles: editedAppList,
+        }),
+      );
+    };
+    populateUserBatch();
+
+    return userBatch;
+  }
+
+  const handleSave = async () => {
+    try {
+      await handleSubmit((data) => {
+        const transformedData = transformSubmitData(data);
+        // For now, this function loops over users and sends multiple individual PUT requests.
+        // Once the JSON payload schema issue is solved, we can test the batch edit
+        // with the below command
+        // (remember to also uncomment the import on top of this file)
+        // return editMultipleUsers(transformedData);
+        transformedData.forEach((userId: User) => {
+          return editUserById(userId);
+        });
+      })();
+    } catch (e: any) {
+      // Continue
+    }
+
+    onClose();
+    clearSelectedUser();
+  };
+
+  const handleClose = () => {
+    onClose();
+    clearSelectedUser();
+  };
+
+  const deleteModalOpen = () => setDeleteModal(true);
+  const deleteModalClose = () => setDeleteModal(false);
+
+  const handleDelete = () => {
+    userIdsList.forEach((id: string) => {
+      deleteUserById(id);
+    });
+
+    clearSelectedUser();
+    setUserId(null);
+    handleClose();
+    deleteModalClose();
+  };
+
+  // Button with delete option.
+  const buttonDelete = () => {
+    return (
+      !userIdsList.includes(currentUser?.id as string) && (
+        <button
+          onClick={deleteModalOpen}
+          type="button"
+          className="mb-4 sm:mb-0 inline-flex items-center px-4 py-2 text-sm font-medium rounded-md text-red-700 bg-red-50 hover:bg-red-100 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-red-500"
+        >
+          <TrashIcon className="-ml-0.5 mr-2 h-4 w-4" aria-hidden="true" />
+          Delete {userIds.length} users
+        </button>
+      )
+    );
+  };
+  return (
+    <>
+      <Modal
+        onClose={handleClose}
+        open={open}
+        onSave={handleSave}
+        isLoading={userModalLoading}
+        leftActions={<>{buttonDelete()}</>}
+        useCancelButton
+      >
+        <div className="bg-white px-4">
+          <div className="space-y-10 divide-y divide-gray-200">
+            <div>
+              <div>
+                <h3 className="text-lg leading-6 font-medium text-gray-900">Edit {userIds.length} users</h3>
+              </div>
+
+              <div className="mt-6 grid grid-cols-1 gap-y-6 gap-x-4 sm:grid-cols-6">
+                {isAdmin && (
+                  <>
+                    <div className="sm:col-span-3">
+                      {fields
+                        .filter((field) => field.name === 'dashboard')
+                        .map((item, index) => (
+                          <Select
+                            key={item.name}
+                            control={control}
+                            name={`app_roles.${index}.role`}
+                            label="Role"
+                            options={[
+                              { value: UserRole.User, name: 'User' },
+                              { value: UserRole.Admin, name: 'Admin' },
+                            ]}
+                          />
+                        ))}
+                    </div>
+                    <div className="sm:col-span-3 opacity-40 cursor-default pointer-events-none select-none">
+                      <label htmlFor="status" className="block text-sm font-medium text-gray-700">
+                        Status
+                      </label>
+                      <div className="mt-1">
+                        <select
+                          id="status"
+                          name="status"
+                          className="shadow-sm focus:ring-primary-500 focus:border-primary-500 block w-full sm:text-sm border-gray-300 rounded-md"
+                        >
+                          <option>Active</option>
+                          <option>Inactive</option>
+                          <option>Banned</option>
+                        </select>
+                      </div>
+                    </div>
+                  </>
+                )}
+              </div>
+            </div>
+            {isAdmin && !userModalLoading && (
+              <div>
+                <div className="mt-8">
+                  <h3 className="text-lg leading-6 font-medium text-gray-900">App Access</h3>
+                </div>
+
+                {isAdminRoleSelected && (
+                  <div className="sm:col-span-6">
+                    <Banner
+                      title="Admin users automatically have admin-level access to all apps."
+                      titleSm="Admin user"
+                    />
+                  </div>
+                )}
+
+                {!isAdminRoleSelected && (
+                  <div>
+                    <div className="flow-root mt-6">
+                      <ul className="-my-5 divide-y divide-gray-200">
+                        {fields.map((item, index) => {
+                          if (item.name != null && HIDDEN_APPS.indexOf(item.name) !== -1) {
+                            return null;
+                          }
+
+                          return (
+                            <li className="py-4" key={item.name}>
+                              <div className="flex items-center space-x-4">
+                                <div className="flex-shrink-0 flex-1 flex items-center">
+                                  <img
+                                    className="h-10 w-10 rounded-md overflow-hidden"
+                                    src={_.find(apps, ['slug', item.name!])?.assetSrc}
+                                    alt={item.name ?? 'Image'}
+                                  />
+                                  <h3 className="ml-4 text-md leading-6 font-medium text-gray-900">
+                                    {_.find(apps, ['slug', item.name!])?.name}
+                                  </h3>
+                                </div>
+                                <div>
+                                  <Select
+                                    key={item.id}
+                                    control={control}
+                                    name={`app_roles.${index}.role`}
+                                    disabled={isAdminRoleSelected}
+                                    options={[
+                                      { value: NoChange.NoChange, name: '...' },
+                                      { value: UserRole.NoAccess, name: 'No Access' },
+                                      { value: UserRole.User, name: 'User' },
+                                      { value: UserRole.Admin, name: 'Admin' },
+                                    ]}
+                                  />
+                                </div>
+                              </div>
+                            </li>
+                          );
+                        })}
+                      </ul>
+                    </div>
+                  </div>
+                )}
+              </div>
+            )}
+          </div>
+        </div>
+      </Modal>
+      <ConfirmationModal
+        onDeleteAction={handleDelete}
+        open={deleteModal}
+        onClose={deleteModalClose}
+        title="Delete user"
+        body={`You are about to delete ${userIds.length} users. Are sure you want to delete them? All of the user data will be permanently removed. This action cannot be undone.`}
+      />
+    </>
+  );
+};
diff --git a/frontend/src/components/MultiEditUserModal/consts.ts b/frontend/src/components/MultiEditUserModal/consts.ts
new file mode 100644
index 0000000000000000000000000000000000000000..bb750df0b4d7952d5c3d3e9a85f9651ce579a09a
--- /dev/null
+++ b/frontend/src/components/MultiEditUserModal/consts.ts
@@ -0,0 +1,96 @@
+// This file is still being used in the AppSingle.tsx file
+// to populate the single app card with URLs and images.
+// Single App is not an active view at the moment,
+// so automating this is not a priority at the moment.
+// once we activate single app views, we will need to use the API call
+// to populate the AppSingle card with this info.
+// See UserModal.tsx for inspiration, search for initialAppRoleLatest()
+
+// import { UserRole } from 'src/services/users';
+
+export const appAccessList = [
+  {
+    name: 'hedgedoc',
+    image: '/assets/hedgedoc.svg',
+    label: 'HedgeDoc',
+    documentationUrl: 'https://docs.hedgedoc.org/',
+  },
+  {
+    name: 'wekan',
+    image: '/assets/wekan.svg',
+    label: 'Wekan',
+    documentationUrl: 'https://github.com/wekan/wekan/wiki',
+  },
+  {
+    name: 'wordpress',
+    image: '/assets/wordpress.svg',
+    label: 'Wordpress',
+    documentationUrl: 'https://wordpress.org/support/',
+  },
+  {
+    name: 'nextcloud',
+    image: '/assets/nextcloud.svg',
+    label: 'Nextcloud',
+    documentationUrl: 'https://docs.nextcloud.com/server/latest/user_manual/en/',
+  },
+  {
+    name: 'zulip',
+    image: '/assets/zulip.svg',
+    label: 'Zulip',
+    documentationUrl: 'https://docs.zulip.com/help/',
+  },
+  {
+    name: 'monitoring',
+    image: '/assets/monitoring.svg',
+    label: 'Monitoring',
+    documentationUrl: 'https://grafana.com/docs/',
+  },
+];
+
+export const allAppAccessList = [
+  {
+    name: 'dashboard',
+    image: '/assets/logo-small.svg',
+    label: 'Dashboard',
+  },
+  ...appAccessList,
+];
+
+// export const initialAppRoles = [
+//   {
+//     name: 'dashboard',
+//     role: UserRole.User,
+//   },
+//   {
+//     name: 'hedgedoc',
+//     role: UserRole.User,
+//   },
+//   {
+//     name: 'wekan',
+//     role: UserRole.User,
+//   },
+//   {
+//     name: 'wordpress',
+//     role: UserRole.User,
+//   },
+//   {
+//     name: 'nextcloud',
+//     role: UserRole.User,
+//   },
+//   {
+//     name: 'zulip',
+//     role: UserRole.User,
+//   },
+//   {
+//     name: 'monitoring',
+//     role: UserRole.NoAccess,
+//   },
+// ];
+
+// export const initialUserForm = {
+//   id: '',
+//   name: '',
+//   email: '',
+//   app_roles: initialAppRoles,
+//   status: '',
+// };
diff --git a/frontend/src/components/MultiEditUserModal/index.ts b/frontend/src/components/MultiEditUserModal/index.ts
new file mode 100644
index 0000000000000000000000000000000000000000..15b8809c59059122e88ff8dbd70794cdc9b90302
--- /dev/null
+++ b/frontend/src/components/MultiEditUserModal/index.ts
@@ -0,0 +1 @@
+export { MultiEditUserModal } from './MultiEditUserModal';
diff --git a/frontend/src/components/MultiEditUserModal/types.ts b/frontend/src/components/MultiEditUserModal/types.ts
new file mode 100644
index 0000000000000000000000000000000000000000..a566c9b96c7af54862236d59caa85bde8ddc9e61
--- /dev/null
+++ b/frontend/src/components/MultiEditUserModal/types.ts
@@ -0,0 +1,9 @@
+import { App } from 'src/services/apps';
+
+export type MultiEditUserModalProps = {
+  open: boolean;
+  onClose: () => void;
+  userIds: any;
+  setUserId: any;
+  apps: App[];
+};
diff --git a/frontend/src/components/Table/Table.tsx b/frontend/src/components/Table/Table.tsx
index 95f22567a3a0760f239c38ab0f8a03b6f1e8840c..e8f46a5492cdb3533c469ab3d31ba4fcf5cfa981 100644
--- a/frontend/src/components/Table/Table.tsx
+++ b/frontend/src/components/Table/Table.tsx
@@ -1,6 +1,6 @@
 import { ArrowSmDownIcon, ArrowSmUpIcon } from '@heroicons/react/solid';
 import React, { useEffect } from 'react';
-import { useTable, useRowSelect, Column, IdType, useSortBy } from 'react-table';
+import { useTable, useRowSelect, Column, IdType, useSortBy, usePagination } from 'react-table';
 
 export interface ReactTableProps<T extends Record<string, unknown>> {
   columns: Column<T>[];
@@ -38,7 +38,7 @@ export const Table = <T extends Record<string, unknown>>({
   pagination = false,
   onRowClick,
   getSelectedRowIds,
-  selectable = false,
+  selectable = true,
   loading = false,
 }: ReactTableProps<T>) => {
   const {
@@ -55,6 +55,7 @@ export const Table = <T extends Record<string, unknown>>({
       data,
     },
     useSortBy,
+    usePagination,
     useRowSelect,
     selectable
       ? (hooks) => {
diff --git a/frontend/src/components/index.ts b/frontend/src/components/index.ts
index 64e430d54ff3ea8a1af2dae1ab0feac9e7c82450..954427fdfa6348af31f2fbf716ad77ba6b7eb874 100644
--- a/frontend/src/components/index.ts
+++ b/frontend/src/components/index.ts
@@ -6,3 +6,4 @@ export { Tabs } from './Tabs';
 export { Modal, ConfirmationModal, InfoModal, StepsModal } from './Modal';
 export { UserModal } from './UserModal';
 export { ProgressSteps } from './ProgressSteps';
+export { MultiEditUserModal } from './MultiEditUserModal';
diff --git a/frontend/src/modules/users/Users.tsx b/frontend/src/modules/users/Users.tsx
index 157b4cda9e61e2fe753134413a401a20551ce7e3..b8bbefe45de85fd13cd35ee777d61c56393ac7c9 100644
--- a/frontend/src/modules/users/Users.tsx
+++ b/frontend/src/modules/users/Users.tsx
@@ -4,25 +4,64 @@
  *
  * Admin users can add one or more users, or edit a user.
  */
-import React, { useState, useCallback, useEffect, useMemo } from 'react';
-import { SearchIcon, PlusIcon, ViewGridAddIcon } from '@heroicons/react/solid';
-import { CogIcon, TrashIcon } from '@heroicons/react/outline';
-import { useUsers } from 'src/services/users';
-import { Table } from 'src/components';
-import { debounce } from 'lodash';
+
+// React main
+import React, { useState, useCallback, useEffect, useMemo, HTMLProps } from 'react';
+
+// Icons
+import {
+  SearchIcon,
+  PlusIcon,
+  ViewGridAddIcon,
+  ChevronDownIcon,
+  ChevronUpIcon,
+  ChevronDoubleLeftIcon,
+  ChevronDoubleRightIcon,
+  ChevronLeftIcon,
+  ChevronRightIcon,
+} from '@heroicons/react/solid';
+import { CogIcon } from '@heroicons/react/outline';
+
+// API - Redux
+import { useUsers, User } from 'src/services/users';
 import { useAuth } from 'src/services/auth';
 import { useApps } from 'src/services/apps';
 
+// Regular Table
+// import { Table } from 'src/components';
+import { debounce } from 'lodash';
+
+// User Table
+import {
+  // Column,
+  ColumnDef,
+  flexRender,
+  getCoreRowModel,
+  getFilteredRowModel,
+  getPaginationRowModel,
+  getSortedRowModel,
+  SortingState,
+  // Table,
+  useReactTable,
+} from '@tanstack/react-table';
+
+import { MultiEditUserModal } from 'src/components';
+
+// Local components
 import { UserModal } from '../../components/UserModal';
 import { MultipleUsersModal } from './components';
 
+// ///////////////////////////////////////////
+
 export const Users: React.FC = () => {
-  const [selectedRowsIds, setSelectedRowsIds] = useState({});
+  // const [selectedRowsIds, setSelectedRowsIds] = useState({});
   const [configureModal, setConfigureModal] = useState(false);
   const [multipleUsersModal, setMultipleUsersModal] = useState(false);
+  const [multiEditUserModal, setMultiEditUserModal] = useState(false);
   const [userId, setUserId] = useState(null);
+  const [multiUserIds, setMultiUserIds] = useState(null);
   const [search, setSearch] = useState('');
-  const { users, loadUsers, userTableLoading } = useUsers();
+  const { users, loadUsers } = useUsers();
   const { isAdmin } = useAuth();
 
   const { apps, loadApps } = useApps();
@@ -46,7 +85,11 @@ export const Users: React.FC = () => {
   }, []);
 
   const filterSearch = useMemo(() => {
-    return users.filter((item: any) => item.email?.toLowerCase().includes(search.toLowerCase()));
+    return users.filter(
+      (item: any) =>
+        item.email?.toLowerCase().includes(search.toLowerCase()) ||
+        item.name?.toLowerCase().includes(search.toLowerCase()),
+    );
   }, [search, users]);
 
   const configureModalOpen = (id: any) => {
@@ -54,87 +97,143 @@ export const Users: React.FC = () => {
     setConfigureModal(true);
   };
 
+  const multiEditUserModalOpen = (ids: any) => {
+    setMultiUserIds(ids);
+    setMultiEditUserModal(true);
+  };
+
   const configureModalClose = () => setConfigureModal(false);
 
   const multipleUsersModalClose = () => setMultipleUsersModal(false);
 
-  const columns: any = React.useMemo(
-    () => [
-      {
-        Header: 'Name',
-        accessor: 'name',
-        width: 'auto',
-      },
-      {
-        Header: 'Email',
-        accessor: 'email',
-        width: 'auto',
-      },
-      {
-        Header: 'Status',
-        accessor: 'status',
-        width: 'auto',
-      },
-      {
-        Header: ' ',
-        Cell: (props: any) => {
-          const { row } = props;
-
-          if (isAdmin) {
-            return (
-              <div className="text-right lg:opacity-0 group-hover:opacity-100 transition-opacity">
-                <button
-                  onClick={() => configureModalOpen(row.original.id)}
-                  type="button"
-                  className="inline-flex items-center px-4 py-2 border border-gray-200 shadow-sm text-sm font-medium rounded-md text-gray-700 bg-white hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-primary-500"
-                >
-                  <CogIcon className="-ml-0.5 mr-2 h-4 w-4" aria-hidden="true" />
-                  Configure
-                </button>
-              </div>
-            );
-          }
+  const multiEditUserModalClose = () => setMultiEditUserModal(false);
 
-          return null;
-        },
-        width: 'auto',
-      },
-    ],
-    [isAdmin],
-  );
+  // const selectedRows = useCallback((rows: Record<string, boolean>) => {
+  //   setSelectedRowsIds(rows);
+  // }, []);
 
-  const selectedRows = useCallback((rows: Record<string, boolean>) => {
-    setSelectedRowsIds(rows);
-  }, []);
+  // ////////////////////////
+  // New Table Start
+  // ////////////////////////
 
-  return (
-    <div className="relative">
-      <div className="max-w-7xl mx-auto py-4 px-3 sm:px-6 lg:px-8 h-full flex-grow">
-        <div className="pb-5 mt-6 border-b border-gray-200 sm:flex sm:items-center sm:justify-between">
-          <h1 className="text-3xl leading-6 font-bold text-gray-900">Users</h1>
+  function IndeterminateCheckbox({
+    indeterminate,
+    ...rest
+  }: { indeterminate?: boolean } & HTMLProps<HTMLInputElement>) {
+    const ref = React.useRef<HTMLInputElement>(null!);
 
-          {isAdmin && (
-            <div className="mt-3 sm:mt-0 sm:ml-4">
-              <button
-                onClick={() => configureModalOpen(null)}
-                type="button"
-                className="inline-flex items-center px-4 py-2 border border-transparent rounded-md shadow-sm text-sm font-medium text-white bg-primary-700 hover:bg-primary-800 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-primary-800 mx-5 "
-              >
-                <PlusIcon className="-ml-0.5 mr-2 h-4 w-4" aria-hidden="true" />
-                Add new user
-              </button>
-              <button
-                onClick={() => setMultipleUsersModal(true)}
-                type="button"
-                className="inline-flex items-center px-4 py-2 border border-transparent rounded-md shadow-sm text-sm font-medium text-white bg-primary-700 hover:bg-primary-800 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-primary-800"
-              >
-                <ViewGridAddIcon className="-ml-0.5 mr-2 h-4 w-4" aria-hidden="true" />
-                Add new users
-              </button>
+    React.useEffect(() => {
+      if (typeof indeterminate === 'boolean') {
+        ref.current.indeterminate = !rest.checked && indeterminate;
+      }
+    }, [ref, indeterminate]);
+
+    return (
+      <input
+        type="checkbox"
+        ref={ref}
+        className="focus:ring-primary-800 h-4 w-4 text-primary-700 border-gray-300 rounded cursor-pointer"
+        {...rest}
+      />
+    );
+  }
+  function CreateUserTable() {
+    const [rowSelection, setRowSelection] = React.useState({});
+    const [sorting, setSorting] = React.useState<SortingState>([]);
+    const userColumns = React.useMemo<ColumnDef<User>[]>(
+      () => [
+        {
+          id: 'select',
+          header: ({ table }) => (
+            <IndeterminateCheckbox
+              {...{
+                checked: table.getIsAllRowsSelected(),
+                indeterminate: table.getIsSomeRowsSelected(),
+                onChange: table.getToggleAllRowsSelectedHandler(),
+              }}
+            />
+          ),
+          cell: ({ row }) => (
+            <div className="flex items-center">
+              <IndeterminateCheckbox
+                {...{
+                  checked: row.getIsSelected(),
+                  disabled: !row.getCanSelect(),
+                  indeterminate: row.getIsSomeSelected(),
+                  onChange: row.getToggleSelectedHandler(),
+                }}
+              />
             </div>
-          )}
-        </div>
+          ),
+        },
+        {
+          header: 'Name',
+          footer: (props) => props.column.id,
+          accessorKey: 'name',
+        },
+        {
+          header: 'Email',
+          footer: (props) => props.column.id,
+          accessorKey: 'email',
+        },
+        {
+          header: 'Status',
+          footer: (props) => props.column.id,
+          accessorKey: 'status',
+        },
+        {
+          header: ' ',
+          cell: (props: any) => {
+            const { row } = props;
+
+            if (isAdmin) {
+              return (
+                <div className="text-right relative">
+                  <div className="absolute inline-flex px-2 py-1 text-transparent items-center font-medium border border-transparent right-0 z-0">
+                    Configure <CogIcon className="-mr-0.5 ml-2 h-4 w-4 text-gray-500" />
+                  </div>
+                  <button
+                    onClick={() => configureModalOpen(row.original.id)}
+                    type="button"
+                    className="relative z-10 opacity-0 group-hover:opacity-100 transition-opacity inline-flex items-center px-2 py-1 border border-gray-200 shadow-sm text-sm font-medium rounded-md text-gray-700 bg-white hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-primary-500"
+                  >
+                    Configure <CogIcon className="-mr-0.5 ml-2 h-4 w-4" />
+                  </button>
+                </div>
+              );
+            }
+
+            return null;
+          },
+        },
+      ],
+      [isAdmin],
+    );
 
+    const table = useReactTable({
+      data: filterSearch,
+      columns: userColumns,
+      state: {
+        rowSelection,
+        sorting,
+      },
+      initialState: {
+        pagination: {
+          pageSize: 10,
+        },
+      },
+      onSortingChange: setSorting,
+      getSortedRowModel: getSortedRowModel(),
+      enableRowSelection: true, // enable row selection for all rows
+      onRowSelectionChange: setRowSelection,
+      getCoreRowModel: getCoreRowModel(),
+      getFilteredRowModel: getFilteredRowModel(),
+      getPaginationRowModel: getPaginationRowModel(),
+      debugTable: false, // make true if needed
+    });
+
+    return (
+      <>
         <div className="flex justify-between w-100 my-3 items-center mb-5 ">
           <div className="flex items-center">
             <div className="inline-block">
@@ -151,7 +250,7 @@ export const Users: React.FC = () => {
                     name="email"
                     id="email"
                     className="focus:ring-primary-500 focus:border-primary-500 block w-full rounded-md pl-10 sm:text-sm border-gray-200"
-                    placeholder="Search Users"
+                    placeholder="Search everything..."
                     onChange={debouncedSearch}
                   />
                 </div>
@@ -159,21 +258,194 @@ export const Users: React.FC = () => {
             </div>
           </div>
 
-          {selectedRowsIds && Object.keys(selectedRowsIds).length !== 0 && (
+          {Object.keys(rowSelection).length !== 0 && (
             <div className="flex items-center">
               <button
-                onClick={() => {}}
+                onClick={() => multiEditUserModalOpen(table.getSelectedRowModel().flatRows)}
                 type="button"
-                className="inline-flex items-center px-4 py-2 text-sm font-medium rounded-md text-red-700 bg-red-50 hover:bg-red-100 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-red-500"
+                className="inline-flex items-center px-4 py-2 text-sm font-medium rounded-md text-gray-500 bg-primary-50 hover:bg-primary-100 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-primary-500 border border-gray-200 shadow-sm"
               >
-                <TrashIcon className="-ml-0.5 mr-2 h-4 w-4" aria-hidden="true" />
-                Delete
+                <CogIcon className="-ml-0.5 mr-2 h-4 w-4" aria-hidden="true" />
+                Configure {Object.keys(rowSelection).length} user{Object.keys(rowSelection).length !== 1 ? 's' : null}
               </button>
             </div>
           )}
         </div>
+        <div className="flex justify-between items-center text-xs font-medium text-gray-500 uppercase tracking-wider">
+          <div className="py-3 text-left ">
+            Showing {table.getRowModel().rows.length} of {table.getCoreRowModel().rows.length} entries
+          </div>
+          <nav className="isolate inline-flex -space-x-px rounded-md shadow-sm" aria-label="Pagination">
+            <button
+              className="relative inline-flex items-center rounded-l-md px-2 py-2 ring-1 ring-inset ring-gray-300 transition hover:bg-gray-100 focus:z-20 focus:outline-offset-0"
+              onClick={() => table.setPageIndex(0)}
+              disabled={!table.getCanPreviousPage()}
+            >
+              <ChevronDoubleLeftIcon className="w-4 h-4" />
+            </button>
+            <button
+              className="relative inline-flex items-center px-2 py-2 ring-1 ring-inset ring-gray-300 transition hover:bg-gray-100 focus:z-20 focus:outline-offset-0"
+              onClick={() => table.previousPage()}
+              disabled={!table.getCanPreviousPage()}
+            >
+              <ChevronLeftIcon className="w-4 h-4" />
+            </button>
+            <div className="relative inline-flex items-center px-2 py-2 ring-1 ring-inset ring-gray-300  focus:z-20 focus:outline-offset-0">
+              <span>
+                Page {table.getState().pagination.pageIndex + 1} of {table.getPageCount()}
+              </span>
+            </div>
+            <button
+              className="relative inline-flex items-center px-2 py-2 ring-1 ring-inset ring-gray-300 transition hover:bg-gray-100 focus:z-20 focus:outline-offset-0"
+              onClick={() => table.nextPage()}
+              disabled={!table.getCanNextPage()}
+            >
+              <ChevronRightIcon className="w-4 h-4" />
+            </button>
+            <button
+              className="relative inline-flex items-center rounded-r-md px-2 py-2 ring-1 ring-inset ring-gray-300 transition hover:bg-gray-100 focus:z-20 focus:outline-offset-0"
+              onClick={() => table.setPageIndex(table.getPageCount() - 1)}
+              disabled={!table.getCanNextPage()}
+            >
+              <ChevronDoubleRightIcon className="w-4 h-4" />
+            </button>
+          </nav>
+          <div className="flex items-center gap-2 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
+            <span>Results per page</span>
+            <select
+              value={table.getState().pagination.pageSize}
+              className="focus:ring-primary-500 focus:border-primary-500 py-1 block rounded-md text-sm border-gray-200"
+              onChange={(e) => {
+                table.setPageSize(Number(e.target.value));
+              }}
+            >
+              {[10, 20, 50, 100, 250, 500, 10000, table.getCoreRowModel().rows.length].map((pageSize) =>
+                pageSize <= table.getCoreRowModel().rows.length ? (
+                  <option key={pageSize} value={pageSize}>
+                    {pageSize === table.getCoreRowModel().rows.length ? `all` : `${pageSize}`}
+                  </option>
+                ) : null,
+              )}
+            </select>
+          </div>
+        </div>
+        <div className="shadow border-b border-gray-200 sm:rounded-lg overflow-hidden">
+          <table className="min-w-full divide-y divide-gray-200 table-auto">
+            <thead className="bg-gray-50">
+              {table.getHeaderGroups().map((headerGroup) => (
+                <tr key={headerGroup.id}>
+                  {headerGroup.headers.map((header) => {
+                    return (
+                      <th
+                        key={header.id}
+                        colSpan={header.colSpan}
+                        scope="col"
+                        className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider cursor-pointer"
+                      >
+                        {header.isPlaceholder ? null : (
+                          <div
+                            {...{
+                              className: header.column.getCanSort() ? 'flex items-center' : '',
+                              onClick: header.column.getToggleSortingHandler(),
+                            }}
+                          >
+                            <span> {flexRender(header.column.columnDef.header, header.getContext())}</span>
+                            {{
+                              asc: <ChevronUpIcon className="w-4 h-4 text-gray-400 ml-1" />,
+                              desc: <ChevronDownIcon className="w-4 h-4 text-gray-400 ml-1" />,
+                            }[header.column.getIsSorted() as string] ?? null}
+                          </div>
+                        )}
+                      </th>
+                    );
+                  })}
+                </tr>
+              ))}
+            </thead>
+            <tbody className="">
+              {table.getRowModel().rows.map((row, rowIndex) => {
+                return (
+                  <tr
+                    key={row.id}
+                    role="row"
+                    className={
+                      rowIndex % 2 === 0
+                        ? 'bg-white group border-l-4 border-transparent transition hover:border-primary-600'
+                        : 'bg-gray-50 group border-l-4 border-transparent transition hover:border-primary-600'
+                    }
+                  >
+                    {row.getVisibleCells().map((cell) => {
+                      return (
+                        <td
+                          key={cell.id}
+                          className={
+                            cell.id.substring(2) === 'select'
+                              ? 'w-4 px-6 py-4 whitespace-nowrap text-sm text-gray-500'
+                              : 'px-6 py-4 whitespace-nowrap text-sm text-gray-500'
+                          }
+                        >
+                          {flexRender(cell.column.columnDef.cell, cell.getContext())}
+                        </td>
+                      );
+                    })}
+                  </tr>
+                );
+              })}
+            </tbody>
+          </table>
+          {/* Debugging buttons below, uncomment if needee */}
+          {/* <hr />
+          <br />
+          <div>
+            <button className="border rounded p-2 mb-2" onClick={() => console.info('rowSelection', rowSelection)}>
+              Log `rowSelection` state
+            </button>
+          </div>
+          <div>
+            <button
+              className="border rounded p-2 mb-2"
+              onClick={() => console.info('table.getSelectedRowModel().flatRows', table.getSelectedRowModel().flatRows)}
+            >
+              Log table.getSelectedRowModel().flatRows
+            </button>
+          </div> */}
+        </div>
+      </>
+    );
+  }
 
-        <div className="flex flex-col">
+  // ////////////////////////
+  // New Table End
+  // ////////////////////////
+  return (
+    <div className="relative">
+      <div className="max-w-7xl mx-auto py-4 px-3 sm:px-6 lg:px-8 h-full flex-grow">
+        <div className="pb-5 mt-6 border-b border-gray-200 sm:flex sm:items-center sm:justify-between">
+          <h1 className="text-3xl leading-6 font-bold text-gray-900">Users</h1>
+
+          {isAdmin && (
+            <div className="mt-3 sm:mt-0 sm:ml-4">
+              <button
+                onClick={() => configureModalOpen(null)}
+                type="button"
+                className="inline-flex items-center px-4 py-2 border border-transparent rounded-md shadow-sm text-sm font-medium text-white bg-primary-700 hover:bg-primary-800 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-primary-800 mx-5 "
+              >
+                <PlusIcon className="-ml-0.5 mr-2 h-4 w-4" aria-hidden="true" />
+                Add new user
+              </button>
+              <button
+                onClick={() => setMultipleUsersModal(true)}
+                type="button"
+                className="inline-flex items-center px-4 py-2 border border-transparent rounded-md shadow-sm text-sm font-medium text-white bg-primary-700 hover:bg-primary-800 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-primary-800"
+              >
+                <ViewGridAddIcon className="-ml-0.5 mr-2 h-4 w-4" aria-hidden="true" />
+                Add new users
+              </button>
+            </div>
+          )}
+        </div>
+
+        {/* <div className="flex flex-col">
           <div className="-my-2 overflow-x-auto sm:-mx-6 lg:-mx-8">
             <div className="py-2 align-middle inline-block min-w-full sm:px-6 lg:px-8">
               <div className="shadow border-b border-gray-200 sm:rounded-lg overflow-hidden">
@@ -186,6 +458,12 @@ export const Users: React.FC = () => {
               </div>
             </div>
           </div>
+        </div> */}
+
+        <div className="flex flex-col">
+          <div className="-my-2 overflow-x-auto sm:-mx-6 lg:-mx-8">
+            <div className="py-2 align-middle inline-block min-w-full sm:px-6 lg:px-8">{CreateUserTable()}</div>
+          </div>
         </div>
 
         {configureModal && (
@@ -200,6 +478,15 @@ export const Users: React.FC = () => {
         {multipleUsersModal && (
           <MultipleUsersModal open={multipleUsersModal} onClose={multipleUsersModalClose} apps={apps} />
         )}
+        {multiEditUserModal && (
+          <MultiEditUserModal
+            open={multiEditUserModal}
+            onClose={multiEditUserModalClose}
+            apps={apps}
+            setUserId={setUserId}
+            userIds={multiUserIds}
+          />
+        )}
       </div>
     </div>
   );
diff --git a/frontend/src/services/users/hooks/use-users.ts b/frontend/src/services/users/hooks/use-users.ts
index 447bcce90e083a48866ad1a26b9e0e6b788225e1..cf189fc899b70be2da788eb9621bd953ea3e2265 100644
--- a/frontend/src/services/users/hooks/use-users.ts
+++ b/frontend/src/services/users/hooks/use-users.ts
@@ -5,6 +5,7 @@ import {
   fetchUserById,
   fetchPersonalInfo,
   updateUserById,
+  updateMultipleUsers,
   updatePersonalInfo,
   createUser,
   deleteUser,
@@ -42,6 +43,10 @@ export function useUsers() {
     return dispatch(updateUserById(data));
   }
 
+  function editMultipleUsers(data: any) {
+    return dispatch(updateMultipleUsers(data));
+  }
+
   function editPersonalInfo(data: any) {
     return dispatch(updatePersonalInfo(data));
   }
@@ -69,6 +74,7 @@ export function useUsers() {
     loadUsers,
     loadPersonalInfo,
     editUserById,
+    editMultipleUsers,
     editPersonalInfo,
     userModalLoading,
     userTableLoading,
diff --git a/frontend/src/services/users/redux/actions.ts b/frontend/src/services/users/redux/actions.ts
index 9fc8770d4ec06109a73a736c0a9aa9e4e0ae0159..394a93407288cbd18e7af40f6e1661c419715c89 100644
--- a/frontend/src/services/users/redux/actions.ts
+++ b/frontend/src/services/users/redux/actions.ts
@@ -7,8 +7,10 @@ import { AuthActionTypes } from 'src/services/auth';
 import {
   transformBatchResponse,
   transformRequestMultipleUsers,
+  transformRequestUpdateMultipleUsers,
   transformRequestUser,
   transformUser,
+  transformUpdateMultipleUsers,
   transformRecoveryLink,
 } from '../transformations';
 
@@ -22,6 +24,7 @@ export enum UserActionTypes {
   SET_USER_MODAL_LOADING = 'users/user_modal_loading',
   SET_USERS_LOADING = 'users/users_loading',
   CREATE_BATCH_USERS = 'users/create_batch_users',
+  UPDATE_MULTIPLE_USERS = '/users/multi-edit',
 }
 
 export const setUsersLoading = (isLoading: boolean) => (dispatch: Dispatch<any>) => {
@@ -132,6 +135,35 @@ export const updateUserById = (user: any) => async (dispatch: Dispatch<any>, get
   dispatch(setUserModalLoading(false));
 };
 
+// ////////////////////
+
+export const updateMultipleUsers = (users: any) => async (dispatch: Dispatch<any>) => {
+  dispatch(setUserModalLoading(true));
+
+  try {
+    const { data } = await performApiCall({
+      path: '/users/multi-edit',
+      method: 'PUT',
+      body: transformRequestUpdateMultipleUsers(users),
+    });
+
+    dispatch({
+      type: UserActionTypes.UPDATE_MULTIPLE_USERS,
+      payload: transformUpdateMultipleUsers(data),
+    });
+
+    showToast('Users updated successfully.', ToastType.Success);
+
+    dispatch(fetchUsers());
+  } catch (err) {
+    console.error(err);
+  }
+
+  dispatch(setUserModalLoading(false));
+};
+
+// /////////////////////
+
 export const updatePersonalInfo = (user: any) => async (dispatch: Dispatch<any>) => {
   dispatch(setUserModalLoading(true));
 
diff --git a/frontend/src/services/users/transformations.ts b/frontend/src/services/users/transformations.ts
index 545c947580639e57a95b3c89ad3b2a0d3a88632a..3d4a0ecb85084b1f5ede794f1c8a9c569446cb52 100644
--- a/frontend/src/services/users/transformations.ts
+++ b/frontend/src/services/users/transformations.ts
@@ -64,6 +64,22 @@ export const transformRequestUser = (data: Pick<User, 'app_roles' | 'name' | 'em
   };
 };
 
+export const transformUpdateMultipleUsers = (response: any): any => {
+  return {
+    success: response.success,
+    existing: response.existing,
+    failed: response.failed,
+  };
+};
+
+export const transformRequestUpdateMultipleUsers = (data: any) => {
+  return {
+    users: _.map(data, (user: Pick<User, 'email' | 'id' | 'app_roles'>) => {
+      return { email: user.email ?? '', id: user.id ?? '', app_roles: user.app_roles.map(transformRequestAppRoles) };
+    }),
+  };
+};
+
 const extractUsersFromCsv = (csvData: string) => {
   const csvRows = csvData.split('\n');
 
diff --git a/frontend/src/services/users/types.ts b/frontend/src/services/users/types.ts
index d22811cba75d1cd8aa84c983de14f632df089dda..bdc6cf7303ab798375668315d26eafcea9a8ed66 100644
--- a/frontend/src/services/users/types.ts
+++ b/frontend/src/services/users/types.ts
@@ -18,11 +18,20 @@ export enum UserRole {
   User = 'user',
 }
 
+export enum NoChange {
+  NoChange = 'no_change',
+}
+
 export interface AppRoles {
   name: string | null;
   role: UserRole | null;
 }
 
+export interface MultiEditAppRoles {
+  name: string | null;
+  role: UserRole | NoChange | null;
+}
+
 export interface UserApiRequest {
   id: number | null;
   email: string;
@@ -34,3 +43,11 @@ export interface MultipleUsersData {
   csvUserData: string;
   appRoles: AppRoles[];
 }
+
+export interface MultiEditUser {
+  userIds: string[];
+  userEmails: string[];
+  userNames: string[];
+  app_roles: MultiEditAppRoles[];
+  status: string;
+}
diff --git a/frontend/yarn.lock b/frontend/yarn.lock
index 92616b45cbf0ea6149aced0eea9bab148cb745a3..2614f4c42f57c14c73a10a0e5fb38acd81c94226 100644
--- a/frontend/yarn.lock
+++ b/frontend/yarn.lock
@@ -1716,6 +1716,25 @@
     lodash.merge "^4.6.2"
     lodash.uniq "^4.5.0"
 
+"@tanstack/match-sorter-utils@^8.8.4":
+  version "8.8.4"
+  resolved "https://registry.yarnpkg.com/@tanstack/match-sorter-utils/-/match-sorter-utils-8.8.4.tgz#0b2864d8b7bac06a9f84cb903d405852cc40a457"
+  integrity sha512-rKH8LjZiszWEvmi01NR72QWZ8m4xmXre0OOwlRGnjU01Eqz/QnN+cqpty2PJ0efHblq09+KilvyR7lsbzmXVEw==
+  dependencies:
+    remove-accents "0.4.2"
+
+"@tanstack/react-table@^8.9.3":
+  version "8.9.3"
+  resolved "https://registry.yarnpkg.com/@tanstack/react-table/-/react-table-8.9.3.tgz#03a52e9e15f65c82a8c697a445c42bfca0c5cfc4"
+  integrity sha512-Ng9rdm3JPoSCi6cVZvANsYnF+UoGVRxflMb270tVj0+LjeT/ZtZ9ckxF6oLPLcKesza6VKBqtdF9mQ+vaz24Aw==
+  dependencies:
+    "@tanstack/table-core" "8.9.3"
+
+"@tanstack/table-core@8.9.3":
+  version "8.9.3"
+  resolved "https://registry.yarnpkg.com/@tanstack/table-core/-/table-core-8.9.3.tgz#991da6b015f6200fdc841c48048bee5e197f6a46"
+  integrity sha512-NpHZBoHTfqyJk0m/s/+CSuAiwtebhYK90mDuf5eylTvgViNOujiaOaxNDxJkQQAsVvHWZftUGAx1EfO1rkKtLg==
+
 "@testing-library/dom@^7.28.1":
   version "7.31.2"
   resolved "https://registry.yarnpkg.com/@testing-library/dom/-/dom-7.31.2.tgz#df361db38f5212b88555068ab8119f5d841a8c4a"
@@ -10791,6 +10810,11 @@ remark-rehype@^9.0.0:
     mdast-util-to-hast "^11.0.0"
     unified "^10.0.0"
 
+remove-accents@0.4.2:
+  version "0.4.2"
+  resolved "https://registry.yarnpkg.com/remove-accents/-/remove-accents-0.4.2.tgz#0a43d3aaae1e80db919e07ae254b285d9e1c7bb5"
+  integrity sha512-7pXIJqJOq5tFgG1A2Zxti3Ht8jJF337m4sowbuHsW30ZnkQFnDzy9qBNhgzX8ZLW4+UBcXiiR7SwR6pokHsxiA==
+
 remove-trailing-separator@^1.0.1:
   version "1.1.0"
   resolved "https://registry.yarnpkg.com/remove-trailing-separator/-/remove-trailing-separator-1.1.0.tgz#c24bce2a283adad5bc3f58e0d48249b92379d8ef"