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"