This is an automated email from the ASF dual-hosted git repository.
jialiang pushed a commit to branch frontend-refactor
in repository https://gitbox.apache.org/repos/asf/ambari.git
The following commit(s) were added to refs/heads/frontend-refactor by this push:
new 52fb8cec61 AMBARI-26168: Ambari Admin React Implementation: Edit Users
Page (#3881)
52fb8cec61 is described below
commit 52fb8cec612901baa4d0b3a383bdeccae4543931
Author: Himanshu Maurya <[email protected]>
AuthorDate: Tue Nov 19 06:32:43 2024 +0530
AMBARI-26168: Ambari Admin React Implementation: Edit Users Page (#3881)
---
.../ambari-admin/src/__mocks__/mockGroupNames.ts | 64 ++
.../ui/ambari-admin/src/__mocks__/mockUserData.ts | 110 ++++
.../src/screens/Users/ChangePasswordModal.tsx | 197 ++++++
.../ui/ambari-admin/src/screens/Users/EditUser.tsx | 721 +++++++++++++++++++++
.../ui/ambari-admin/src/tests/EditUser.test.tsx | 342 ++++++++++
5 files changed, 1434 insertions(+)
diff --git
a/ambari-admin/src/main/resources/ui/ambari-admin/src/__mocks__/mockGroupNames.ts
b/ambari-admin/src/main/resources/ui/ambari-admin/src/__mocks__/mockGroupNames.ts
new file mode 100644
index 0000000000..27edb2b49a
--- /dev/null
+++
b/ambari-admin/src/main/resources/ui/ambari-admin/src/__mocks__/mockGroupNames.ts
@@ -0,0 +1,64 @@
+/**
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+export const groupNames = {
+ "href":
"http://host.example.com:8080/api/v1/groups?Groups/group_name.matches(.*.*)",
+ "items": [
+ {
+ "href": "http://host.example.com:8080/api/v1/groups/ferfew",
+ "Groups": {
+ "group_name": "ferfew"
+ }
+ },
+ {
+ "href": "http://host.example.com:8080/api/v1/groups/gdgdeg",
+ "Groups": {
+ "group_name": "gdgdeg"
+ }
+ },
+ {
+ "href": "http://host.example.com:8080/api/v1/groups/group1",
+ "Groups": {
+ "group_name": "group1"
+ }
+ },
+ {
+ "href": "http://host.example.com:8080/api/v1/groups/group2",
+ "Groups": {
+ "group_name": "group2"
+ }
+ },
+ {
+ "href": "http://host.example.com:8080/api/v1/groups/group3",
+ "Groups": {
+ "group_name": "group3"
+ }
+ },
+ {
+ "href": "http://host.example.com:8080/api/v1/groups/group4",
+ "Groups": {
+ "group_name": "group4"
+ }
+ },
+ {
+ "href": "http://host.example.com:8080/api/v1/groups/group5",
+ "Groups": {
+ "group_name": "group5"
+ }
+ },
+ ]
+}
\ No newline at end of file
diff --git
a/ambari-admin/src/main/resources/ui/ambari-admin/src/__mocks__/mockUserData.ts
b/ambari-admin/src/main/resources/ui/ambari-admin/src/__mocks__/mockUserData.ts
new file mode 100644
index 0000000000..a0e63c639d
--- /dev/null
+++
b/ambari-admin/src/main/resources/ui/ambari-admin/src/__mocks__/mockUserData.ts
@@ -0,0 +1,110 @@
+/**
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+export const userData = {
+ "href":
"http://host.example.com:8080/api/v1/users/dsasd?fields=privileges/PrivilegeInfo,Users",
+ "Users": {
+ "active": true,
+ "admin": false,
+ "consecutive_failures": 0,
+ "created": 1724325120429,
+ "display_name": "dsasd",
+ "groups": [
+ "gdgdeg",
+ "group1"
+ ],
+ "ldap_user": false,
+ "local_user_name": "dsasd",
+ "user_name": "dsasd",
+ "user_type": "LOCAL"
+ },
+ "privileges": [
+ {
+ "href":
"http://host.example.com:8080/api/v1/users/dsasd/privileges/1005",
+ "PrivilegeInfo": {
+ "instance_name": "jukende",
+ "permission_label": "View User",
+ "permission_name": "VIEW.USER",
+ "principal_name": "dsasd",
+ "principal_type": "USER",
+ "privilege_id": 1005,
+ "type": "VIEW",
+ "user_name": "dsasd",
+ "version": "1.0.0",
+ "view_name": "FILES"
+ }
+ },
+ {
+ "href":
"http://host.example.com:8080/api/v1/users/dsasd/privileges/1017",
+ "PrivilegeInfo": {
+ "instance_name": "Files",
+ "permission_label": "View User",
+ "permission_name": "VIEW.USER",
+ "principal_name": "group1",
+ "principal_type": "GROUP",
+ "privilege_id": 1017,
+ "type": "VIEW",
+ "user_name": "dsasd",
+ "version": "1.0.0",
+ "view_name": "CAPACITY-SCHEDULER"
+ }
+ },
+ {
+ "href":
"http://host.example.com:8080/api/v1/users/dsasd/privileges/1019",
+ "PrivilegeInfo": {
+ "cluster_name": "testCluster",
+ "permission_label": "Cluster User",
+ "permission_name": "CLUSTER.USER",
+ "principal_name": "dsasd",
+ "principal_type": "USER",
+ "privilege_id": 1019,
+ "type": "CLUSTER",
+ "user_name": "dsasd"
+ }
+ },
+ {
+ "href":
"http://host.example.com:8080/api/v1/users/dsasd/privileges/1020",
+ "PrivilegeInfo": {
+ "instance_name":
"2345678123456781234567812345678123456781212345678123456",
+ "permission_label": "View User",
+ "permission_name": "VIEW.USER",
+ "principal_name": "dsasd",
+ "principal_type": "USER",
+ "privilege_id": 1020,
+ "type": "VIEW",
+ "user_name": "dsasd",
+ "version": "1.0.0",
+ "view_name": "CAPACITY-SCHEDULER"
+ }
+ },
+ {
+ "href":
"http://host.example.com:8080/api/v1/users/dsasd/privileges/1021",
+ "PrivilegeInfo": {
+ "instance_name": "cdsadcwe@",
+ "permission_label": "View User",
+ "permission_name": "VIEW.USER",
+ "principal_name": "dsasd",
+ "principal_type": "USER",
+ "privilege_id": 1021,
+ "type": "VIEW",
+ "user_name": "dsasd",
+ "version": "1.0.0",
+ "view_name": "FILES"
+ }
+ }
+ ]
+}
\ No newline at end of file
diff --git
a/ambari-admin/src/main/resources/ui/ambari-admin/src/screens/Users/ChangePasswordModal.tsx
b/ambari-admin/src/main/resources/ui/ambari-admin/src/screens/Users/ChangePasswordModal.tsx
new file mode 100644
index 0000000000..e2403d0d0e
--- /dev/null
+++
b/ambari-admin/src/main/resources/ui/ambari-admin/src/screens/Users/ChangePasswordModal.tsx
@@ -0,0 +1,197 @@
+/**
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+import { Alert, Button, Form, Modal } from "react-bootstrap";
+import DefaultButton from "../../components/DefaultButton";
+import { useEffect, useState } from "react";
+import { get, set } from "lodash";
+
+type ChangePasswordModalProps = {
+ isOpen: boolean;
+ onClose: () => void;
+ userName: string;
+ updatePassword: (yourPassword: string, newUserPassword: string) => void;
+};
+
+export default function ChangePasswordModal({
+ isOpen,
+ onClose,
+ userName,
+ updatePassword,
+}: ChangePasswordModalProps) {
+ const [yourPassword, setYourPassword] = useState("");
+ const [newUserPassword, setNewUserPassword] = useState("");
+ const [newUserPasswordConfirmation, setNewUserPasswordConfirmation] =
+ useState("");
+ const [validationError, setValidationError] = useState({
+ yourPassword: "",
+ newUserPassword: "",
+ });
+
+ useEffect(() => {
+ if (newUserPassword !== newUserPasswordConfirmation) {
+ setValidationError({
+ ...validationError,
+ newUserPassword: "Password must match!",
+ });
+ } else {
+ setValidationError({
+ ...validationError,
+ newUserPassword: "",
+ });
+ }
+ }, [newUserPassword, newUserPasswordConfirmation]);
+
+ useEffect(() => {
+ if (!isOpen) {
+ setYourPassword("");
+ setNewUserPassword("");
+ setNewUserPasswordConfirmation("");
+ setValidationError({
+ yourPassword: "",
+ newUserPassword: "",
+ });
+ }
+ }, [isOpen]);
+
+ const handlePasswordChangeModalSave = async (event: any) => {
+ event.preventDefault();
+ let error = {
+ ...validationError
+ };
+ if (!yourPassword) {
+ set(error, "yourPassword", "Password required!");
+ }
+ if (!newUserPassword) {
+ set(error, "newUserPassword", "Password required!");
+ }
+ setValidationError(error);
+ if (
+ yourPassword &&
+ newUserPassword &&
+ newUserPasswordConfirmation &&
+ !get(error, "yourPassword") &&
+ !get(error, "newUserPassword")
+ ) {
+ updatePassword(yourPassword, newUserPassword);
+ }
+ };
+
+ return (
+ <Modal
+ show={isOpen}
+ onHide={onClose}
+ size="lg"
+ className="custom-modal-container"
+ data-testid="change-password-modal"
+ >
+ <Modal.Header>
+ <Modal.Title><h3>Change Password for {userName}</h3></Modal.Title>
+ </Modal.Header>
+ <Form onSubmit={handlePasswordChangeModalSave}>
+ <Modal.Body>
+ <Form.Group className="mb-4 d-flex">
+ <div className="w-25 d-flex justify-content-end ms-5 me-4 mt-2">
+ <Form.Label
+ className={
+ get(validationError, "yourPassword", "")
+ ? "text-danger"
+ : ""
+ }
+ >Your Password</Form.Label>
+ </div>
+ <div className="w-100">
+ <Form.Control
+ type="password"
+ value={yourPassword}
+ placeholder="Your Password"
+ className={
+ get(validationError, "yourPassword", "")
+ ? "custom-form-control border-danger"
+ : "custom-form-control"
+ }
+ onChange={(e) => {
+ setYourPassword(e.target.value);
+ if (e.target.value) {
+ setValidationError({
+ ...validationError,
+ yourPassword: "",
+ });
+ }
+ }}
+ data-testid="your-password-input"
+ />
+ {get(validationError, "yourPassword", "") ? (
+ <Alert className="mt-2 mb-0 p-2 rounded-0 text-danger"
variant="danger">
+ {get(validationError, "yourPassword")}
+ </Alert>
+ ) : null}
+ </div>
+ </Form.Group>
+ <Form.Group className="mb-3 d-flex">
+ <div className="w-25 d-flex justify-content-end ms-5 me-4 mt-2">
+ <Form.Label
+ className={
+ get(validationError, "newUserPassword", "")
+ ? "text-danger"
+ : ""
+ }
+ >New User Password</Form.Label>
+ </div>
+ <div className="w-100">
+ <Form.Control
+ type="password"
+ value={newUserPassword}
+ placeholder="New User Password"
+ className={
+ get(validationError, "newUserPassword", "")
+ ? "custom-form-control mb-2 border-danger"
+ : "custom-form-control mb-2"
+ }
+ onChange={(e) => setNewUserPassword(e.target.value)}
+ data-testid="new-password-input"
+ />
+ <Form.Control
+ type="password"
+ value={newUserPasswordConfirmation}
+ placeholder="New User Password Confirmation"
+ className={
+ get(validationError, "newUserPassword", "")
+ ? "custom-form-control border-danger"
+ : "custom-form-control"
+ }
+ onChange={(e) =>
setNewUserPasswordConfirmation(e.target.value)}
+ data-testid="new-confirm-password-input"
+ />
+ {get(validationError, "newUserPassword", "") ? (
+ <Alert className="mt-2 mb-0 p-2 rounded-0 text-danger"
variant="danger">
+ {get(validationError, "newUserPassword")}
+ </Alert>
+ ) : null}
+ </div>
+ </Form.Group>
+ </Modal.Body>
+ <Modal.Footer className="d-flex justify-content-end">
+ <DefaultButton onClick={onClose}>CANCEL</DefaultButton>
+ <Button type="submit" className="custom-btn" variant="success"
data-testid="save-password-btn">
+ OK
+ </Button>
+ </Modal.Footer>
+ </Form>
+ </Modal>
+ );
+}
diff --git
a/ambari-admin/src/main/resources/ui/ambari-admin/src/screens/Users/EditUser.tsx
b/ambari-admin/src/main/resources/ui/ambari-admin/src/screens/Users/EditUser.tsx
new file mode 100644
index 0000000000..b165623e0f
--- /dev/null
+++
b/ambari-admin/src/main/resources/ui/ambari-admin/src/screens/Users/EditUser.tsx
@@ -0,0 +1,721 @@
+/**
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+import { useContext, useEffect, useRef, useState } from "react";
+import { Alert, Button, Form, OverlayTrigger, Tooltip } from "react-bootstrap";
+import { useParams } from "react-router";
+import { Link, useHistory, Prompt } from "react-router-dom";
+import DefaultButton from "../../components/DefaultButton";
+import RoleBasedAccessControl from "./RoleBasedAccessControl";
+import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
+import {
+ faCheck,
+ faCloud,
+ faQuestionCircle,
+ faTh,
+ faTrashCan,
+} from "@fortawesome/free-solid-svg-icons";
+import Select from "react-select";
+import { get, startCase } from "lodash";
+import { permissionLabelToName, userAccessOptions } from "./constants";
+import ChangePasswordModal from "./ChangePasswordModal";
+import {
+ UserInfoType,
+ GroupNamesType,
+ PermissionLabel,
+ SelectOptionType,
+} from "./types";
+import { PrivilegeType, PrincipalType, PermissionNameType, DefaultAccess }
from "./enums";
+import ConfirmationModal from "../../components/ConfirmationModal";
+import UserGroupApi from "../../api/userGroupApi";
+import Spinner from "../../components/Spinner";
+import { PrivilegesDataType } from "../../api/types";
+import PrivilegeApi from "../../api/privilegeApi";
+import toast from "react-hot-toast";
+import AppContent from "../../context/AppContext";
+import Table from "../../components/Table";
+import WarningModal from "./WarningModal";
+import {
+ decryptData,
+ getFromLocalStorage,
+ parseJSONData,
+} from "../../api/Utility";
+
+type ParamsType = {
+ userName: string;
+};
+
+export const constructLinkToEditInstance = (
+ viewName: string,
+ version: string,
+ instanceName: string
+) => {
+ return
`/views/${viewName}/versions/${version}/instances/${instanceName}/edit`;
+};
+
+export default function EditUser() {
+ const params: ParamsType = useParams();
+
+ const [currentLoggedInUser, setCurrentLoggedInUser] = useState("");
+ const [showUserAccessModal, setShowUserAccessModal] = useState(false);
+ const [showChangeStatusModal, setShowChangeStatusModal] = useState(false);
+ const [showChangeAdminPriviligesModal, setShowChangeAdminPriviligesModal] =
+ useState(false);
+ const [showChangePasswordModal, setShowChangePasswordModal] =
useState(false);
+
+ const [loading, setLoading] = useState(false);
+ const [userInfo, setUserInfo] = useState<UserInfoType | null>(null);
+ const [groupNames, setGroupNames] = useState<GroupNamesType | null>(null);
+ const [userGroups, setUserGroups] = useState<SelectOptionType[]>([]);
+ const {
+ cluster: { cluster_name: clusterName },
+ setSelectedOption,
+ } = useContext(AppContent);
+ const [showUnsavedChangesWarning, setShowUnsavedChangesWarning] =
+ useState(false);
+ const [hasUnsavedChanges, setHasUnsavedChanges] = useState(true);
+ const [isNavigating, setIsNavigating] = useState(false);
+ const [nextLocation, setNextLocation] = useState();
+ const history = useHistory();
+
+ const previousMembers = useRef<string[]>([]);
+ const newMembers = useRef<string[]>([]);
+
+ useEffect(() => {
+ setSelectedOption("Users");
+ async function getGroupNames() {
+ setLoading(true);
+ const data: any = await UserGroupApi.groupNames();
+ setGroupNames(data);
+ setLoading(false);
+ }
+ getGroupNames();
+ getUserData();
+ let ambariKey = getFromLocalStorage("ambari");
+ if (ambariKey) {
+ let parsedAmbariKey = parseJSONData(decryptData(ambariKey));
+ setCurrentLoggedInUser(get(parsedAmbariKey, "app.loginName", ""));
+ }
+ }, []);
+
+ useEffect(() => {
+ previousMembers.current = get(userInfo, "Users.groups", [] as string[]);
+ }, [userInfo]);
+
+ useEffect(() => {
+ newMembers.current = userGroups.map(
+ (group: SelectOptionType) => group.value
+ );
+ setHasUnsavedChanges(
+ JSON.stringify(previousMembers.current) !==
+ JSON.stringify(newMembers.current)
+ );
+ }, [userGroups]);
+
+ const groupOptions = get(groupNames, "items", []).map((group: any) => ({
+ value: get(group, "Groups.group_name"),
+ label: get(group, "Groups.group_name"),
+ }));
+
+ const columnsInUserPrivilegesCluster = [
+ {
+ header: "Cluster",
+ width: "80%",
+ cell: (info: any) => {
+ return (
+ <div>
+ <FontAwesomeIcon
+ icon={faCloud}
+ height={13}
+ width={13}
+ className="me-1"
+ />
+ <Link to={"/clusterInformation"} className="custom-link">
+ {get(info, "row.original.PrivilegeInfo.cluster_name")}
+ </Link>
+ </div>
+ );
+ },
+ },
+ {
+ header: "Cluster Role",
+ width: "20%",
+ cell: (info: any) => {
+ return (
+ <div>{get(info, "row.original.PrivilegeInfo.permission_label")}</div>
+ );
+ },
+ },
+ ];
+
+ const columnsInUserPrivilegesView = [
+ {
+ header: "View",
+ width: "80%",
+ cell: (info: any) => {
+ return (
+ <div className="w-100">
+ <FontAwesomeIcon
+ icon={faTh}
+ height={13}
+ width={13}
+ className="me-1"
+ />
+ <Link
+ to={constructLinkToEditInstance(
+ get(info, "row.original.PrivilegeInfo.view_name"),
+ get(info, "row.original.PrivilegeInfo.version"),
+ get(info, "row.original.PrivilegeInfo.instance_name")
+ )}
+ className="custom-link"
+ >
+ {get(info, "row.original.PrivilegeInfo.instance_name")}
+ </Link>
+ </div>
+ );
+ },
+ },
+ {
+ header: "View Permissions",
+ cell: (info: any) => {
+ return (
+ <div className="d-flex justify-content-between">
+ <div className="d-flex align-items-center">
+ {startCase(
+ get(
+ info,
+ "row.original.PrivilegeInfo.permission_label",
+ ""
+ ).toLowerCase()
+ )}
+ </div>
+ <Button
+ className="btn-wrapping-icon make-all-grey"
+ onClick={() =>
+ deleteViewPrivilege(
+ get(info, "row.original.PrivilegeInfo.view_name"),
+ get(info, "row.original.PrivilegeInfo.version"),
+ get(info, "row.original.PrivilegeInfo.instance_name"),
+ get(info, "row.original.PrivilegeInfo.privilege_id")
+ )
+ }
+ >
+ <FontAwesomeIcon
+ icon={faTrashCan}
+ data-testid={`remove-privilege-icon-${get(
+ info,
+ "row.original.PrivilegeInfo.instance_name"
+ )}`}
+ />
+ </Button>
+ </div>
+ );
+ },
+ },
+ ];
+
+ async function getUserData() {
+ setLoading(true);
+ const data: any = await UserGroupApi.userData(
+ params.userName,
+ "privileges/PrivilegeInfo,Users"
+ );
+ setUserInfo(data);
+ setUserGroups(
+ get(data, "Users.groups", []).map((group: any) => ({
+ value: group,
+ label: group,
+ })) as SelectOptionType[]
+ );
+ setLoading(false);
+ }
+
+ const updateUserData = async (key: string, value?: boolean) => {
+ if (key === "groups") {
+ const membersToAdd: string[] = newMembers.current.filter(
+ (member) => !previousMembers.current.includes(member)
+ );
+ const membersToRemove: string[] = previousMembers.current.filter(
+ (member) => !newMembers.current.includes(member)
+ );
+ const addMembersPromises = membersToAdd.map((member) =>
+ UserGroupApi.addMember(member, get(userInfo, "Users.user_name", ""))
+ );
+ const removeMembersPromises = membersToRemove.map((member) =>
+ UserGroupApi.removeMember(member, get(userInfo, "Users.user_name", ""))
+ );
+ await Promise.all([...addMembersPromises, ...removeMembersPromises]);
+ toast.success(
+ <div className="toast-message">
+ Local Group Membership updated for{" "}
+ {get(userInfo, "Users.user_name", "")}
+ </div>
+ );
+ } else {
+ const userData = {
+ ["Users/" + key]: value,
+ };
+ await UserGroupApi.updateUser(
+ get(userInfo, "Users.user_name", ""),
+ userData
+ );
+ toast.success(
+ <div className="toast-message">
+ The {key} status for {get(userInfo, "Users.user_name", "")} is
+ updated.
+ </div>
+ );
+ }
+ getUserData();
+ };
+
+ const updatePassword = async (
+ yourPassword: string,
+ newUserPassword: string
+ ) => {
+ const userPasswordData = {
+ "Users/old_password": yourPassword,
+ "Users/password": newUserPassword,
+ };
+ await UserGroupApi.updateUser(
+ get(userInfo, "Users.user_name", ""),
+ userPasswordData
+ );
+ toast.success(
+ <div className="toast-message">
+ Password changed for {get(userInfo, "Users.user_name", "")}
+ </div>
+ );
+ setShowChangePasswordModal(false);
+ };
+
+ const updateUserDataPrivileges = async (value: PermissionLabel) => {
+ if (value === DefaultAccess.NONE) {
+ const currentClusterPrivileges = get(userInfo, "privileges", []).filter(
+ (privilige) =>
+ get(privilige, "PrivilegeInfo.type") === PrivilegeType.CLUSTER &&
+ get(privilige, "PrivilegeInfo.principal_type") === PrincipalType.USER
+ );
+ const removePrivilegesPromises = currentClusterPrivileges.map(
+ (privilege: any) =>
+ PrivilegeApi.removeClusterPrivileges(
+ clusterName,
+ get(privilege, "PrivilegeInfo.privilege_id")
+ )
+ );
+ Promise.all(removePrivilegesPromises).then(() => {
+ toast.success(
+ <div className="toast-message">
+ {get(userInfo, "Users.user_name", "")}'s explicit privilege has
been
+ changed to 'NONE'. Any privilege now seen for this user comes
+ through its Group(s).
+ </div>
+ );
+ getUserData();
+ });
+ } else {
+ const privilegesData: PrivilegesDataType[] = [
+ {
+ PrivilegeInfo: {
+ permission_name: permissionLabelToName[value],
+ principal_name: get(userInfo, "Users.user_name", ""),
+ principal_type: PrincipalType.USER,
+ },
+ },
+ ];
+ await PrivilegeApi.addClusterPrivileges(clusterName, privilegesData);
+ toast.success(
+ <div className="toast-message">
+ {get(userInfo, "Users.user_name", "")} changed to {value}
+ </div>
+ );
+ getUserData();
+ }
+ };
+
+ const deleteViewPrivilege = async (
+ view_name: string,
+ version: string,
+ instance_name: string,
+ privilege_id: string
+ ) => {
+ await PrivilegeApi.removeViewPrivileges(
+ view_name,
+ version,
+ instance_name,
+ privilege_id
+ );
+ toast.success(
+ <div className="toast-message">
+ {instance_name} view privilege is removed for{" "}
+ {get(userInfo, "Users.user_name", "")}
+ </div>
+ );
+ getUserData();
+ };
+
+ const handleBlockedNavigation = (nextLocation: any) => {
+ if (hasUnsavedChanges) {
+ setShowUnsavedChangesWarning(true);
+ setIsNavigating(true);
+ setNextLocation(nextLocation);
+ return false;
+ }
+ return true;
+ };
+
+ const handleWarningDiscard = () => {
+ if (nextLocation) {
+ setShowUnsavedChangesWarning(false);
+ setIsNavigating(false);
+ setHasUnsavedChanges(false);
+ setTimeout(() => {
+ history.push(get(nextLocation, "pathname"));
+ }, 0);
+ }
+ };
+
+ const handleWarningSave = () => {
+ updateUserData("groups");
+ setShowUnsavedChangesWarning(false);
+ setIsNavigating(false);
+ };
+
+ return (
+ <div>
+ <Prompt when={hasUnsavedChanges} message={handleBlockedNavigation} />
+ {isNavigating && showUnsavedChangesWarning ? (
+ <WarningModal
+ isOpen={showUnsavedChangesWarning}
+ onClose={() => setShowUnsavedChangesWarning(false)}
+ handleWarningDiscard={handleWarningDiscard}
+ handleWarningSave={handleWarningSave}
+ />
+ ) : null}
+ <div className="d-flex flex-wrap">
+ <Link
+ to={"/userManagement?tab=users"}
+ className="custom-link"
+ data-testid="users-list-link"
+ >
+ <h4>Users</h4>
+ </Link>
+ <h4 className="ms-2 make-all-grey">{`/ ${get(
+ userInfo,
+ "Users.user_name",
+ ""
+ )}`}</h4>
+ </div>
+ <hr className="mb-4" />
+ {loading ? (
+ <Spinner />
+ ) : (
+ <Form className="d-flex flex-column">
+ <Form.Group className="d-flex mb-4">
+ <Form.Label className="width-15 mt-2">Type</Form.Label>
+ <Form.Control
+ value={startCase(
+ get(userInfo, "Users.user_type", "").toLowerCase()
+ )}
+ readOnly
+ plaintext
+ className="ps-4"
+ />
+ </Form.Group>
+ <Form.Group className="d-flex mb-4">
+ <Form.Label className="mt-2 width-15">Status</Form.Label>
+ <div className="d-flex">
+ {get(userInfo, "Users.user_name", "") === currentLoggedInUser ? (
+ <OverlayTrigger
+ key="top"
+ placement="top"
+ overlay={<Tooltip>Cannot Change Status</Tooltip>}
+ >
+ <div>
+ <Form.Check
+ type="switch"
+ checked={get(userInfo, "Users.active", false)}
+ onChange={() => setShowChangeStatusModal(true)}
+ className="custom-form-check cursor-not-allowed"
+ disabled={
+ get(userInfo, "Users.user_name", "") ===
+ currentLoggedInUser
+ }
+ />
+ </div>
+ </OverlayTrigger>
+ ) : (
+ <Form.Check
+ type="switch"
+ checked={get(userInfo, "Users.active", false)}
+ onChange={() => setShowChangeStatusModal(true)}
+ className="custom-form-check"
+ data-testid="user-status-switch"
+ />
+ )}
+ {get(userInfo, "Users.active", false) ? (
+ <span className="m-2 ps-2">Active</span>
+ ) : (
+ <span className="m-2 ps-2">Inactive</span>
+ )}
+ </div>
+ <ConfirmationModal
+ isOpen={showChangeStatusModal}
+ onClose={() => setShowChangeStatusModal(false)}
+ modalTitle={"Change Status"}
+ modalBody={`Are you sure you want to change status for user
"${get(
+ userInfo,
+ "Users.user_name",
+ ""
+ )}
+ " to
+ ${get(userInfo, "Users.active", false) ? "Inactive" : "Active"}
+ ?`}
+ successCallback={() => {
+ updateUserData("active", !get(userInfo, "Users.active",
false));
+ setShowChangeStatusModal(false);
+ }}
+ />
+ </Form.Group>
+ <Form.Group className="d-flex mb-4">
+ <Form.Label className="mt-2 width-15">Ambari Admin</Form.Label>
+ <div className="d-flex">
+ {get(userInfo, "Users.user_name", "") === currentLoggedInUser ? (
+ <OverlayTrigger
+ key="top"
+ placement="top"
+ overlay={<Tooltip>Cannot Change Admin</Tooltip>}
+ >
+ <div>
+ <Form.Check
+ type="switch"
+ checked={get(userInfo, "Users.admin", false)}
+ onChange={() => setShowChangeAdminPriviligesModal(true)}
+ className="custom-form-check cursor-not-allowed"
+ disabled={
+ get(userInfo, "Users.user_name", "") ===
+ currentLoggedInUser
+ }
+ />
+ </div>
+ </OverlayTrigger>
+ ) : (
+ <Form.Check
+ type="switch"
+ checked={get(userInfo, "Users.admin", false)}
+ onChange={() => setShowChangeAdminPriviligesModal(true)}
+ className="custom-form-check"
+ data-testid="ambari-admin-switch"
+ />
+ )}
+ {get(userInfo, "Users.admin", false) ? (
+ <span className="m-2 ps-2">Yes</span>
+ ) : (
+ <span className="m-2 ps-2">No</span>
+ )}
+ </div>
+ <ConfirmationModal
+ isOpen={showChangeAdminPriviligesModal}
+ onClose={() => setShowChangeAdminPriviligesModal(false)}
+ modalTitle={"Change Admin Privilege"}
+ modalBody={`Are you sure you want to
+ ${get(userInfo, "Users.admin", false) ? "revoke" : "grant"}
+ Admin privilege to user "${get(userInfo, "Users.user_name",
"")}"?`}
+ successCallback={() => {
+ updateUserData("admin", !get(userInfo, "Users.admin", false));
+ setShowChangeAdminPriviligesModal(false);
+ }}
+ />
+ </Form.Group>
+ <Form.Group className="d-flex mb-4">
+ <Form.Label className="mt-2 width-15">Password</Form.Label>
+ <DefaultButton
+ onClick={() => {
+ if (
+ get(userInfo, "Users.user_type", "").toLowerCase() ===
"local"
+ ) {
+ setShowChangePasswordModal(true);
+ }
+ }}
+ className={
+ get(userInfo, "Users.user_type", "").toLowerCase() !== "local"
+ ? "cursor-not-allowed opacity-50"
+ : ""
+ }
+ data-testid="change-password-btn"
+ >
+ Change Password
+ </DefaultButton>
+ <ChangePasswordModal
+ isOpen={showChangePasswordModal}
+ onClose={() => setShowChangePasswordModal(false)}
+ userName={get(userInfo, "Users.user_name", "")}
+ updatePassword={(yourPassword, newUserPassword) => {
+ updatePassword(yourPassword, newUserPassword);
+ }}
+ />
+ </Form.Group>
+ <Form.Group className="d-flex mb-4">
+ <Form.Label className="mt-2 width-15">
+ Local Group Membership
+ </Form.Label>
+ <Select
+ isMulti
+ name="groups"
+ options={groupOptions}
+ className="basic-multi-select w-75"
+ placeholder="Add Group"
+ value={userGroups}
+ onChange={(e) => setUserGroups(e as SelectOptionType[])}
+ isDisabled={
+ get(userInfo, "Users.user_type", "").toLowerCase() !== "local"
+ }
+ aria-label="select-group"
+ ></Select>
+ <DefaultButton
+ onClick={() => {
+ if (
+ get(userInfo, "Users.user_type", "").toLowerCase() ===
+ "local" &&
+ JSON.stringify(previousMembers) !==
JSON.stringify(newMembers)
+ ) {
+ updateUserData("groups");
+ }
+ }}
+ className={
+ get(userInfo, "Users.user_type", "").toLowerCase() !==
+ "local" ||
+ JSON.stringify(previousMembers) === JSON.stringify(newMembers)
+ ? "cursor-not-allowed opacity-50 ms-2"
+ : "ms-2"
+ }
+ data-testid="save-groups-btn"
+ >
+ <FontAwesomeIcon icon={faCheck} />
+ </DefaultButton>
+ </Form.Group>
+ <Form.Group className="d-flex mb-4">
+ <Form.Label className="mt-2 width-15">
+ User Access{" "}
+ <FontAwesomeIcon
+ icon={faQuestionCircle}
+ onClick={() => setShowUserAccessModal(true)}
+ data-testid="user-access-info-icon"
+ />
+ <RoleBasedAccessControl
+ isOpen={showUserAccessModal}
+ onClose={() => setShowUserAccessModal(false)}
+ />
+ </Form.Label>
+ {get(userInfo, "Users.admin", false) ? (
+ <Form.Control
+ value={"Ambari Administrator"}
+ readOnly
+ plaintext
+ className="ps-4"
+ />
+ ) : (
+ <Form.Select
+ aria-label="Select"
+ className="w-25 custom-form-control"
+ onChange={(e) =>
+ updateUserDataPrivileges(e.target.value as PermissionLabel)
+ }
+ value={get(
+ get(userInfo, "privileges", []).filter(
+ (privilige) =>
+ get(privilige, "PrivilegeInfo.type") ===
+ PrivilegeType.CLUSTER
+ ),
+ "[0].PrivilegeInfo.permission_label"
+ )}
+ data-testid="user-access-dropdown"
+ >
+ {userAccessOptions.map((option, idx) => (
+ <option value={option} key={idx}>
+ {option}
+ </option>
+ ))}
+ </Form.Select>
+ )}
+ </Form.Group>
+ <Form.Group className="d-flex mb-4">
+ <Form.Label className="mt-2 width-15">Privileges</Form.Label>
+ {get(userInfo, "Users.admin", false) ? (
+ <Alert className="w-75" variant="info">
+ This user is an Ambari Admin and has all privileges.
+ </Alert>
+ ) : !get(userInfo, "privileges", []).filter(
+ (privilige) =>
+ get(privilige, "PrivilegeInfo.type") ===
+ PrivilegeType.CLUSTER &&
+ get(privilige, "PrivilegeInfo.principal_type") ===
+ PrincipalType.USER
+ )?.length &&
+ !get(userInfo, "privileges", []).filter(
+ (privilige) =>
+ get(privilige, "PrivilegeInfo.permission_name") ===
+ PermissionNameType.VIEW_USER
+ )?.length ? (
+ <Alert className="w-75" variant="info">
+ This user does not have any privileges.
+ </Alert>
+ ) : (
+ <div className="w-75 scrollable">
+ <Table
+ data={get(userInfo, "privileges", []).filter(
+ (privilige) =>
+ get(privilige, "PrivilegeInfo.type") ===
+ PrivilegeType.CLUSTER &&
+ get(privilige, "PrivilegeInfo.principal_type") ===
+ PrincipalType.USER
+ )}
+ columns={columnsInUserPrivilegesCluster}
+ />
+ {get(userInfo, "privileges", []).filter(
+ (privilige) =>
+ get(privilige, "PrivilegeInfo.type") ===
+ PrivilegeType.CLUSTER &&
+ get(privilige, "PrivilegeInfo.principal_type") ===
+ PrincipalType.USER
+ )?.length ? null : (
+ <div className="ps-2">No cluster privileges</div>
+ )}
+ <Table
+ data={get(userInfo, "privileges", []).filter(
+ (privilige) =>
+ get(privilige, "PrivilegeInfo.permission_name") ===
+ PermissionNameType.VIEW_USER
+ )}
+ columns={columnsInUserPrivilegesView}
+ className="mt-5"
+ />
+ {get(userInfo, "privileges", []).filter(
+ (privilige) =>
+ get(privilige, "PrivilegeInfo.permission_name") ===
+ PermissionNameType.VIEW_USER
+ )?.length ? null : (
+ <div className="ps-2">No view privileges</div>
+ )}
+ </div>
+ )}
+ </Form.Group>
+ </Form>
+ )}
+ </div>
+ );
+}
diff --git
a/ambari-admin/src/main/resources/ui/ambari-admin/src/tests/EditUser.test.tsx
b/ambari-admin/src/main/resources/ui/ambari-admin/src/tests/EditUser.test.tsx
new file mode 100644
index 0000000000..259496675f
--- /dev/null
+++
b/ambari-admin/src/main/resources/ui/ambari-admin/src/tests/EditUser.test.tsx
@@ -0,0 +1,342 @@
+/**
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+import { render, screen, fireEvent, waitFor } from "@testing-library/react";
+import EditUser from "../screens/Users/EditUser";
+import { describe, it, beforeEach, expect, vi } from "vitest";
+import "@testing-library/jest-dom/vitest";toast
+import UserGroupApi from "../api/userGroupApi";
+import { Router } from "react-router";
+import AppContent from "../context/AppContext";
+import { createMemoryHistory } from "history";
+import toast from "react-hot-toast";
+import { rbacData } from "../__mocks__/mockRbacData";
+import { groupNames } from "../__mocks__/mockGroupNames";
+import { userData } from "../__mocks__/mockUserData";
+import PrivilegeApi from "../api/privilegeApi";
+
+describe("EditUser component", () => {
+ const mockClusterName = "testCluster";
+ const mockContext = {
+ cluster: { cluster_name: mockClusterName },
+ setSelectedOption: vi.fn(),
+ selectedOption: "Users",
+ rbacData: {},
+ setRbacData: () => vi.fn(),
+ permissionLabelList: [],
+ setPermissionLabelList: vi.fn(),
+ };
+
+ let mockToastSuccessMessage = "";
+ let mockToastErrorMessage = "";
+
+ toast.success = (message) => {
+ mockToastSuccessMessage = message as string;
+ return "";
+ };
+
+ toast.error = (message) => {
+ mockToastErrorMessage = message as string;
+ return "";
+ };
+
+ beforeEach(() => {
+ vi.clearAllMocks();
+ mockToastSuccessMessage = "";
+ mockToastErrorMessage = "";
+ UserGroupApi.getPermissions = async () => rbacData;
+ UserGroupApi.groupNames = async () => groupNames;
+ UserGroupApi.userData = async () => userData;
+ });
+
+ const renderComponent = () => {
+ render(
+ <AppContent.Provider value={mockContext}>
+ <Router history={createMemoryHistory()}>
+ <EditUser />
+ </Router>
+ </AppContent.Provider>
+ );
+ };
+
+ it("renders EditUser component without crashing", async () => {
+ renderComponent();
+ expect(screen.getByText(/Users/i)).toBeInTheDocument();
+ });
+
+ it("handles loading state", async () => {
+ renderComponent();
+
+ await waitFor(() => {
+ expect(screen.getByTestId("admin-spinner")).toBeInTheDocument();
+ });
+ });
+
+ it("fetches and displays user data", async () => {
+ renderComponent();
+
+ await waitFor(() => {
+ expect(screen.getByText(/dsasd/i)).toBeInTheDocument();
+ });
+
+ expect(screen.getByText(/Local/i)).toBeInTheDocument();
+ expect(screen.getByText(/Active/i)).toBeInTheDocument();
+ expect(screen.getByText(/gdgdeg/i)).toBeInTheDocument();
+ expect(screen.getByText(/group1/i)).toBeInTheDocument();
+ expect(screen.getAllByText(/Cluster User/i)).toHaveLength(2);
+ expect(screen.getAllByText(/View User/i)).toHaveLength(4);
+ expect(screen.getByText(mockClusterName)).toBeInTheDocument();
+ });
+
+ it("toggles user status", async () => {
+ UserGroupApi.updateUser = async (userName: string) => {
+ toast.success(`The active status for ${userName} is updated`);
+ return { status: 200 };
+ };
+ renderComponent();
+
+ await waitFor(() => {
+ expect(screen.getByText(/dsasd/i)).toBeInTheDocument();
+ });
+
+ const statusSwitch = screen.getByTestId("user-status-switch");
+ expect(statusSwitch).toBeChecked();
+ fireEvent.click(statusSwitch);
+
+ await waitFor(() => {
+ screen.getByTestId("confirmation-modal");
+ });
+
+ const confirmButton = screen.getByTestId("confirm-ok-btn");
+ fireEvent.click(confirmButton);
+
+ await waitFor(() => {
+ expect(mockToastSuccessMessage).toBe(
+ "The active status for dsasd is updated"
+ );
+ });
+ });
+
+ it("toggles Ambari Admin status", async () => {
+ UserGroupApi.updateUser = async (userName: string) => {
+ toast.success(`The admin status for ${userName} is updated`);
+ return { status: 200 };
+ };
+ renderComponent();
+
+ await waitFor(() => {
+ expect(screen.getByText(/dsasd/i)).toBeInTheDocument();
+ });
+
+ const adminSwitch = screen.getByTestId("ambari-admin-switch");
+ expect(adminSwitch).not.toBeChecked();
+ fireEvent.click(adminSwitch);
+
+ await waitFor(() => {
+ screen.getByTestId("confirmation-modal");
+ });
+
+ const confirmButton = screen.getByTestId("confirm-ok-btn");
+ fireEvent.click(confirmButton);
+
+ await waitFor(() => {
+ expect(mockToastSuccessMessage).toBe(
+ "The admin status for dsasd is updated"
+ );
+ });
+ });
+
+ it("updates user password", async () => {
+ UserGroupApi.updateUser = async (userName: string) => {
+ toast.success(`Password changed for ${userName}`);
+ return { status: 200 };
+ };
+ renderComponent();
+
+ await waitFor(() => {
+ expect(screen.getByText(/dsasd/i)).toBeInTheDocument();
+ });
+
+ const changePasswordButton = screen.getByTestId("change-password-btn");
+ fireEvent.click(changePasswordButton);
+
+ await waitFor(() => {
+ screen.getByTestId("change-password-modal");
+ });
+
+ expect(screen.getByText(/Change Password for dsasd/i)).toBeInTheDocument();
+
+ const yourPasswordInput = screen.getByTestId("your-password-input");
+ const newPasswordInput = screen.getByTestId("new-password-input");
+ const newConfirmPasswordInput = screen.getByTestId(
+ "new-confirm-password-input"
+ );
+
+ fireEvent.change(yourPasswordInput, { target: { value: "oldPassword" } });
+ fireEvent.change(newPasswordInput, { target: { value: "newPassword" } });
+ fireEvent.change(newConfirmPasswordInput, {
+ target: { value: "newPassword" },
+ });
+
+ const savePasswordButton = screen.getByTestId("save-password-btn");
+ fireEvent.click(savePasswordButton);
+
+ await waitFor(() => {
+ expect(mockToastSuccessMessage).toBe("Password changed for dsasd");
+ });
+ });
+
+ it("updates local group membership", async () => {
+ UserGroupApi.addMember = async (_, userName: string) => {
+ toast.success(`Local Group Membership updated for ${userName}`);
+ return { status: 200 };
+ };
+ renderComponent();
+
+ await waitFor(() => {
+ expect(screen.getByText(/dsasd/i)).toBeInTheDocument();
+ });
+
+ const groupSelect = screen.getByLabelText("select-group");
+
+ fireEvent.mouseDown(groupSelect);
+ fireEvent.click(screen.getByText("ferfew"));
+
+ const saveButton = screen.getByTestId("save-groups-btn");
+ fireEvent.click(saveButton);
+
+ await waitFor(() => {
+ expect(mockToastSuccessMessage).toBe(
+ "Local Group Membership updated for dsasd"
+ );
+ });
+ });
+
+ it("renders user access dropdown and selects an option", async () => {
+ PrivilegeApi.addClusterPrivileges = async () => {
+ toast.success("User access updated successfully");
+ return { status: 200 };
+ };
+ renderComponent();
+
+ await waitFor(() => {
+ expect(screen.getByText(/dsasd/i)).toBeInTheDocument();
+ });
+
+ const userAccessSelect = screen.getByTestId(
+ "user-access-dropdown"
+ ) as HTMLSelectElement;
+ fireEvent.change(userAccessSelect, { target: { value: "Cluster User" } });
+
+ expect(userAccessSelect.value).toBe("Cluster User");
+ await waitFor(() => {
+ expect(mockToastSuccessMessage).toBe("User access updated successfully");
+ });
+ });
+
+ it("shows unsaved changes warning modal", async () => {
+ renderComponent();
+
+ await waitFor(() => {
+ expect(screen.getByText(/dsasd/i)).toBeInTheDocument();
+ });
+
+ const groupSelect = screen.getByLabelText("select-group");
+
+ fireEvent.mouseDown(groupSelect);
+ fireEvent.click(screen.getByText("ferfew"));
+
+ const usersListButton = screen.getByTestId("users-list-link");
+ fireEvent.click(usersListButton);
+
+ await waitFor(() => {
+ expect(screen.getByTestId("warning-modal")).toBeInTheDocument();
+ });
+
+ const cancelButton = screen.getByText("Cancel");
+ fireEvent.click(cancelButton);
+
+ await waitFor(() => {
+ expect(screen.queryByTestId("warning-modal")).toBeNull();
+ });
+ });
+
+ it("renders RBAC modal for user access tooltip", async () => {
+ renderComponent();
+
+ await waitFor(() => {
+ expect(screen.getByText(/dsasd/i)).toBeInTheDocument();
+ });
+
+ const userAccessTooltipIcon = screen.getByTestId("user-access-info-icon");
+ fireEvent.click(userAccessTooltipIcon);
+
+ await waitFor(() => {
+ expect(
+ screen.getByTestId("role-based-access-control-modal")
+ ).toBeInTheDocument();
+ });
+ });
+
+ it("removes view privileges", async () => {
+ PrivilegeApi.removeViewPrivileges = async () => {
+ toast.success("View privileges removed successfully");
+ return { status: 200 };
+ };
+
+ renderComponent();
+
+ await waitFor(() => {
+ expect(screen.getByText(/dsasd/i)).toBeInTheDocument();
+ });
+
+ const removeViewButton = screen.getByTestId(
+ "remove-privilege-icon-jukende"
+ );
+ fireEvent.click(removeViewButton);
+
+ await waitFor(() => {
+ expect(mockToastSuccessMessage).toBe(
+ "View privileges removed successfully"
+ );
+ });
+ });
+
+ it("handles API errors gracefully", async () => {
+ UserGroupApi.userData = async () => {
+ toast.error("Failed to fetch user data");
+ return { status: 400 };
+ };
+
+ renderComponent();
+
+ await waitFor(() => {
+ expect(mockToastErrorMessage).toBe("Failed to fetch user data");
+ });
+ });
+
+ it("disables Local Group Membership by default unless changes are made and
submitted", async () => {
+ renderComponent();
+
+ await waitFor(() => {
+ expect(screen.getByText(/dsasd/i)).toBeInTheDocument();
+ });
+
+ const saveButton = screen.getByTestId("save-groups-btn");
+ expect(saveButton).toHaveClass("cursor-not-allowed opacity-50");
+ });
+});
---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]