Repository: osbuild/image-builder-frontend
Pull request #3071: Wizard: Add support for multiple users (HMS-5911)
Author: login: regexowl
Created: 2025-04-03T11:42:21Z | Merged: 2025-04-18T21:45:59Z

This adds support for multiple users in the Wizard. The users are rendered in tabs and can be both added and removed. When clicking on remove button, modal pops up to inform user that the changes cannot be undone.

JIRA: [HMS-5911](https://issues.redhat.com/browse/HMS-5911)


Diff:
diff --git a/src/Components/CreateImageWizard/steps/Users/components/RemoveUserModal.tsx b/src/Components/CreateImageWizard/steps/Users/components/RemoveUserModal.tsx
new file mode 100644
index 0000000000..e93a84b5b3
--- /dev/null
+++ b/src/Components/CreateImageWizard/steps/Users/components/RemoveUserModal.tsx
@@ -0,0 +1,71 @@
+import React from 'react';
+
+import { Button, Modal } from '@patternfly/react-core';
+
+import calculateNewIndex from './calculateNewIndex';
+
+import { useAppDispatch, useAppSelector } from '../../../../../store/hooks';
+import { removeUser, selectUsers } from '../../../../../store/wizardSlice';
+
+type RemoveUserModalProps = {
+  setShowRemoveUserModal: React.Dispatch<React.SetStateAction<boolean>>;
+  activeTabKey: number;
+  setActiveTabKey: React.Dispatch<React.SetStateAction<number>>;
+  tabIndex: number;
+  setIndex: React.Dispatch<React.SetStateAction<number>>;
+  isOpen: boolean;
+  userName: string;
+};
+
+const RemoveUserModal = ({
+  setShowRemoveUserModal,
+  activeTabKey,
+  setActiveTabKey,
+  tabIndex,
+  setIndex,
+  isOpen,
+  userName,
+}: RemoveUserModalProps) => {
+  const dispatch = useAppDispatch();
+  const users = useAppSelector(selectUsers);
+
+  const onClose = () => {
+    setShowRemoveUserModal(!isOpen);
+  };
+
+  const onConfirm = () => {
+    const nextTabIndex = calculateNewIndex(
+      tabIndex,
+      activeTabKey,
+      users.length
+    );
+
+    setActiveTabKey(nextTabIndex);
+    setIndex(nextTabIndex);
+    dispatch(removeUser(tabIndex));
+    setShowRemoveUserModal(!isOpen);
+  };
+
+  return (
+    <Modal
+      title={`Remove user${userName ? ` ${userName}` : ''}?`}
+      isOpen={isOpen}
+      onClose={onClose}
+      width="50%"
+      actions={[
+        <Button key="confirm" variant="primary" onClick={onConfirm}>
+          Remove user
+        </Button>,
+        <Button key="cancel" variant="link" onClick={onClose}>
+          Cancel
+        </Button>,
+      ]}
+      ouiaId="removeUserModal"
+    >
+      This action is permanent and cannot be undone. Once deleted all
+      information about the user will be lost.
+    </Modal>
+  );
+};
+
+export default RemoveUserModal;
diff --git a/src/Components/CreateImageWizard/steps/Users/components/UserInfo.tsx b/src/Components/CreateImageWizard/steps/Users/components/UserInfo.tsx
index 418898a864..a3d50d45e9 100644
--- a/src/Components/CreateImageWizard/steps/Users/components/UserInfo.tsx
+++ b/src/Components/CreateImageWizard/steps/Users/components/UserInfo.tsx
@@ -1,23 +1,30 @@
-import React from 'react';
+import React, { useState } from 'react';
 
-import { Button, FormGroup, Checkbox, Tooltip } from '@patternfly/react-core';
-import { ExternalLinkAltIcon, TrashIcon } from '@patternfly/react-icons';
+import {
+  Button,
+  FormGroup,
+  Checkbox,
+  Tabs,
+  Tab,
+  TabTitleText,
+} from '@patternfly/react-core';
+import { ExternalLinkAltIcon } from '@patternfly/react-icons';
+
+import calculateNewIndex from './calculateNewIndex';
+import RemoveUserModal from './RemoveUserModal';
 
 import { GENERATING_SSH_KEY_PAIRS_URL } from '../../../../../constants';
 import { useAppDispatch, useAppSelector } from '../../../../../store/hooks';
 import {
-  selectUserAdministrator,
-  selectUserNameByIndex,
-  selectUserPasswordByIndex,
-  selectUserSshKeyByIndex,
   setUserNameByIndex,
   setUserPasswordByIndex,
   setUserSshKeyByIndex,
   setUserAdministratorByIndex,
-  removeUser,
-  selectUserGroupsByIndex,
+  addUser,
+  selectUsers,
   addUserGroupByIndex,
   removeUserGroupByIndex,
+  removeUser,
 } from '../../../../../store/wizardSlice';
 import LabelInput from '../../../LabelInput';
 import { PasswordValidatedInput } from '../../../utilities/PasswordValidatedInput';
@@ -27,17 +34,62 @@ import { isUserGroupValid } from '../../../validators';
 
 const UserInfo = () => {
   const dispatch = useAppDispatch();
-  const index = 0;
-  const userNameSelector = selectUserNameByIndex(index);
-  const userName = useAppSelector(userNameSelector);
-  const userPasswordSelector = selectUserPasswordByIndex(index);
-  const userPassword = useAppSelector(userPasswordSelector);
-  const userSshKeySelector = selectUserSshKeyByIndex(index);
-  const userSshKey = useAppSelector(userSshKeySelector);
-  const userIsAdministratorSelector = selectUserAdministrator(index);
-  const userIsAdministrator = useAppSelector(userIsAdministratorSelector);
-  const userGroupsSelector = selectUserGroupsByIndex(index);
-  const userGroups = useAppSelector(userGroupsSelector);
+  const users = useAppSelector(selectUsers);
+  const stepValidation = useUsersValidation();
+
+  const [index, setIndex] = useState(0);
+  const [activeTabKey, setActiveTabKey] = useState(0);
+  const [showRemoveUserModal, setShowRemoveUserModal] = useState(false);
+
+  // Taken directly from PF5 Dynamic tabs documentation
+  // https://v5-archive.patternfly.org/components/tabs#dynamic-tabs
+  // eslint-disable-next-line @typescript-eslint/no-explicit-any
+  const tabComponentRef = React.useRef<any>();
+  const firstMount = React.useRef(true);
+
+  const onSelect = (event: React.MouseEvent, tabIndex: number) => {
+    setActiveTabKey(tabIndex);
+    setIndex(tabIndex);
+  };
+
+  const onAdd = () => {
+    setActiveTabKey(users.length);
+    setIndex(index + 1);
+    dispatch(addUser());
+  };
+
+  React.useEffect(() => {
+    if (firstMount.current) {
+      firstMount.current = false;
+      return;
+    } else {
+      const first =
+        tabComponentRef.current.tabList.current.childNodes[activeTabKey];
+      if (first) {
+        first.firstChild.focus();
+      }
+    }
+  }, [users.length]);
+
+  const onClose = (_event: React.MouseEvent, tabIndex: number) => {
+    if (
+      users[tabIndex].name === '' &&
+      users[tabIndex].password === '' &&
+      users[tabIndex].ssh_key === ''
+    ) {
+      const nextTabIndex = calculateNewIndex(
+        tabIndex,
+        activeTabKey,
+        users.length
+      );
+      setActiveTabKey(nextTabIndex);
+      setIndex(nextTabIndex);
+      dispatch(removeUser(tabIndex));
+    } else {
+      setShowRemoveUserModal(true);
+      setIndex(tabIndex);
+    }
+  };
 
   const handleNameChange = (
     _e: React.FormEvent<HTMLInputElement | HTMLTextAreaElement>,
@@ -60,8 +112,6 @@ const UserInfo = () => {
     dispatch(setUserSshKeyByIndex({ index: index, sshKey: value }));
   };
 
-  const stepValidation = useUsersValidation();
-
   const handleCheckboxChange = (
     _event: React.FormEvent<HTMLInputElement>,
     value: boolean
@@ -71,88 +121,113 @@ const UserInfo = () => {
     );
   };
 
-  const onRemoveUserClick = () => {
-    dispatch(removeUser(index));
+  const getValidationByIndex = (index: number) => {
+    return {
+      errors: {
+        userName: stepValidation?.errors[index]?.userName,
+        userSshKey: stepValidation?.errors[index]?.userSshKey,
+      },
+      disabledNext: stepValidation.disabledNext,
+    };
   };
 
   return (
     <>
-      <FormGroup isRequired label="Username">
-        <ValidatedInputAndTextArea
-          ariaLabel="blueprint user name"
-          value={userName || ''}
-          placeholder="Enter username"
-          onChange={(_e, value) => handleNameChange(_e, value)}
-          stepValidation={stepValidation}
-          fieldName="userName"
-        />
-      </FormGroup>
-      <PasswordValidatedInput
-        value={userPassword || ''}
-        ariaLabel="blueprint user password"
-        placeholder="Enter password"
-        onChange={(_e, value) => handlePasswordChange(_e, value)}
+      <RemoveUserModal
+        setShowRemoveUserModal={setShowRemoveUserModal}
+        activeTabKey={activeTabKey}
+        setActiveTabKey={setActiveTabKey}
+        tabIndex={index}
+        setIndex={setIndex}
+        isOpen={showRemoveUserModal}
+        userName={users[index] ? users[index].name : ''}
       />
-      <FormGroup isRequired label="SSH key">
-        <ValidatedInputAndTextArea
-          inputType={'textArea'}
-          ariaLabel="public SSH key"
-          value={userSshKey || ''}
-          type={'text'}
-          onChange={(_e, value) => handleSshKeyChange(_e, value)}
-          placeholder="Paste your public SSH key"
-          stepValidation={stepValidation}
-          fieldName="userSshKey"
-        />
-        <Button
-          component="a"
-          target="_blank"
-          variant="link"
-          icon={<ExternalLinkAltIcon />}
-          iconPosition="right"
-          href={GENERATING_SSH_KEY_PAIRS_URL}
-          className="pf-v5-u-pl-0"
-        >
-          Learn more about SSH keys
-        </Button>
-      </FormGroup>
-      <FormGroup>
-        <Checkbox
-          label="Administrator"
-          isChecked={userIsAdministrator || userGroups.includes('wheel')}
-          onChange={(_e, value) => handleCheckboxChange(_e, value)}
-          aria-label="Administrator"
-          id="user Administrator"
-          name="user Administrator"
-        />
-      </FormGroup>
-      <FormGroup label="Groups">
-        <LabelInput
-          ariaLabel="Add user group"
-          placeholder="Add user group"
-          validator={isUserGroupValid}
-          list={userGroups}
-          item="Group"
-          addAction={(value) =>
-            addUserGroupByIndex({ index: index, group: value })
-          }
-          removeAction={(value) =>
-            removeUserGroupByIndex({ index: index, group: value })
-          }
-          stepValidation={stepValidation}
-          fieldName="groups"
-        />
-      </FormGroup>
-      <Tooltip position="top-start" content={'Remove user'}>
-        <FormGroup>
-          <Button
-            aria-label="remove user"
-            onClick={onRemoveUserClick}
-            variant="tertiary"
-            icon={<TrashIcon />}
-          ></Button>
-        </FormGroup>
-      </Tooltip>
+      <Tabs
+        aria-label="Users tabs"
+        activeKey={activeTabKey}
+        onSelect={onSelect}
+        onAdd={onAdd}
+        onClose={onClose}
+        ref={tabComponentRef}
+      >
+        {users.map((user, index) => (
+          <Tab
+            aria-label={`User ${user.name} tab`}
+            key={index}
+            eventKey={index}
+            title={<TabTitleText>{user.name || 'New user'}</TabTitleText>}
+          >
+            <FormGroup isRequired label="Username" className="pf-v5-u-pb-md">
+              <ValidatedInputAndTextArea
+                ariaLabel="blueprint user name"
+                value={user.name || ''}
+                placeholder="Enter username"
+                onChange={(_e, value) => handleNameChange(_e, value)}
+                stepValidation={getValidationByIndex(index)}
+                fieldName="userName"
+              />
+            </FormGroup>
+            <PasswordValidatedInput
+              value={user.password || ''}
+              ariaLabel="blueprint user password"
+              placeholder="Enter password"
+              onChange={(_e, value) => handlePasswordChange(_e, value)}
+            />
+            <FormGroup label="SSH key" className="pf-v5-u-pb-md">
+              <ValidatedInputAndTextArea
+                inputType={'textArea'}
+                ariaLabel="public SSH key"
+                value={user.ssh_key || ''}
+                type={'text'}
+                onChange={(_e, value) => handleSshKeyChange(_e, value)}
+                placeholder="Paste your public SSH key"
+                stepValidation={getValidationByIndex(index)}
+                fieldName="userSshKey"
+              />
+              <Button
+                component="a"
+                target="_blank"
+                variant="link"
+                icon={<ExternalLinkAltIcon />}
+                iconPosition="right"
+                href={GENERATING_SSH_KEY_PAIRS_URL}
+                className="pf-v5-u-pl-0"
+              >
+                Learn more about SSH keys
+              </Button>
+            </FormGroup>
+            <FormGroup className="pf-v5-u-pb-md">
+              <Checkbox
+                label="Administrator"
+                isChecked={
+                  user.isAdministrator || user.groups.includes('wheel')
+                }
+                onChange={(_e, value) => handleCheckboxChange(_e, value)}
+                aria-label="Administrator"
+                id={`${user.name}-${index}`}
+                name="user Administrator"
+              />
+            </FormGroup>
+            <FormGroup label="Groups">
+              <LabelInput
+                ariaLabel="Add user group"
+                placeholder="Add user group"
+                validator={isUserGroupValid}
+                list={user.groups}
+                item="Group"
+                addAction={(value) =>
+                  addUserGroupByIndex({ index: index, group: value })
+                }
+                removeAction={(value) =>
+                  removeUserGroupByIndex({ index: index, group: value })
+                }
+                stepValidation={getValidationByIndex(index)}
+                fieldName="groups"
+              />
+            </FormGroup>
+          </Tab>
+        ))}
+      </Tabs>
     </>
   );
 };
diff --git a/src/Components/CreateImageWizard/steps/Users/components/calculateNewIndex.ts b/src/Components/CreateImageWizard/steps/Users/components/calculateNewIndex.ts
new file mode 100644
index 0000000000..d2548195a3
--- /dev/null
+++ b/src/Components/CreateImageWizard/steps/Users/components/calculateNewIndex.ts
@@ -0,0 +1,20 @@
+function calculateNewIndex(
+  tabIndex: number,
+  activeTabKey: number,
+  usersLength: number
+) {
+  const tabIndexNum = tabIndex;
+  let nextTabIndex = activeTabKey;
+
+  if (tabIndexNum < activeTabKey) {
+    // if a preceding tab is closing, keep focus on the new index of the current tab
+    nextTabIndex = activeTabKey - 1 > 0 ? activeTabKey - 1 : 0;
+  } else if (activeTabKey === usersLength - 1) {
+    // if the closing tab is the last tab, focus the preceding tab
+    nextTabIndex = usersLength - 2 >= 0 ? usersLength - 2 : 0;
+  }
+
+  return nextTabIndex;
+}
+
+export default calculateNewIndex;
diff --git a/src/Components/CreateImageWizard/utilities/PasswordValidatedInput.tsx b/src/Components/CreateImageWizard/utilities/PasswordValidatedInput.tsx
index c395da6ba9..8926e4b5b1 100644
--- a/src/Components/CreateImageWizard/utilities/PasswordValidatedInput.tsx
+++ b/src/Components/CreateImageWizard/utilities/PasswordValidatedInput.tsx
@@ -45,7 +45,7 @@ export const PasswordValidatedInput = ({
   };
 
   return (
-    <FormGroup label="Password" isRequired>
+    <FormGroup label="Password" className="pf-v5-u-pb-md">
       <>
         <InputGroup>
           <InputGroupItem isFill>
diff --git a/src/Components/CreateImageWizard/utilities/useValidation.tsx b/src/Components/CreateImageWizard/utilities/useValidation.tsx
index 22370f9603..2a618e21eb 100644
--- a/src/Components/CreateImageWizard/utilities/useValidation.tsx
+++ b/src/Components/CreateImageWizard/utilities/useValidation.tsx
@@ -21,19 +21,17 @@ import {
   selectRegistrationType,
   selectHostname,
   selectKernel,
-  selectUserNameByIndex,
   selectUsers,
-  selectUserPasswordByIndex,
-  selectUserSshKeyByIndex,
   selectNtpServers,
   selectFirewall,
   selectServices,
   selectLanguages,
   selectKeyboard,
   selectTimezone,
-  selectImageTypes,
   selectSatelliteCaCertificate,
   selectSatelliteRegistrationCommand,
+  selectImageTypes,
+  UserWithAdditionalInfo,
 } from '../../../store/wizardSlice';
 import { keyboardsList } from '../steps/Locale/keyboardsList';
 import { languagesList } from '../steps/Locale/languagesList';
@@ -61,6 +59,13 @@ export type StepValidation = {
   disabledNext: boolean;
 };
 
+export type UsersStepValidation = {
+  errors: {
+    [key: string]: { [key: string]: string };
+  };
+  disabledNext: boolean;
+};
+
 export function useIsBlueprintValid(): boolean {
   const registration = useRegistrationValidation();
   const filesystem = useFilesystemValidation();
@@ -90,12 +95,6 @@ export function useIsBlueprintValid(): boolean {
   );
 }
 
-type ValidationFlags = {
-  isUserNameValidValue: boolean;
-  isSshKeyValidValue: boolean;
-  isPasswordValidValue: boolean;
-};
-
 type PasswordValidationResult = {
   isValid: boolean;
   strength: {
@@ -453,70 +452,80 @@ export function useServicesValidation(): StepValidation {
   };
 }
 
-const getUserNameErrorMsg = (userName: string): string => {
-  if (userName && !isUserNameValid(userName)) {
+const validateUserName = (
+  users: UserWithAdditionalInfo[],
+  userName: string
+): string => {
+  if (!userName) {
+    return 'Required value';
+  }
+  if (!isUserNameValid(userName)) {
     return 'Invalid user name';
   }
+
+  // check for duplicate names
+  const duplicateName =
+    new Set(users.map((user) => user.name)).size !== users.length;
+  if (duplicateName) {
+    return 'Username already exists';
+  }
   return '';
 };
 
-const getSshKeyErrorMsg = (userSshKey: string): string => {
+const validateSshKey = (userSshKey: string): string => {
   if (userSshKey && !isSshKeyValid(userSshKey)) {
     return 'Invalid SSH key';
   }
   return '';
 };
 
-export function useUsersValidation(): StepValidation {
-  const index = 0;
+export function useUsersValidation(): UsersStepValidation {
   const environments = useAppSelector(selectImageTypes);
-  const userNameSelector = selectUserNameByIndex(index);
-  const userName = useAppSelector(userNameSelector);
-  const userPasswordSelector = selectUserPasswordByIndex(index);
-  const userPassword = useAppSelector(userPasswordSelector);
-  const userSshKeySelector = selectUserSshKeyByIndex(index);
-  const userSshKey = useAppSelector(userSshKeySelector);
   const users = useAppSelector(selectUsers);
+  const errors: { [key: string]: { [key: string]: string } } = {};
 
-  const userNameError = getUserNameErrorMsg(userName);
-  const sshKeyError = getSshKeyErrorMsg(userSshKey);
+  if (users.length === 0) {
+    return {
+      errors: {},
+      disabledNext: false,
+    };
+  }
 
-  const { isUserNameValidValue, isSshKeyValidValue, isPasswordValidValue } =
-    calculateValidationFlags(
-      userName,
-      userPassword,
-      userSshKey,
+  for (let index = 0; index < users.length; index++) {
+    const userNameError = validateUserName(users, users[index].name);
+    const sshKeyError = validateSshKey(users[index].ssh_key);
+    const isPasswordValid = checkPasswordValidity(
+      users[index].password,
       environments.includes('azure')
-    );
+    ).isValid;
+    const passwordError =
+      users[index].password && !isPasswordValid ? 'Invalid password' : '';
+
+    if (
+      userNameError ||
+      sshKeyError ||
+      (users[index].password && !isPasswordValid)
+    ) {
+      errors[`${index}`] = {
+        userName: userNameError,
+        userSshKey: sshKeyError,
+        userPassword: passwordError,
+      };
+    }
+  }
 
   const canProceed =
     // Case 1: there is no users
     users.length === 0 ||
-    // Case 2: userName is valid and SshKey or Password is valid
-    (isUserNameValidValue && (isSshKeyValidValue || isPasswordValidValue));
+    // Case 2: all users are valid
+    Object.keys(errors).length === 0;
 
   return {
-    errors: {
-      userName: userNameError,
-      userSshKey: sshKeyError,
-    },
+    errors,
     disabledNext: !canProceed,
   };
 }
 
-const calculateValidationFlags = (
-  userName: string,
-  userPassword: string,
-  userSshKey: string,
-  isAzure: boolean
-): ValidationFlags => {
-  const isUserNameValidValue = !!userName && isUserNameValid(userName);
-  const isSshKeyValidValue = !!userSshKey && isSshKeyValid(userSshKey);
-  const isPasswordValidValue =
-    !!userPassword && checkPasswordValidity(userPassword, isAzure).isValid;
-  return { isUserNameValidValue, isSshKeyValidValue, isPasswordValidValue };
-};
-
 export const checkPasswordValidity = (
   password: string,
   isAzure: boolean
diff --git a/src/test/Components/CreateImageWizard/CreateImageWizard.test.tsx b/src/test/Components/CreateImageWizard/CreateImageWizard.test.tsx
index 772dd2473b..3d8c3ac8b9 100644
--- a/src/test/Components/CreateImageWizard/CreateImageWizard.test.tsx
+++ b/src/test/Components/CreateImageWizard/CreateImageWizard.test.tsx
@@ -10,30 +10,6 @@ const getSourceDropdown = async () => {
   return sourceDropdown;
 };
 
-export const addTargetEnvAzure = async () => {
-  const user = userEvent.setup();
-  expect(
-    await screen.findByRole('radio', {
-      name: /use an account configured from sources\./i,
-    })
-  ).toHaveFocus();
-  const azureSourceDropdown = await getSourceDropdown();
-  await waitFor(() => user.click(azureSourceDropdown));
-  const azureSource = await screen.findByRole('option', {
-    name: /azureSource1/i,
-  });
-  await waitFor(() => user.click(azureSource));
-
-  const resourceGroupDropdown = await screen.findByPlaceholderText(
-    /select resource group/i
-  );
-  await waitFor(() => user.click(resourceGroupDropdown));
-  await waitFor(async () =>
-    user.click(await screen.findByLabelText('Resource group myResourceGroup1'))
-  );
-  await clickNext();
-};
-
 const selectAllEnvironments = async () => {
   const user = userEvent.setup();
 
@@ -134,7 +110,28 @@ describe('Keyboard accessibility', () => {
     await clickNext();
 
     // Target environment azure
-    await addTargetEnvAzure();
+    expect(
+      await screen.findByRole('radio', {
+        name: /use an account configured from sources\./i,
+      })
+    ).toHaveFocus();
+    const azureSourceDropdown = await getSourceDropdown();
+    await waitFor(() => user.click(azureSourceDropdown));
+    const azureSource = await screen.findByRole('option', {
+      name: /azureSource1/i,
+    });
+    await waitFor(() => user.click(azureSource));
+
+    const resourceGroupDropdown = await screen.findByPlaceholderText(
+      /select resource group/i
+    );
+    await waitFor(() => user.click(resourceGroupDropdown));
+    await waitFor(async () =>
+      user.click(
+        await screen.findByLabelText('Resource group myResourceGroup1')
+      )
+    );
+    await clickNext();
 
     // Registration
     await screen.findByText(
diff --git a/src/test/Components/CreateImageWizard/steps/Users/Users.test.tsx b/src/test/Components/CreateImageWizard/steps/Users/Users.test.tsx
index cf2059790a..cdbe1e4c2c 100644
--- a/src/test/Components/CreateImageWizard/steps/Users/Users.test.tsx
+++ b/src/test/Components/CreateImageWizard/steps/Users/Users.test.tsx
@@ -5,7 +5,6 @@ import { userEvent } from '@testing-library/user-event';
 import { CREATE_BLUEPRINT, EDIT_BLUEPRINT } from '../../../../../constants';
 import { mockBlueprintIds } from '../../../../fixtures/blueprints';
 import { usersCreateBlueprintRequest } from '../../../../fixtures/editMode';
-import { addTargetEnvAzure } from '../../CreateImageWizard.test';
 import {
   blueprintRequest,
   clickBack,
@@ -14,28 +13,31 @@ import {
   getNextButton,
   interceptBlueprintRequest,
   interceptEditBlueprintRequest,
+  openAndDismissSaveAndBuildModal,
   renderEditMode,
+  selectGuestImageTarget,
   verifyCancelButton,
 } from '../../wizardTestUtils';
-import {
-  clickRegisterLater,
-  goToRegistrationStep,
-  renderCreateMode,
-} from '../../wizardTestUtils';
+import { clickRegisterLater, renderCreateMode } from '../../wizardTestUtils';
 
 let router: RemixRouter | undefined = undefined;
+
 const validUserName = 'best';
 const validSshKey = 'ssh-rsa d';
 const validPassword = 'validPassword';
 const invalidPassword = 'inval';
+const rachelPasswd = 'rachelPass';
+const chandlerPasswd = 'chandlerPass';
 
 const goToUsersStep = async () => {
-  await clickNext();
-  await clickNext();
-  await clickNext();
-  await clickNext();
-  await clickNext();
-  await clickNext();
+  await clickNext(); // Registration
+  await clickRegisterLater();
+  await clickNext(); // OpenSCAP
+  await clickNext(); // File system configuration
+  await clickNext(); // Snapshots
+  await clickNext(); // Custom repositories
+  await clickNext(); // Additional packages
+  await clickNext(); // Users
 };
 
 const goToReviewStep = async () => {
@@ -51,6 +53,29 @@ const goToReviewStep = async () => {
   await clickNext(); // Review
 };
 
+const addAzureTarget = async () => {
+  const user = userEvent.setup();
+  await waitFor(() => user.click(screen.getByTestId('upload-azure')));
+  await clickNext();
+
+  const azureSourceDropdown = await screen.findByPlaceholderText(
+    /select source/i
+  );
+  await waitFor(() => user.click(azureSourceDropdown));
+  const azureSource = await screen.findByRole('option', {
+    name: /azureSource1/i,
+  });
+  await waitFor(() => user.click(azureSource));
+
+  const resourceGroupDropdown = await screen.findByPlaceholderText(
+    /select resource group/i
+  );
+  await waitFor(() => user.click(resourceGroupDropdown));
+  await waitFor(async () =>
+    user.click(await screen.findByLabelText('Resource group myResourceGroup1'))
+  );
+};
+
 const clickRevisitButton = async () => {
   const user = userEvent.setup();
   const expandable = await screen.findByTestId('users-expandable');
@@ -65,13 +90,56 @@ const clickAddUser = async () => {
   await waitFor(() => user.click(addUser));
 };
 
-const clickRemoveUser = async () => {
+const addAnotherUser = async () => {
   const user = userEvent.setup();
-  const addUser = await screen.findByRole('button', { name: /remove user/i });
+  const addUser = await screen.findByRole('button', { name: /add tab/i });
   expect(addUser).toBeEnabled();
   await waitFor(() => user.click(addUser));
 };
 
+const addAndFillThreeUsers = async () => {
+  await clickAddUser();
+  await addUserName('rachel');
+  await addSshKey('ssh-rsa rachel');
+  await addPasswordByUserIndex('rachelPass', 0);
+  await checkAdminCheckbox();
+  await addUserGroupByUserIndex('users', 0);
+  await addUserGroupByUserIndex('widget', 0);
+
+  await addAnotherUser();
+  await switchToNewUser();
+  await addUserName('monica');
+
+  await addAnotherUser();
+  await switchToNewUser();
+  await addUserName('chandler');
+  await addSshKey('ssh-rsa chandler');
+  await addPasswordByUserIndex('chandlerPass', 2);
+  await addUserGroupByUserIndex('group', 2);
+};
+
+const switchToNewUser = async () => {
+  const user = userEvent.setup();
+  const newUserButton = await screen.findByRole('tab', { name: /user tab/i });
+  await waitFor(() => user.click(newUserButton));
+};
+
+const closeNthTab = async (index: number) => {
+  const user = userEvent.setup();
+  const tabs = await screen.findAllByRole('presentation');
+  const closeTabButton = await within(tabs[index]).findByRole('button');
+  await waitFor(() => user.click(closeTabButton));
+  await clickRemoveUser();
+};
+
+const clickRemoveUser = async () => {
+  const user = userEvent.setup();
+  const removeUserModalButton = await screen.findByRole('button', {
+    name: /Remove user/,
+  });
+  await waitFor(() => user.click(removeUserModalButton));
+};
+
 const addSshKey = async (sshKey: string) => {
   const user = userEvent.setup();
   const enterSshKey = await screen.findByRole('textbox', {
@@ -90,11 +158,10 @@ const addUserName = async (userName: string) => {
   await waitFor(() => expect(enterUserName).toHaveValue(userName));
 };
 
-const addPassword = async (mockPassword: string) => {
+const addPasswordByUserIndex = async (value: string, index: number) => {
   const user = userEvent.setup();
-  const enterPassword = screen.getByPlaceholderText(/enter password/i);
-  await waitFor(() => user.type(enterPassword, mockPassword));
-  await waitFor(() => expect(enterPassword).toHaveValue(mockPassword));
+  const passwordInputs = screen.getAllByPlaceholderText(/enter password/i);
+  await waitFor(() => user.type(passwordInputs[index], value));
 };
 
 const getAdminCheckbox = async () => {
@@ -110,11 +177,13 @@ const checkAdminCheckbox = async () => {
   await waitFor(() => user.click(adminCheckbox));
 };
 
-const addUserGroup = async (group: string) => {
+const addUserGroupByUserIndex = async (group: string, index: number) => {
   const user = userEvent.setup();
-  const userGroupInput = await screen.findByPlaceholderText('Add user group');
-  await waitFor(() => user.click(userGroupInput));
-  await waitFor(() => user.type(userGroupInput, group));
+  const userGroupInputs = await screen.findAllByPlaceholderText(
+    'Add user group'
+  );
+  await waitFor(() => user.click(userGroupInputs[index]));
+  await waitFor(() => user.type(userGroupInputs[index], group));
   const addGroup = await screen.findByRole('button', {
     name: /Add user group/,
   });
@@ -136,22 +205,19 @@ describe('Step Users', () => {
     router = undefined;
   });
 
-  test('without adding user loads timezone', async () => {
-    const user = userEvent.setup();
+  test('clicking Next loads Timezone', async () => {
     await renderCreateMode();
-    await goToRegistrationStep();
-    await clickRegisterLater();
+    await selectGuestImageTarget();
     await goToUsersStep();
-    const nextButton = await getNextButton();
-    expect(nextButton).toBeEnabled();
-    await waitFor(() => user.click(nextButton));
-    await screen.findByText('Select a timezone for your image.');
+    await clickNext();
+    await screen.findByRole('heading', {
+      name: 'Timezone',
+    });
   });
 
   test('clicking Back loads Additional packages', async () => {
     await renderCreateMode();
-    await goToRegistrationStep();
-    await clickRegisterLater();
+    await selectGuestImageTarget();
     await goToUsersStep();
     await clickBack();
     await screen.findByRole('heading', { name: 'Additional packages' });
@@ -159,60 +225,38 @@ describe('Step Users', () => {
 
   test('clicking Cancel loads landing page', async () => {
     await renderCreateMode();
+    await selectGuestImageTarget();
     await goToUsersStep();
     await verifyCancelButton(router);
   });
 
-  test('revisit step button on Review works', async () => {
-    await renderCreateMode();
-    await goToRegistrationStep();
-    await clickRegisterLater();
-    await goToUsersStep();
-    await clickAddUser();
-    await addUserName(validUserName);
-    await addSshKey(validSshKey);
-    const nextButton = await getNextButton();
-    await waitFor(() => expect(nextButton).toBeEnabled());
-    await goToReviewStep();
-    await clickRevisitButton();
-    await screen.findByRole('heading', { name: /Users/ });
-  });
-
   test('with invalid name', async () => {
     await renderCreateMode();
-    await goToRegistrationStep();
-    await clickRegisterLater();
+    await selectGuestImageTarget();
     await goToUsersStep();
     await clickAddUser();
-    await addUserName('ss.');
-    await addSshKey('ssh');
-    const invalidUserMessage = screen.getByText(/invalid user name/i);
-    await waitFor(() => expect(invalidUserMessage));
+    await addUserName('.');
+    await clickNext();
+    await waitFor(() => expect(screen.getByText(/invalid user name/i)));
   });
 
-  test('with invalid ssh key', async () => {
+  test('with invalid SSH key', async () => {
     await renderCreateMode();
-    await goToRegistrationStep();
-    await clickRegisterLater();
+    await selectGuestImageTarget();
     await goToUsersStep();
     await clickAddUser();
     await addSshKey('ssh');
-    await addUserName('bestUser');
-    const invalidUserMessage = screen.getByText(/invalid ssh key/i);
-    await waitFor(() => expect(invalidUserMessage));
+    await clickNext();
+    await waitFor(() => expect(screen.getByText(/invalid ssh key/i)));
   });
 
-  test('try to create Azure image with invalid password', async () => {
-    const user = userEvent.setup();
+  test('Azure target with invalid password', async () => {
     await renderCreateMode();
-    await waitFor(() => user.click(screen.getByTestId('upload-azure')));
-    await clickNext();
-    await addTargetEnvAzure();
-    await clickRegisterLater();
+    await addAzureTarget();
     await goToUsersStep();
     await clickAddUser();
     await addUserName(validUserName);
-    await addPassword(invalidPassword);
+    await addPasswordByUserIndex(invalidPassword, 0);
 
     const invalidUserMessage = screen.getByText(
       /Password must be at least 6 characters long/i
@@ -222,49 +266,48 @@ describe('Step Users', () => {
     );
     await waitFor(() => expect(invalidUserMessage));
     await waitFor(() => expect(warningUserMessage));
+
     const nextButton = await getNextButton();
     await waitFor(() => expect(nextButton).toBeDisabled());
   });
 
   test('with invalid password', async () => {
     await renderCreateMode();
-    await goToRegistrationStep();
-    await clickRegisterLater();
+    await selectGuestImageTarget();
     await goToUsersStep();
     await clickAddUser();
     await addUserName(validUserName);
-    await addPassword(invalidPassword);
+    await addPasswordByUserIndex(invalidPassword, 0);
 
     const invalidUserMessage = screen.getByText(
       /Password must be at least 6 characters long/i
     );
     await waitFor(() => expect(invalidUserMessage));
+
     const nextButton = await getNextButton();
     await waitFor(() => expect(nextButton).toBeDisabled());
   });
 
   test('user groups can be added and removed', async () => {
     await renderCreateMode();
-    await goToRegistrationStep();
-    await clickRegisterLater();
+    await selectGuestImageTarget();
     await goToUsersStep();
     await clickAddUser();
-    await addUserGroup('users');
-    await addUserGroup('widget');
+    await addUserGroupByUserIndex('users', 0);
+    await addUserGroupByUserIndex('widget', 0);
     await removeUserGroup('users');
     expect(screen.queryByText('users')).not.toBeInTheDocument();
   });
 
   test('adding wheel group checks Administrator checkbox', async () => {
     await renderCreateMode();
-    await goToRegistrationStep();
-    await clickRegisterLater();
+    await selectGuestImageTarget();
     await goToUsersStep();
     await clickAddUser();
     const adminCheckbox = await getAdminCheckbox();
 
     // Adding wheel group via groups input
-    await addUserGroup('wheel');
+    await addUserGroupByUserIndex('wheel', 0);
     expect(adminCheckbox).toBeChecked();
 
     await removeUserGroup('wheel');
@@ -279,38 +322,76 @@ describe('Step Users', () => {
     expect(adminCheckbox).not.toBeChecked();
     expect(screen.queryByText('wheel')).not.toBeInTheDocument();
   });
+
+  test('one valid and one invalid user', async () => {
+    await renderCreateMode();
+    await selectGuestImageTarget();
+    await goToUsersStep();
+    await clickAddUser();
+    await addUserName(validUserName);
+
+    const nextButton = await getNextButton();
+    expect(nextButton).toBeEnabled();
+
+    await addAnotherUser();
+    await switchToNewUser();
+
+    await addUserName('s');
+    expect(nextButton).toBeDisabled();
+
+    // remove invalid user and expect Next to get enabled
+    await closeNthTab(1);
+    expect(nextButton).toBeEnabled();
+  });
+
+  test('revisit step button on Review works', async () => {
+    await renderCreateMode();
+    await selectGuestImageTarget();
+    await goToUsersStep();
+    await clickAddUser();
+    await addUserName(validUserName);
+    await goToReviewStep();
+    await clickRevisitButton();
+    await screen.findByRole('heading', { name: /Users/ });
+  });
 });
 
 describe('User request generated correctly', () => {
-  test('create image with valid name, password, ssh key and checked Administrator checkbox', async () => {
+  beforeEach(() => {
+    vi.clearAllMocks();
+  });
+
+  test('add a user', async () => {
     await renderCreateMode();
-    await goToRegistrationStep();
-    await clickRegisterLater();
+    await selectGuestImageTarget();
     await goToUsersStep();
     await clickAddUser();
     await addUserName(validUserName);
     await addSshKey(validSshKey);
-    await addPassword(validPassword);
-    const nextButton = await getNextButton();
-    await waitFor(() => expect(nextButton).toBeEnabled());
+    await addPasswordByUserIndex(validPassword, 0);
     await checkAdminCheckbox();
-    await addUserGroup('users');
-    await addUserGroup('widget');
+    await addUserGroupByUserIndex('users', 0);
+    await addUserGroupByUserIndex('widget', 0);
     await goToReviewStep();
+    // informational modal pops up in the first test only as it's tied
+    // to a 'imageBuilder.saveAndBuildModalSeen' variable in localStorage
+    await openAndDismissSaveAndBuildModal();
     const receivedRequest = await interceptBlueprintRequest(CREATE_BLUEPRINT);
+
     const expectedRequest = {
       ...blueprintRequest,
       customizations: {
         users: [
           {
-            name: 'best',
-            ssh_key: 'ssh-rsa d',
+            name: validUserName,
+            ssh_key: validSshKey,
             password: validPassword,
             groups: ['wheel', 'users', 'widget'],
           },
         ],
       },
     };
+
     await waitFor(() => {
       expect(receivedRequest).toEqual(expectedRequest);
     });
@@ -318,22 +399,84 @@ describe('User request generated correctly', () => {
 
   test('remove a user', async () => {
     await renderCreateMode();
-    await goToRegistrationStep();
-    await clickRegisterLater();
+    await selectGuestImageTarget();
     await goToUsersStep();
     await clickAddUser();
     await addUserName('test');
     await addSshKey('ssh-rsa');
-    await addUserGroup('users');
-    await addUserGroup('widget');
-    await clickRemoveUser();
-    await waitFor(() => expect('add a user to your image'));
+    await addUserGroupByUserIndex('users', 0);
+    await addUserGroupByUserIndex('widget', 0);
+    await closeNthTab(0);
+    await screen.findByText(/add a user to your image/i);
+    await goToReviewStep();
+    const receivedRequest = await interceptBlueprintRequest(CREATE_BLUEPRINT);
+
+    const expectedRequest = {
+      ...blueprintRequest,
+      customizations: {},
+    };
+
+    await waitFor(() => {
+      expect(receivedRequest).toEqual(expectedRequest);
+    });
+  });
+
+  test('add multiple users', async () => {
+    await renderCreateMode();
+    await selectGuestImageTarget();
+    await goToUsersStep();
+
+    await addAndFillThreeUsers();
+
+    await goToReviewStep();
+    const receivedRequest = await interceptBlueprintRequest(CREATE_BLUEPRINT);
+
+    const expectedRequest = {
+      ...blueprintRequest,
+      customizations: {
+        users: [
+          {
+            name: 'rachel',
+            ssh_key: 'ssh-rsa rachel',
+            password: rachelPasswd,
+            groups: ['wheel', 'users', 'widget'],
+          },
+          {
+            name: 'monica',
+          },
+          {
+            name: 'chandler',
+            ssh_key: 'ssh-rsa chandler',
+            password: chandlerPasswd,
+            groups: ['group'],
+          },
+        ],
+      },
+    };
+
+    await waitFor(() => {
+      expect(receivedRequest).toEqual(expectedRequest);
+    });
+  });
+
+  test('remove multiple users', async () => {
+    await renderCreateMode();
+    await selectGuestImageTarget();
+    await goToUsersStep();
+
+    await addAndFillThreeUsers();
+    await closeNthTab(2);
+    await closeNthTab(1);
+    await closeNthTab(0);
+
     await goToReviewStep();
     const receivedRequest = await interceptBlueprintRequest(CREATE_BLUEPRINT);
+
     const expectedRequest = {
       ...blueprintRequest,
       customizations: {},
     };
+
     await waitFor(() => {
       expect(receivedRequest).toEqual(expectedRequest);
     });
@@ -353,6 +496,7 @@ describe('Users edit mode', () => {
     const receivedRequest = await interceptEditBlueprintRequest(
       `${EDIT_BLUEPRINT}/${id}`
     );
-    expect(receivedRequest).toEqual(usersCreateBlueprintRequest);
+    const expectedRequest = usersCreateBlueprintRequest;
+    expect(receivedRequest).toEqual(expectedRequest);
   });
 });


Reviews:
user: login: graphite-app[bot]
state: COMMENTED
author_association: NONE
submitted_at: 2025-04-04T13:16:25Z
user: login: graphite-app[bot]
state: COMMENTED
author_association: NONE
submitted_at: 2025-04-04T13:17:46Z
user: login: regexowl
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-04-04T13:21:39Z
user: login: ksiekl
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-04-08T12:31:44Z
user: login: regexowl
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-04-10T07:54:00Z
user: login: regexowl
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-04-10T07:54:26Z
user: login: avitova
body: 1) If I create a user, there is immediately  "Invalid password" in errors structure. I do not know if we mind, since it can be empty, and there is different behaviour for ssh keys. I probably would fix it.

2) If you create a user, add ssh key that is invalid, remove a different user, the ssh key error disappears.

I ran into weird bugs, but could not reproduce it, so I guess that's it for now. :eyes: 
state: DISMISSED
author_association: COLLABORATOR
submitted_at: 2025-04-10T14:35:11Z
user: login: regexowl
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-04-11T08:20:53Z
user: login: regexowl
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-04-11T08:47:44Z
user: login: regexowl
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-04-11T08:47:58Z
user: login: ksiekl
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-04-11T10:01:59Z
user: login: avitova
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-04-16T12:32:43Z
user: login: regexowl
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-04-16T13:33:20Z
user: login: lucasgarfield
body: Approving and merging since @avitova acked it earlier.
state: APPROVED
author_association: COLLABORATOR
submitted_at: 2025-04-18T21:45:53Z

Review comments:
diff_hunk: @@ -469,70 +467,68 @@ export function useServicesValidation(): StepValidation {
   };
 }
 
-const getUserNameErrorMsg = (userName: string): string => {
+const validateUserName = (userName: string): string => {
+  if (!userName) {
+    return 'Required value';
+  }
   if (userName && !isUserNameValid(userName)) {
     return 'Invalid user name';
   }
   return '';
path: src/Components/CreateImageWizard/utilities/useValidation.tsx
user: login: graphite-app[bot]
body: There's a logical issue in the `validateUserName` function. The function first checks if `userName` is falsy and returns 'Required value', but then has a redundant check with `userName && !isUserNameValid(userName)`. Since you've already verified `userName` exists in the first condition, the second condition can be simplified to just `!isUserNameValid(userName)`. This would make the validation more straightforward and avoid potential bugs where an empty string might bypass the validity check.

```javascript
const validateUserName = (userName: string): string => {
  if (!userName) {
    return 'Required value';
  }
  if (!isUserNameValid(userName)) {
    return 'Invalid user name';
  }
  return '';
};
```
```suggestion
const validateUserName = (userName: string): string => {
  if (!userName) {
    return 'Required value';
  }
  if (!isUserNameValid(userName)) {
    return 'Invalid user name';
  }
  return '';
}
```
  

*Spotted by [Diamond](https://app.graphite.dev/diamond/?org=osbuild&ref=ai-review-comment)*<i class='graphite__hidden'><br /><br />Is this helpful? React 👍 or 👎 to let us know.</i>
created_at: 2025-04-04T13:16:23Z
author_association: NONE
diff_hunk: @@ -279,61 +322,161 @@ describe('Step Users', () => {
     expect(adminCheckbox).not.toBeChecked();
     expect(screen.queryByText('wheel')).not.toBeInTheDocument();
   });
+
+  test('one valid and one invalid user', async () => {
+    await renderCreateMode();
+    await selectGuestImageTarget();
+    await goToUsersStep();
+    await clickAddUser();
+    await addUserName(validUserName);
+
+    const nextButton = await getNextButton();
+    expect(nextButton).toBeEnabled();
+
+    await addAnotherUser();
+    await switchToNewUser();
+
+    await addUserName('s');
+    expect(nextButton).toBeDisabled();
+
+    // remove invalid user and expect Next to get enabled
+    await closeNthTab(1);
+    expect(nextButton).toBeEnabled();
+  });
+
+  test('revisit step button on Review works', async () => {
+    await renderCreateMode();
+    await selectGuestImageTarget();
+    await goToUsersStep();
+    await clickAddUser();
+    await addUserName(validUserName);
+    await goToReviewStep();
+    await clickRevisitButton();
+    await screen.findByRole('heading', { name: /Users/ });
+  });
 });
 
 describe('User request generated correctly', () => {
-  test('create image with valid name, password, ssh key and checked Administrator checkbox', async () => {
+  beforeEach(() => {
+    vi.clearAllMocks();
+  });
+
+  test('add a user', async () => {
     await renderCreateMode();
-    await goToRegistrationStep();
-    await clickRegisterLater();
+    await selectGuestImageTarget();
     await goToUsersStep();
     await clickAddUser();
     await addUserName(validUserName);
     await addSshKey(validSshKey);
-    await addPassword(validPassword);
-    const nextButton = await getNextButton();
-    await waitFor(() => expect(nextButton).toBeEnabled());
+    await addPasswordByUserIndex(validPassword, 0);
     await checkAdminCheckbox();
-    await addUserGroup('users');
-    await addUserGroup('widget');
+    await addUserGroupByUserIndex('users', 0);
+    await addUserGroupByUserIndex('widget', 0);
     await goToReviewStep();
+    // informational modal pops up in the first test only as it's tied
+    // to a 'imageBuilder.saveAndBuildModalSeen' variable in localStorage
+    await openAndDismissSaveAndBuildModal();
     const receivedRequest = await interceptBlueprintRequest(CREATE_BLUEPRINT);
+
     const expectedRequest = {
       ...blueprintRequest,
       customizations: {
         users: [
           {
-            name: 'best',
-            ssh_key: 'ssh-rsa d',
+            name: validUserName,
+            ssh_key: validSshKey,
             password: validPassword,
             groups: ['wheel', 'users', 'widget'],
           },
         ],
       },
     };
+
     await waitFor(() => {
       expect(receivedRequest).toEqual(expectedRequest);
     });
   });
 
   test('remove a user', async () => {
     await renderCreateMode();
-    await goToRegistrationStep();
-    await clickRegisterLater();
+    await selectGuestImageTarget();
     await goToUsersStep();
     await clickAddUser();
     await addUserName('test');
     await addSshKey('ssh-rsa');
-    await addUserGroup('users');
-    await addUserGroup('widget');
-    await clickRemoveUser();
-    await waitFor(() => expect('add a user to your image'));
+    await addUserGroupByUserIndex('users', 0);
+    await addUserGroupByUserIndex('widget', 0);
+    await closeNthTab(0);
+    await waitFor(() => expect(screen.getByText(/add a user to your image/i)));
path: src/test/Components/CreateImageWizard/steps/Users/Users.test.tsx
user: login: graphite-app[bot]
body: The assertion here doesn't actually test anything - `expect()` is being called with a string directly without any matcher. To properly verify the text is displayed, this should be:

```javascript
expect(screen.getByText(/add a user to your image/i)).toBeInTheDocument();
```

This ensures the test is actually checking for the presence of the element with the specified text.
```suggestion
    await waitFor(() => expect(screen.getByText(/add a user to your image/i)).toBeInTheDocument());
```
  

*Spotted by [Diamond](https://app.graphite.dev/diamond/?org=osbuild&ref=ai-review-comment)*<i class='graphite__hidden'><br /><br />Is this helpful? React 👍 or 👎 to let us know.</i>
created_at: 2025-04-04T13:16:24Z
author_association: NONE
diff_hunk: @@ -0,0 +1,72 @@
+import React from 'react';
+
+import { Button, Modal } from '@patternfly/react-core';
+
+import { useAppDispatch, useAppSelector } from '../../../../../store/hooks';
+import { removeUser, selectUsers } from '../../../../../store/wizardSlice';
+
+type RemoveUserModalProps = {
+  setShowRemoveUserModal: React.Dispatch<React.SetStateAction<boolean>>;
+  activeTabKey: number;
+  setActiveTabKey: React.Dispatch<React.SetStateAction<number>>;
+  tabIndex: number;
+  setIndex: React.Dispatch<React.SetStateAction<number>>;
+  isOpen: boolean;
+};
+
+const RemoveUserModal = ({
+  setShowRemoveUserModal,
+  activeTabKey,
+  setActiveTabKey,
+  tabIndex,
+  setIndex,
+  isOpen,
+}: RemoveUserModalProps) => {
+  const dispatch = useAppDispatch();
+  const users = useAppSelector(selectUsers);
+
+  const onClose = () => {
+    setShowRemoveUserModal(!isOpen);
+  };
+
+  const onConfirm = () => {
+    const tabIndexNum = tabIndex;
+    let nextTabIndex = activeTabKey;
+
+    if (tabIndexNum < activeTabKey) {
+      // if a preceding tab is closing, keep focus on the new index of the current tab
+      nextTabIndex = activeTabKey - 1 > 0 ? activeTabKey - 1 : 0;
+    } else if (activeTabKey === users.length - 1) {
+      // if the closing tab is the last tab, focus the preceding tab
+      nextTabIndex = users.length - 2 > 0 ? users.length - 2 : 0;
path: src/Components/CreateImageWizard/steps/Users/components/RemoveUserModal.tsx
user: login: graphite-app[bot]
body: There's a potential issue with the tab focus logic when removing the last tab. When `users.length === 1`, the calculation `users.length - 2` would result in `-1`, which is an invalid index. 

Consider modifying the condition to:
```js
nextTabIndex = users.length - 2 >= 0 ? users.length - 2 : 0;
```

This ensures that when removing the only remaining user tab, the focus will properly default to index 0 rather than potentially trying to access an invalid negative index.
```suggestion
    } else if (activeTabKey === users.length - 1) {
      // if the closing tab is the last tab, focus the preceding tab
      nextTabIndex = users.length - 2 >= 0 ? users.length - 2 : 0;
```
  

*Spotted by [Diamond](https://app.graphite.dev/diamond/?org=osbuild&ref=ai-review-comment)*<i class='graphite__hidden'><br /><br />Is this helpful? React 👍 or 👎 to let us know.</i>
created_at: 2025-04-04T13:17:45Z
author_association: NONE
diff_hunk: @@ -0,0 +1,72 @@
+import React from 'react';
+
+import { Button, Modal } from '@patternfly/react-core';
+
+import { useAppDispatch, useAppSelector } from '../../../../../store/hooks';
+import { removeUser, selectUsers } from '../../../../../store/wizardSlice';
+
+type RemoveUserModalProps = {
+  setShowRemoveUserModal: React.Dispatch<React.SetStateAction<boolean>>;
+  activeTabKey: number;
+  setActiveTabKey: React.Dispatch<React.SetStateAction<number>>;
+  tabIndex: number;
+  setIndex: React.Dispatch<React.SetStateAction<number>>;
+  isOpen: boolean;
+};
+
+const RemoveUserModal = ({
+  setShowRemoveUserModal,
+  activeTabKey,
+  setActiveTabKey,
+  tabIndex,
+  setIndex,
+  isOpen,
+}: RemoveUserModalProps) => {
+  const dispatch = useAppDispatch();
+  const users = useAppSelector(selectUsers);
+
+  const onClose = () => {
+    setShowRemoveUserModal(!isOpen);
+  };
+
+  const onConfirm = () => {
+    const tabIndexNum = tabIndex;
+    let nextTabIndex = activeTabKey;
+
+    if (tabIndexNum < activeTabKey) {
+      // if a preceding tab is closing, keep focus on the new index of the current tab
+      nextTabIndex = activeTabKey - 1 > 0 ? activeTabKey - 1 : 0;
+    } else if (activeTabKey === users.length - 1) {
+      // if the closing tab is the last tab, focus the preceding tab
+      nextTabIndex = users.length - 2 > 0 ? users.length - 2 : 0;
path: src/Components/CreateImageWizard/steps/Users/components/RemoveUserModal.tsx
user: login: regexowl
body: Look at you go, reviewing Patternfly example code :fly: 
created_at: 2025-04-04T13:21:39Z
author_association: COLLABORATOR
diff_hunk: @@ -71,88 +86,111 @@ const UserInfo = () => {
     );
   };
 
-  const onRemoveUserClick = () => {
-    dispatch(removeUser(index));
+  const getValidationByIndex = (index: number) => {
+    return {
+      errors: {
+        userName: stepValidation?.errors[index]?.userName,
+        userSshKey: stepValidation?.errors[index]?.userSshKey,
+      },
+      disabledNext: stepValidation.disabledNext,
+    };
   };
 
   return (
     <>
-      <FormGroup isRequired label="Username">
-        <ValidatedInputAndTextArea
-          ariaLabel="blueprint user name"
-          value={userName || ''}
-          placeholder="Enter username"
-          onChange={(_e, value) => handleNameChange(_e, value)}
-          stepValidation={stepValidation}
-          fieldName="userName"
-        />
-      </FormGroup>
-      <PasswordValidatedInput
-        value={userPassword || ''}
-        ariaLabel="blueprint user password"
-        placeholder="Enter password"
-        onChange={(_e, value) => handlePasswordChange(_e, value)}
+      <RemoveUserModal
+        setShowRemoveUserModal={setShowRemoveUserModal}
+        activeTabKey={activeTabKey}
+        setActiveTabKey={setActiveTabKey}
+        tabIndex={tabIndex}
+        setIndex={setIndex}
+        isOpen={showRemoveUserModal}
       />
-      <FormGroup isRequired label="SSH key">
-        <ValidatedInputAndTextArea
-          inputType={'textArea'}
-          ariaLabel="public SSH key"
-          value={userSshKey || ''}
-          type={'text'}
-          onChange={(_e, value) => handleSshKeyChange(_e, value)}
-          placeholder="Paste your public SSH key"
-          stepValidation={stepValidation}
-          fieldName="userSshKey"
-        />
-        <Button
-          component="a"
-          target="_blank"
-          variant="link"
-          icon={<ExternalLinkAltIcon />}
-          iconPosition="right"
-          href={GENERATING_SSH_KEY_PAIRS_URL}
-          className="pf-v5-u-pl-0"
-        >
-          Learn more about SSH keys
-        </Button>
-      </FormGroup>
-      <FormGroup>
-        <Checkbox
-          label="Administrator"
-          isChecked={userIsAdministrator || userGroups.includes('wheel')}
-          onChange={(_e, value) => handleCheckboxChange(_e, value)}
-          aria-label="Administrator"
-          id="user Administrator"
-          name="user Administrator"
-        />
-      </FormGroup>
-      <FormGroup label="Groups">
-        <LabelInput
-          ariaLabel="Add user group"
-          placeholder="Add user group"
-          validator={isUserGroupValid}
-          list={userGroups}
-          item="Group"
-          addAction={(value) =>
-            addUserGroupByIndex({ index: index, group: value })
-          }
-          removeAction={(value) =>
-            removeUserGroupByIndex({ index: index, group: value })
-          }
-          stepValidation={stepValidation}
-          fieldName="groups"
-        />
-      </FormGroup>
-      <Tooltip position="top-start" content={'Remove user'}>
-        <FormGroup>
-          <Button
-            aria-label="remove user"
-            onClick={onRemoveUserClick}
-            variant="tertiary"
-            icon={<TrashIcon />}
-          ></Button>
-        </FormGroup>
-      </Tooltip>
+      <Tabs
+        aria-label="Users tabs"
+        activeKey={activeTabKey}
+        onSelect={onSelect}
+        onAdd={onAdd}
+        onClose={onClose}
+      >
+        {users.map((user, index) => (
+          <Tab
+            aria-label={`User ${user.name} tab`}
+            key={user.name}
+            eventKey={index}
+            title={<TabTitleText>{user.name || 'New user'}</TabTitleText>}
path: src/Components/CreateImageWizard/steps/Users/components/UserInfo.tsx
user: login: ksiekl
body: Noticed some funky behavior with mingling of the tabs, if I open 2 new users, and try to edit the first one, a new tab is added. I think maybe its connected to the focus of the tabs? But they also don't disappear when deleted, so it might be more complex ... :thinking: 
![user-tabs](https://github.com/user-attachments/assets/d04f3503-d361-4a7e-82a8-51d43585f160)



created_at: 2025-04-08T11:06:04Z
author_association: COLLABORATOR
diff_hunk: @@ -71,88 +86,111 @@ const UserInfo = () => {
     );
   };
 
-  const onRemoveUserClick = () => {
-    dispatch(removeUser(index));
+  const getValidationByIndex = (index: number) => {
+    return {
+      errors: {
+        userName: stepValidation?.errors[index]?.userName,
+        userSshKey: stepValidation?.errors[index]?.userSshKey,
+      },
+      disabledNext: stepValidation.disabledNext,
+    };
   };
 
   return (
     <>
-      <FormGroup isRequired label="Username">
-        <ValidatedInputAndTextArea
-          ariaLabel="blueprint user name"
-          value={userName || ''}
-          placeholder="Enter username"
-          onChange={(_e, value) => handleNameChange(_e, value)}
-          stepValidation={stepValidation}
-          fieldName="userName"
-        />
-      </FormGroup>
-      <PasswordValidatedInput
-        value={userPassword || ''}
-        ariaLabel="blueprint user password"
-        placeholder="Enter password"
-        onChange={(_e, value) => handlePasswordChange(_e, value)}
+      <RemoveUserModal
+        setShowRemoveUserModal={setShowRemoveUserModal}
+        activeTabKey={activeTabKey}
+        setActiveTabKey={setActiveTabKey}
+        tabIndex={tabIndex}
+        setIndex={setIndex}
+        isOpen={showRemoveUserModal}
       />
-      <FormGroup isRequired label="SSH key">
-        <ValidatedInputAndTextArea
-          inputType={'textArea'}
-          ariaLabel="public SSH key"
-          value={userSshKey || ''}
-          type={'text'}
-          onChange={(_e, value) => handleSshKeyChange(_e, value)}
-          placeholder="Paste your public SSH key"
-          stepValidation={stepValidation}
-          fieldName="userSshKey"
-        />
-        <Button
-          component="a"
-          target="_blank"
-          variant="link"
-          icon={<ExternalLinkAltIcon />}
-          iconPosition="right"
-          href={GENERATING_SSH_KEY_PAIRS_URL}
-          className="pf-v5-u-pl-0"
-        >
-          Learn more about SSH keys
-        </Button>
-      </FormGroup>
-      <FormGroup>
-        <Checkbox
-          label="Administrator"
-          isChecked={userIsAdministrator || userGroups.includes('wheel')}
-          onChange={(_e, value) => handleCheckboxChange(_e, value)}
-          aria-label="Administrator"
-          id="user Administrator"
-          name="user Administrator"
-        />
-      </FormGroup>
-      <FormGroup label="Groups">
-        <LabelInput
-          ariaLabel="Add user group"
-          placeholder="Add user group"
-          validator={isUserGroupValid}
-          list={userGroups}
-          item="Group"
-          addAction={(value) =>
-            addUserGroupByIndex({ index: index, group: value })
-          }
-          removeAction={(value) =>
-            removeUserGroupByIndex({ index: index, group: value })
-          }
-          stepValidation={stepValidation}
-          fieldName="groups"
-        />
-      </FormGroup>
-      <Tooltip position="top-start" content={'Remove user'}>
-        <FormGroup>
-          <Button
-            aria-label="remove user"
-            onClick={onRemoveUserClick}
-            variant="tertiary"
-            icon={<TrashIcon />}
-          ></Button>
-        </FormGroup>
-      </Tooltip>
+      <Tabs
+        aria-label="Users tabs"
+        activeKey={activeTabKey}
+        onSelect={onSelect}
+        onAdd={onAdd}
+        onClose={onClose}
+      >
+        {users.map((user, index) => (
+          <Tab
+            aria-label={`User ${user.name} tab`}
+            key={user.name}
+            eventKey={index}
+            title={<TabTitleText>{user.name || 'New user'}</TabTitleText>}
+          >
+            <FormGroup isRequired label="Username" className="pf-v5-u-pb-md">
+              <ValidatedInputAndTextArea
+                ariaLabel="blueprint user name"
+                value={user.name || ''}
+                placeholder="Enter username"
+                onChange={(_e, value) => handleNameChange(_e, value)}
+                stepValidation={getValidationByIndex(index)}
+                fieldName="userName"
+              />
+            </FormGroup>
+            <PasswordValidatedInput
+              value={user.password || ''}
+              ariaLabel="blueprint user password"
+              placeholder="Enter password"
+              onChange={(_e, value) => handlePasswordChange(_e, value)}
+            />
+            <FormGroup label="SSH key" className="pf-v5-u-pb-md">
+              <ValidatedInputAndTextArea
+                inputType={'textArea'}
+                ariaLabel="public SSH key"
+                value={user.ssh_key || ''}
+                type={'text'}
+                onChange={(_e, value) => handleSshKeyChange(_e, value)}
+                placeholder="Paste your public SSH key"
+                stepValidation={getValidationByIndex(index)}
+                fieldName="userSshKey"
+              />
+              <Button
+                component="a"
+                target="_blank"
+                variant="link"
+                icon={<ExternalLinkAltIcon />}
+                iconPosition="right"
+                href={GENERATING_SSH_KEY_PAIRS_URL}
+                className="pf-v5-u-pl-0"
+              >
+                Learn more about SSH keys
+              </Button>
+            </FormGroup>
+            <FormGroup className="pf-v5-u-pb-md">
+              <Checkbox
path: src/Components/CreateImageWizard/steps/Users/components/UserInfo.tsx
user: login: ksiekl
body: When I create multiple users, give them names, I can click on "Administrator" label (_not_ the checkbox), and the wheels just keep on rolling: 

![wheel](https://github.com/user-attachments/assets/80c70062-e8b7-4d1d-a349-f5c0318b4410)


created_at: 2025-04-08T12:20:15Z
author_association: COLLABORATOR
diff_hunk: @@ -71,88 +86,111 @@ const UserInfo = () => {
     );
   };
 
-  const onRemoveUserClick = () => {
-    dispatch(removeUser(index));
+  const getValidationByIndex = (index: number) => {
+    return {
+      errors: {
+        userName: stepValidation?.errors[index]?.userName,
+        userSshKey: stepValidation?.errors[index]?.userSshKey,
+      },
+      disabledNext: stepValidation.disabledNext,
+    };
   };
 
   return (
     <>
-      <FormGroup isRequired label="Username">
-        <ValidatedInputAndTextArea
-          ariaLabel="blueprint user name"
-          value={userName || ''}
-          placeholder="Enter username"
-          onChange={(_e, value) => handleNameChange(_e, value)}
-          stepValidation={stepValidation}
-          fieldName="userName"
-        />
-      </FormGroup>
-      <PasswordValidatedInput
-        value={userPassword || ''}
-        ariaLabel="blueprint user password"
-        placeholder="Enter password"
-        onChange={(_e, value) => handlePasswordChange(_e, value)}
+      <RemoveUserModal
+        setShowRemoveUserModal={setShowRemoveUserModal}
+        activeTabKey={activeTabKey}
+        setActiveTabKey={setActiveTabKey}
+        tabIndex={tabIndex}
+        setIndex={setIndex}
+        isOpen={showRemoveUserModal}
       />
-      <FormGroup isRequired label="SSH key">
-        <ValidatedInputAndTextArea
-          inputType={'textArea'}
-          ariaLabel="public SSH key"
-          value={userSshKey || ''}
-          type={'text'}
-          onChange={(_e, value) => handleSshKeyChange(_e, value)}
-          placeholder="Paste your public SSH key"
-          stepValidation={stepValidation}
-          fieldName="userSshKey"
-        />
-        <Button
-          component="a"
-          target="_blank"
-          variant="link"
-          icon={<ExternalLinkAltIcon />}
-          iconPosition="right"
-          href={GENERATING_SSH_KEY_PAIRS_URL}
-          className="pf-v5-u-pl-0"
-        >
-          Learn more about SSH keys
-        </Button>
-      </FormGroup>
-      <FormGroup>
-        <Checkbox
-          label="Administrator"
-          isChecked={userIsAdministrator || userGroups.includes('wheel')}
-          onChange={(_e, value) => handleCheckboxChange(_e, value)}
-          aria-label="Administrator"
-          id="user Administrator"
-          name="user Administrator"
-        />
-      </FormGroup>
-      <FormGroup label="Groups">
-        <LabelInput
-          ariaLabel="Add user group"
-          placeholder="Add user group"
-          validator={isUserGroupValid}
-          list={userGroups}
-          item="Group"
-          addAction={(value) =>
-            addUserGroupByIndex({ index: index, group: value })
-          }
-          removeAction={(value) =>
-            removeUserGroupByIndex({ index: index, group: value })
-          }
-          stepValidation={stepValidation}
-          fieldName="groups"
-        />
-      </FormGroup>
-      <Tooltip position="top-start" content={'Remove user'}>
-        <FormGroup>
-          <Button
-            aria-label="remove user"
-            onClick={onRemoveUserClick}
-            variant="tertiary"
-            icon={<TrashIcon />}
-          ></Button>
-        </FormGroup>
-      </Tooltip>
+      <Tabs
+        aria-label="Users tabs"
+        activeKey={activeTabKey}
+        onSelect={onSelect}
+        onAdd={onAdd}
+        onClose={onClose}
+      >
+        {users.map((user, index) => (
+          <Tab
+            aria-label={`User ${user.name} tab`}
+            key={user.name}
+            eventKey={index}
+            title={<TabTitleText>{user.name || 'New user'}</TabTitleText>}
+          >
+            <FormGroup isRequired label="Username" className="pf-v5-u-pb-md">
+              <ValidatedInputAndTextArea
+                ariaLabel="blueprint user name"
+                value={user.name || ''}
+                placeholder="Enter username"
+                onChange={(_e, value) => handleNameChange(_e, value)}
+                stepValidation={getValidationByIndex(index)}
+                fieldName="userName"
+              />
+            </FormGroup>
+            <PasswordValidatedInput
+              value={user.password || ''}
+              ariaLabel="blueprint user password"
+              placeholder="Enter password"
+              onChange={(_e, value) => handlePasswordChange(_e, value)}
+            />
+            <FormGroup label="SSH key" className="pf-v5-u-pb-md">
+              <ValidatedInputAndTextArea
+                inputType={'textArea'}
+                ariaLabel="public SSH key"
+                value={user.ssh_key || ''}
+                type={'text'}
+                onChange={(_e, value) => handleSshKeyChange(_e, value)}
+                placeholder="Paste your public SSH key"
+                stepValidation={getValidationByIndex(index)}
+                fieldName="userSshKey"
+              />
+              <Button
+                component="a"
+                target="_blank"
+                variant="link"
+                icon={<ExternalLinkAltIcon />}
+                iconPosition="right"
+                href={GENERATING_SSH_KEY_PAIRS_URL}
+                className="pf-v5-u-pl-0"
+              >
+                Learn more about SSH keys
+              </Button>
+            </FormGroup>
+            <FormGroup className="pf-v5-u-pb-md">
+              <Checkbox
path: src/Components/CreateImageWizard/steps/Users/components/UserInfo.tsx
user: login: regexowl
body: Great catch, should be fixed now
created_at: 2025-04-10T07:53:59Z
author_association: COLLABORATOR
diff_hunk: @@ -71,88 +86,111 @@ const UserInfo = () => {
     );
   };
 
-  const onRemoveUserClick = () => {
-    dispatch(removeUser(index));
+  const getValidationByIndex = (index: number) => {
+    return {
+      errors: {
+        userName: stepValidation?.errors[index]?.userName,
+        userSshKey: stepValidation?.errors[index]?.userSshKey,
+      },
+      disabledNext: stepValidation.disabledNext,
+    };
   };
 
   return (
     <>
-      <FormGroup isRequired label="Username">
-        <ValidatedInputAndTextArea
-          ariaLabel="blueprint user name"
-          value={userName || ''}
-          placeholder="Enter username"
-          onChange={(_e, value) => handleNameChange(_e, value)}
-          stepValidation={stepValidation}
-          fieldName="userName"
-        />
-      </FormGroup>
-      <PasswordValidatedInput
-        value={userPassword || ''}
-        ariaLabel="blueprint user password"
-        placeholder="Enter password"
-        onChange={(_e, value) => handlePasswordChange(_e, value)}
+      <RemoveUserModal
+        setShowRemoveUserModal={setShowRemoveUserModal}
+        activeTabKey={activeTabKey}
+        setActiveTabKey={setActiveTabKey}
+        tabIndex={tabIndex}
+        setIndex={setIndex}
+        isOpen={showRemoveUserModal}
       />
-      <FormGroup isRequired label="SSH key">
-        <ValidatedInputAndTextArea
-          inputType={'textArea'}
-          ariaLabel="public SSH key"
-          value={userSshKey || ''}
-          type={'text'}
-          onChange={(_e, value) => handleSshKeyChange(_e, value)}
-          placeholder="Paste your public SSH key"
-          stepValidation={stepValidation}
-          fieldName="userSshKey"
-        />
-        <Button
-          component="a"
-          target="_blank"
-          variant="link"
-          icon={<ExternalLinkAltIcon />}
-          iconPosition="right"
-          href={GENERATING_SSH_KEY_PAIRS_URL}
-          className="pf-v5-u-pl-0"
-        >
-          Learn more about SSH keys
-        </Button>
-      </FormGroup>
-      <FormGroup>
-        <Checkbox
-          label="Administrator"
-          isChecked={userIsAdministrator || userGroups.includes('wheel')}
-          onChange={(_e, value) => handleCheckboxChange(_e, value)}
-          aria-label="Administrator"
-          id="user Administrator"
-          name="user Administrator"
-        />
-      </FormGroup>
-      <FormGroup label="Groups">
-        <LabelInput
-          ariaLabel="Add user group"
-          placeholder="Add user group"
-          validator={isUserGroupValid}
-          list={userGroups}
-          item="Group"
-          addAction={(value) =>
-            addUserGroupByIndex({ index: index, group: value })
-          }
-          removeAction={(value) =>
-            removeUserGroupByIndex({ index: index, group: value })
-          }
-          stepValidation={stepValidation}
-          fieldName="groups"
-        />
-      </FormGroup>
-      <Tooltip position="top-start" content={'Remove user'}>
-        <FormGroup>
-          <Button
-            aria-label="remove user"
-            onClick={onRemoveUserClick}
-            variant="tertiary"
-            icon={<TrashIcon />}
-          ></Button>
-        </FormGroup>
-      </Tooltip>
+      <Tabs
+        aria-label="Users tabs"
+        activeKey={activeTabKey}
+        onSelect={onSelect}
+        onAdd={onAdd}
+        onClose={onClose}
+      >
+        {users.map((user, index) => (
+          <Tab
+            aria-label={`User ${user.name} tab`}
+            key={user.name}
+            eventKey={index}
+            title={<TabTitleText>{user.name || 'New user'}</TabTitleText>}
path: src/Components/CreateImageWizard/steps/Users/components/UserInfo.tsx
user: login: regexowl
body: I can't index :flushed:  thanks!
created_at: 2025-04-10T07:54:25Z
author_association: COLLABORATOR
path: src/Components/CreateImageWizard/steps/Users/components/UserInfo.tsx
user: login: avitova
body: Just an idea, but can we maybe highlight the tab somehow, in case there are errors that disable the Next button? I think it would help users to find out the culprit in case the Next button is disabled. I think we might do this in a followup even.
created_at: 2025-04-10T12:29:04Z
author_association: COLLABORATOR
diff_hunk: @@ -0,0 +1,72 @@
+import React from 'react';
+
+import { Button, Modal } from '@patternfly/react-core';
+
+import { useAppDispatch, useAppSelector } from '../../../../../store/hooks';
+import { removeUser, selectUsers } from '../../../../../store/wizardSlice';
+
+type RemoveUserModalProps = {
+  setShowRemoveUserModal: React.Dispatch<React.SetStateAction<boolean>>;
+  activeTabKey: number;
+  setActiveTabKey: React.Dispatch<React.SetStateAction<number>>;
+  tabIndex: number;
+  setIndex: React.Dispatch<React.SetStateAction<number>>;
+  isOpen: boolean;
+};
+
+const RemoveUserModal = ({
+  setShowRemoveUserModal,
+  activeTabKey,
+  setActiveTabKey,
+  tabIndex,
+  setIndex,
+  isOpen,
+}: RemoveUserModalProps) => {
+  const dispatch = useAppDispatch();
+  const users = useAppSelector(selectUsers);
+
+  const onClose = () => {
+    setShowRemoveUserModal(!isOpen);
+  };
+
+  const onConfirm = () => {
+    const tabIndexNum = tabIndex;
+    let nextTabIndex = activeTabKey;
+
+    if (tabIndexNum < activeTabKey) {
+      // if a preceding tab is closing, keep focus on the new index of the current tab
+      nextTabIndex = activeTabKey - 1 > 0 ? activeTabKey - 1 : 0;
+    } else if (activeTabKey === users.length - 1) {
+      // if the closing tab is the last tab, focus the preceding tab
+      nextTabIndex = users.length - 2 >= 0 ? users.length - 2 : 0;
+    }
+
+    setActiveTabKey(nextTabIndex);
+    setIndex(nextTabIndex);
+    dispatch(removeUser(tabIndex));
+    setShowRemoveUserModal(!isOpen);
+  };
+
+  return (
+    <Modal
+      title="Remove user?"
path: src/Components/CreateImageWizard/steps/Users/components/RemoveUserModal.tsx
user: login: avitova
body: I think we might want to add name of the user to be deleted here.
created_at: 2025-04-10T14:02:44Z
author_association: COLLABORATOR
diff_hunk: @@ -27,17 +32,29 @@ import { isUserGroupValid } from '../../../validators';
 
 const UserInfo = () => {
   const dispatch = useAppDispatch();
-  const index = 0;
-  const userNameSelector = selectUserNameByIndex(index);
-  const userName = useAppSelector(userNameSelector);
-  const userPasswordSelector = selectUserPasswordByIndex(index);
-  const userPassword = useAppSelector(userPasswordSelector);
-  const userSshKeySelector = selectUserSshKeyByIndex(index);
-  const userSshKey = useAppSelector(userSshKeySelector);
-  const userIsAdministratorSelector = selectUserAdministrator(index);
-  const userIsAdministrator = useAppSelector(userIsAdministratorSelector);
-  const userGroupsSelector = selectUserGroupsByIndex(index);
-  const userGroups = useAppSelector(userGroupsSelector);
+  const users = useAppSelector(selectUsers);
+  const stepValidation = useUsersValidation();
+
+  const [index, setIndex] = useState(0);
+  const [activeTabKey, setActiveTabKey] = useState(0);
+  const [tabIndex, setTabIndex] = useState(0);
+  const [showRemoveUserModal, setShowRemoveUserModal] = useState(false);
+
+  const onSelect = (event: React.MouseEvent, tabIndex: number) => {
+    setActiveTabKey(tabIndex);
+    setIndex(tabIndex);
+  };
+
+  const onAdd = () => {
+    setActiveTabKey(users.length);
+    setIndex(index + 1);
+    dispatch(addUser());
+  };
+
+  const onClose = (_event: React.MouseEvent, tabIndex: number) => {
+    setShowRemoveUserModal(true);
path: src/Components/CreateImageWizard/steps/Users/components/UserInfo.tsx
user: login: avitova
body: I was wondering. In case no values are on input - name, password, ssh key, and groups are empty - do we want to remove them without the dialog? Just a thought for improvement.
created_at: 2025-04-10T14:08:04Z
author_association: COLLABORATOR
path: src/Components/CreateImageWizard/steps/Users/components/UserInfo.tsx
user: login: regexowl
body: Yeah, that would be super useful, opened an issue to address this in a follow up https://github.com/osbuild/image-builder-frontend/issues/3097
created_at: 2025-04-11T08:20:53Z
author_association: COLLABORATOR
diff_hunk: @@ -0,0 +1,72 @@
+import React from 'react';
+
+import { Button, Modal } from '@patternfly/react-core';
+
+import { useAppDispatch, useAppSelector } from '../../../../../store/hooks';
+import { removeUser, selectUsers } from '../../../../../store/wizardSlice';
+
+type RemoveUserModalProps = {
+  setShowRemoveUserModal: React.Dispatch<React.SetStateAction<boolean>>;
+  activeTabKey: number;
+  setActiveTabKey: React.Dispatch<React.SetStateAction<number>>;
+  tabIndex: number;
+  setIndex: React.Dispatch<React.SetStateAction<number>>;
+  isOpen: boolean;
+};
+
+const RemoveUserModal = ({
+  setShowRemoveUserModal,
+  activeTabKey,
+  setActiveTabKey,
+  tabIndex,
+  setIndex,
+  isOpen,
+}: RemoveUserModalProps) => {
+  const dispatch = useAppDispatch();
+  const users = useAppSelector(selectUsers);
+
+  const onClose = () => {
+    setShowRemoveUserModal(!isOpen);
+  };
+
+  const onConfirm = () => {
+    const tabIndexNum = tabIndex;
+    let nextTabIndex = activeTabKey;
+
+    if (tabIndexNum < activeTabKey) {
+      // if a preceding tab is closing, keep focus on the new index of the current tab
+      nextTabIndex = activeTabKey - 1 > 0 ? activeTabKey - 1 : 0;
+    } else if (activeTabKey === users.length - 1) {
+      // if the closing tab is the last tab, focus the preceding tab
+      nextTabIndex = users.length - 2 >= 0 ? users.length - 2 : 0;
+    }
+
+    setActiveTabKey(nextTabIndex);
+    setIndex(nextTabIndex);
+    dispatch(removeUser(tabIndex));
+    setShowRemoveUserModal(!isOpen);
+  };
+
+  return (
+    <Modal
+      title="Remove user?"
path: src/Components/CreateImageWizard/steps/Users/components/RemoveUserModal.tsx
user: login: regexowl
body: Good point, added!
created_at: 2025-04-11T08:47:44Z
author_association: COLLABORATOR
diff_hunk: @@ -27,17 +32,29 @@ import { isUserGroupValid } from '../../../validators';
 
 const UserInfo = () => {
   const dispatch = useAppDispatch();
-  const index = 0;
-  const userNameSelector = selectUserNameByIndex(index);
-  const userName = useAppSelector(userNameSelector);
-  const userPasswordSelector = selectUserPasswordByIndex(index);
-  const userPassword = useAppSelector(userPasswordSelector);
-  const userSshKeySelector = selectUserSshKeyByIndex(index);
-  const userSshKey = useAppSelector(userSshKeySelector);
-  const userIsAdministratorSelector = selectUserAdministrator(index);
-  const userIsAdministrator = useAppSelector(userIsAdministratorSelector);
-  const userGroupsSelector = selectUserGroupsByIndex(index);
-  const userGroups = useAppSelector(userGroupsSelector);
+  const users = useAppSelector(selectUsers);
+  const stepValidation = useUsersValidation();
+
+  const [index, setIndex] = useState(0);
+  const [activeTabKey, setActiveTabKey] = useState(0);
+  const [tabIndex, setTabIndex] = useState(0);
+  const [showRemoveUserModal, setShowRemoveUserModal] = useState(false);
+
+  const onSelect = (event: React.MouseEvent, tabIndex: number) => {
+    setActiveTabKey(tabIndex);
+    setIndex(tabIndex);
+  };
+
+  const onAdd = () => {
+    setActiveTabKey(users.length);
+    setIndex(index + 1);
+    dispatch(addUser());
+  };
+
+  const onClose = (_event: React.MouseEvent, tabIndex: number) => {
+    setShowRemoveUserModal(true);
path: src/Components/CreateImageWizard/steps/Users/components/UserInfo.tsx
user: login: regexowl
body: Makes sense, updated :+1: 
created_at: 2025-04-11T08:47:58Z
author_association: COLLABORATOR
diff_hunk: @@ -71,88 +86,111 @@ const UserInfo = () => {
     );
   };
 
-  const onRemoveUserClick = () => {
-    dispatch(removeUser(index));
+  const getValidationByIndex = (index: number) => {
+    return {
+      errors: {
+        userName: stepValidation?.errors[index]?.userName,
+        userSshKey: stepValidation?.errors[index]?.userSshKey,
+      },
+      disabledNext: stepValidation.disabledNext,
+    };
   };
 
   return (
     <>
-      <FormGroup isRequired label="Username">
-        <ValidatedInputAndTextArea
-          ariaLabel="blueprint user name"
-          value={userName || ''}
-          placeholder="Enter username"
-          onChange={(_e, value) => handleNameChange(_e, value)}
-          stepValidation={stepValidation}
-          fieldName="userName"
-        />
-      </FormGroup>
-      <PasswordValidatedInput
-        value={userPassword || ''}
-        ariaLabel="blueprint user password"
-        placeholder="Enter password"
-        onChange={(_e, value) => handlePasswordChange(_e, value)}
+      <RemoveUserModal
+        setShowRemoveUserModal={setShowRemoveUserModal}
+        activeTabKey={activeTabKey}
+        setActiveTabKey={setActiveTabKey}
+        tabIndex={tabIndex}
+        setIndex={setIndex}
+        isOpen={showRemoveUserModal}
       />
-      <FormGroup isRequired label="SSH key">
-        <ValidatedInputAndTextArea
-          inputType={'textArea'}
-          ariaLabel="public SSH key"
-          value={userSshKey || ''}
-          type={'text'}
-          onChange={(_e, value) => handleSshKeyChange(_e, value)}
-          placeholder="Paste your public SSH key"
-          stepValidation={stepValidation}
-          fieldName="userSshKey"
-        />
-        <Button
-          component="a"
-          target="_blank"
-          variant="link"
-          icon={<ExternalLinkAltIcon />}
-          iconPosition="right"
-          href={GENERATING_SSH_KEY_PAIRS_URL}
-          className="pf-v5-u-pl-0"
-        >
-          Learn more about SSH keys
-        </Button>
-      </FormGroup>
-      <FormGroup>
-        <Checkbox
-          label="Administrator"
-          isChecked={userIsAdministrator || userGroups.includes('wheel')}
-          onChange={(_e, value) => handleCheckboxChange(_e, value)}
-          aria-label="Administrator"
-          id="user Administrator"
-          name="user Administrator"
-        />
-      </FormGroup>
-      <FormGroup label="Groups">
-        <LabelInput
-          ariaLabel="Add user group"
-          placeholder="Add user group"
-          validator={isUserGroupValid}
-          list={userGroups}
-          item="Group"
-          addAction={(value) =>
-            addUserGroupByIndex({ index: index, group: value })
-          }
-          removeAction={(value) =>
-            removeUserGroupByIndex({ index: index, group: value })
-          }
-          stepValidation={stepValidation}
-          fieldName="groups"
-        />
-      </FormGroup>
-      <Tooltip position="top-start" content={'Remove user'}>
-        <FormGroup>
-          <Button
-            aria-label="remove user"
-            onClick={onRemoveUserClick}
-            variant="tertiary"
-            icon={<TrashIcon />}
-          ></Button>
-        </FormGroup>
-      </Tooltip>
+      <Tabs
+        aria-label="Users tabs"
+        activeKey={activeTabKey}
+        onSelect={onSelect}
+        onAdd={onAdd}
+        onClose={onClose}
+      >
+        {users.map((user, index) => (
+          <Tab
+            aria-label={`User ${user.name} tab`}
+            key={user.name}
+            eventKey={index}
+            title={<TabTitleText>{user.name || 'New user'}</TabTitleText>}
path: src/Components/CreateImageWizard/steps/Users/components/UserInfo.tsx
user: login: ksiekl
body: 🧮 it's always a off-by-one problem :D  
created_at: 2025-04-11T10:01:59Z
author_association: COLLABORATOR
diff_hunk: @@ -27,17 +34,63 @@ import { isUserGroupValid } from '../../../validators';
 
 const UserInfo = () => {
   const dispatch = useAppDispatch();
-  const index = 0;
-  const userNameSelector = selectUserNameByIndex(index);
-  const userName = useAppSelector(userNameSelector);
-  const userPasswordSelector = selectUserPasswordByIndex(index);
-  const userPassword = useAppSelector(userPasswordSelector);
-  const userSshKeySelector = selectUserSshKeyByIndex(index);
-  const userSshKey = useAppSelector(userSshKeySelector);
-  const userIsAdministratorSelector = selectUserAdministrator(index);
-  const userIsAdministrator = useAppSelector(userIsAdministratorSelector);
-  const userGroupsSelector = selectUserGroupsByIndex(index);
-  const userGroups = useAppSelector(userGroupsSelector);
+  const users = useAppSelector(selectUsers);
+  const stepValidation = useUsersValidation();
+
+  const [index, setIndex] = useState(0);
+  const [activeTabKey, setActiveTabKey] = useState(0);
+  const [tabIndex, setTabIndex] = useState(0);
path: src/Components/CreateImageWizard/steps/Users/components/UserInfo.tsx
user: login: avitova
body: btw I think this is not used anywhere, you are using tabIndex, but that's a local variable, I can not see any here. 🤔 But we can bring this to a followup as well.
created_at: 2025-04-16T12:32:43Z
author_association: COLLABORATOR
diff_hunk: @@ -27,17 +34,63 @@ import { isUserGroupValid } from '../../../validators';
 
 const UserInfo = () => {
   const dispatch = useAppDispatch();
-  const index = 0;
-  const userNameSelector = selectUserNameByIndex(index);
-  const userName = useAppSelector(userNameSelector);
-  const userPasswordSelector = selectUserPasswordByIndex(index);
-  const userPassword = useAppSelector(userPasswordSelector);
-  const userSshKeySelector = selectUserSshKeyByIndex(index);
-  const userSshKey = useAppSelector(userSshKeySelector);
-  const userIsAdministratorSelector = selectUserAdministrator(index);
-  const userIsAdministrator = useAppSelector(userIsAdministratorSelector);
-  const userGroupsSelector = selectUserGroupsByIndex(index);
-  const userGroups = useAppSelector(userGroupsSelector);
+  const users = useAppSelector(selectUsers);
+  const stepValidation = useUsersValidation();
+
+  const [index, setIndex] = useState(0);
+  const [activeTabKey, setActiveTabKey] = useState(0);
+  const [tabIndex, setTabIndex] = useState(0);
path: src/Components/CreateImageWizard/steps/Users/components/UserInfo.tsx
user: login: regexowl
body: Ah, you mean the `tabIndex`? Might be a duplicate value to `index` actually... I'll add a commit. Thanks!
created_at: 2025-04-16T13:33:20Z
author_association: COLLABORATOR

PR comments:
user: login: regexowl
created_at: 2025-04-03T11:43:23Z
body: /jira-epic HMS-4181
author_association: COLLABORATOR
user: login: codecov[bot]
created_at: 2025-04-03T13:34:03Z
body: ## [Codecov](https://app.codecov.io/gh/osbuild/image-builder-frontend/pull/3071?dropdown=coverage&src=pr&el=h1&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=osbuild) Report
Attention: Patch coverage is `94.48819%` with `14 lines` in your changes missing coverage. Please review.
> Project coverage is 85.29%. Comparing base [(`f903d61`)](https://app.codecov.io/gh/osbuild/image-builder-frontend/commit/f903d617c022f344f87d5897590d512235090b82?dropdown=coverage&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=osbuild) to head [(`822daea`)](https://app.codecov.io/gh/osbuild/image-builder-frontend/commit/822daeabdd9ffd672bc505541512e25978fb9495?dropdown=coverage&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=osbuild).
> Report is 5 commits behind head on main.

| [Files with missing lines](https://app.codecov.io/gh/osbuild/image-builder-frontend/pull/3071?dropdown=coverage&src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=osbuild) | Patch % | Lines |
|---|---|---|
| [...ateImageWizard/steps/Users/components/UserInfo.tsx](https://app.codecov.io/gh/osbuild/image-builder-frontend/pull/3071?src=pr&el=tree&filepath=src%2FComponents%2FCreateImageWizard%2Fsteps%2FUsers%2Fcomponents%2FUserInfo.tsx&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=osbuild#diff-c3JjL0NvbXBvbmVudHMvQ3JlYXRlSW1hZ2VXaXphcmQvc3RlcHMvVXNlcnMvY29tcG9uZW50cy9Vc2VySW5mby50c3g=) | 93.24% | [9 Missing and 1 partial :warning: ](https://app.codecov.io/gh/osbuild/image-builder-frontend/pull/3071?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=osbuild) |
| [...eWizard/steps/Users/components/RemoveUserModal.tsx](https://app.codecov.io/gh/osbuild/image-builder-frontend/pull/3071?src=pr&el=tree&filepath=src%2FComponents%2FCreateImageWizard%2Fsteps%2FUsers%2Fcomponents%2FRemoveUserModal.tsx&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=osbuild#diff-c3JjL0NvbXBvbmVudHMvQ3JlYXRlSW1hZ2VXaXphcmQvc3RlcHMvVXNlcnMvY29tcG9uZW50cy9SZW1vdmVVc2VyTW9kYWwudHN4) | 95.65% | [2 Missing :warning: ](https://app.codecov.io/gh/osbuild/image-builder-frontend/pull/3071?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=osbuild) |
| [...Wizard/steps/Users/components/calculateNewIndex.ts](https://app.codecov.io/gh/osbuild/image-builder-frontend/pull/3071?src=pr&el=tree&filepath=src%2FComponents%2FCreateImageWizard%2Fsteps%2FUsers%2Fcomponents%2FcalculateNewIndex.ts&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=osbuild#diff-c3JjL0NvbXBvbmVudHMvQ3JlYXRlSW1hZ2VXaXphcmQvc3RlcHMvVXNlcnMvY29tcG9uZW50cy9jYWxjdWxhdGVOZXdJbmRleC50cw==) | 93.33% | [1 Missing :warning: ](https://app.codecov.io/gh/osbuild/image-builder-frontend/pull/3071?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=osbuild) |
| [...ents/CreateImageWizard/utilities/useValidation.tsx](https://app.codecov.io/gh/osbuild/image-builder-frontend/pull/3071?src=pr&el=tree&filepath=src%2FComponents%2FCreateImageWizard%2Futilities%2FuseValidation.tsx&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=osbuild#diff-c3JjL0NvbXBvbmVudHMvQ3JlYXRlSW1hZ2VXaXphcmQvdXRpbGl0aWVzL3VzZVZhbGlkYXRpb24udHN4) | 97.72% | [1 Missing :warning: ](https://app.codecov.io/gh/osbuild/image-builder-frontend/pull/3071?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=osbuild) |

[![Impacted file tree graph](https://app.codecov.io/gh/osbuild/image-builder-frontend/pull/3071/graphs/tree.svg?width=650&height=150&src=pr&token=64TO0SQEP0&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=osbuild)](https://app.codecov.io/gh/osbuild/image-builder-frontend/pull/3071?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=osbuild)

```diff
@@            Coverage Diff             @@
##             main    #3071      +/-   ##
==========================================
+ Coverage   85.27%   85.29%   +0.02%     
==========================================
  Files         179      181       +2     
  Lines       22485    22614     +129     
  Branches     2270     2301      +31     
==========================================
+ Hits        19173    19288     +115     
- Misses       3299     3312      +13     
- Partials       13       14       +1     
```

| [Files with missing lines](https://app.codecov.io/gh/osbuild/image-builder-frontend/pull/3071?dropdown=coverage&src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=osbuild) | Coverage Δ | |
|---|---|---|
| [...teImageWizard/utilities/PasswordValidatedInput.tsx](https://app.codecov.io/gh/osbuild/image-builder-frontend/pull/3071?src=pr&el=tree&filepath=src%2FComponents%2FCreateImageWizard%2Futilities%2FPasswordValidatedInput.tsx&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=osbuild#diff-c3JjL0NvbXBvbmVudHMvQ3JlYXRlSW1hZ2VXaXphcmQvdXRpbGl0aWVzL1Bhc3N3b3JkVmFsaWRhdGVkSW5wdXQudHN4) | `96.49% <100.00%> (ø)` | |
| [...Wizard/steps/Users/components/calculateNewIndex.ts](https://app.codecov.io/gh/osbuild/image-builder-frontend/pull/3071?src=pr&el=tree&filepath=src%2FComponents%2FCreateImageWizard%2Fsteps%2FUsers%2Fcomponents%2FcalculateNewIndex.ts&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=osbuild#diff-c3JjL0NvbXBvbmVudHMvQ3JlYXRlSW1hZ2VXaXphcmQvc3RlcHMvVXNlcnMvY29tcG9uZW50cy9jYWxjdWxhdGVOZXdJbmRleC50cw==) | `93.33% <93.33%> (ø)` | |
| [...ents/CreateImageWizard/utilities/useValidation.tsx](https://app.codecov.io/gh/osbuild/image-builder-frontend/pull/3071?src=pr&el=tree&filepath=src%2FComponents%2FCreateImageWizard%2Futilities%2FuseValidation.tsx&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=osbuild#diff-c3JjL0NvbXBvbmVudHMvQ3JlYXRlSW1hZ2VXaXphcmQvdXRpbGl0aWVzL3VzZVZhbGlkYXRpb24udHN4) | `95.76% <97.72%> (-0.13%)` | :arrow_down: |
| [...eWizard/steps/Users/components/RemoveUserModal.tsx](https://app.codecov.io/gh/osbuild/image-builder-frontend/pull/3071?src=pr&el=tree&filepath=src%2FComponents%2FCreateImageWizard%2Fsteps%2FUsers%2Fcomponents%2FRemoveUserModal.tsx&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=osbuild#diff-c3JjL0NvbXBvbmVudHMvQ3JlYXRlSW1hZ2VXaXphcmQvc3RlcHMvVXNlcnMvY29tcG9uZW50cy9SZW1vdmVVc2VyTW9kYWwudHN4) | `95.65% <95.65%> (ø)` | |
| [...ateImageWizard/steps/Users/components/UserInfo.tsx](https://app.codecov.io/gh/osbuild/image-builder-frontend/pull/3071?src=pr&el=tree&filepath=src%2FComponents%2FCreateImageWizard%2Fsteps%2FUsers%2Fcomponents%2FUserInfo.tsx&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=osbuild#diff-c3JjL0NvbXBvbmVudHMvQ3JlYXRlSW1hZ2VXaXphcmQvc3RlcHMvVXNlcnMvY29tcG9uZW50cy9Vc2VySW5mby50c3g=) | `94.76% <93.24%> (-5.24%)` | :arrow_down: |

------

[Continue to review full report in Codecov by Sentry](https://app.codecov.io/gh/osbuild/image-builder-frontend/pull/3071?dropdown=coverage&src=pr&el=continue&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=osbuild).
> **Legend** - [Click here to learn more](https://docs.codecov.io/docs/codecov-delta?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=osbuild)
> `Δ = absolute <relative> (impact)`, `ø = not affected`, `? = missing data`
> Powered by [Codecov](https://app.codecov.io/gh/osbuild/image-builder-frontend/pull/3071?dropdown=coverage&src=pr&el=footer&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=osbuild). Last update [f903d61...822daea](https://app.codecov.io/gh/osbuild/image-builder-frontend/pull/3071?dropdown=coverage&src=pr&el=lastupdated&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=osbuild). Read the [comment docs](https://docs.codecov.io/docs/pull-request-comments?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=osbuild).

<details><summary> :rocket: New features to boost your workflow: </summary>

- :snowflake: [Test Analytics](https://docs.codecov.com/docs/test-analytics): Detect flaky tests, report on failures, and find test suite problems.
- :package: [JS Bundle Analysis](https://docs.codecov.com/docs/javascript-bundle-analysis): Save yourself from yourself by tracking and limiting bundle sizes in JS merges.
</details>
author_association: NONE
user: login: regexowl
created_at: 2025-04-10T07:49:17Z
body: <img src="https://github.com/user-attachments/assets/85689158-1b20-40a2-89cc-9df2723aa4a9" width="400" />

It's me, I'm "this guy" in this scenario, should be fixed now. Really nice catches, thank you! 
author_association: COLLABORATOR
user: login: regexowl
created_at: 2025-04-11T08:46:49Z
body: Added two commits with changes. The only not resolved issue is the ssh key validation one - it seems to be connected to handling `isPristine` in validated input, same as in https://github.com/osbuild/image-builder-frontend/pull/3072#issuecomment-2794130788

The error stays in the `errors` object and  if you click away from the field after the error disappeared, the helper text reappears again. We should probably revisit and evaluate how is the `isPristine` reset and handled.
author_association: COLLABORATOR
user: login: avitova
created_at: 2025-04-14T13:10:44Z
body: Ah, sorry, some more nitpicks I did not notice before.
1) Should it be possible to have more users with the same name?
2) When adding new tab when the navigation is full, I would expect that the navigation shifts so that we can see the new tab:
https://github.com/user-attachments/assets/53c64e6b-72ae-477b-8003-890f9f11b89a


author_association: COLLABORATOR
user: login: regexowl
created_at: 2025-04-14T15:16:02Z
body: > Ah, sorry, some more nitpicks I did not notice before.
> 
>     1. Should it be possible to have more users with the same name?
> 
>     2. When adding new tab when the navigation is full, I would expect that the navigation shifts so that we can see the new tab:
>        https://github.com/user-attachments/assets/53c64e6b-72ae-477b-8003-890f9f11b89a

Thanks! I've added a commit to address the name duplicity. I believe we shouldn't allow to add users with duplicate username.

As for the second point - agree. I presume the `useEffect` from "Dynamic tabs" example in PF docs should do the trick, alas it doesn't work great as is :thinking: will give it another look tomorrow.
author_association: COLLABORATOR
user: login: regexowl
created_at: 2025-04-14T15:28:56Z
body: Wasn't that hard actually, focusing on newly added user should be working now :sweat_smile: 
author_association: COLLABORATOR
user: login: avitova
created_at: 2025-04-16T12:14:44Z
body: Looks good, I am good here. Suggestion for future: I would probably disable the "+" button to add new users as long as there is an empty user. :thinking: If you like that idea, we can do that in a followup, I do not want to block this.
author_association: COLLABORATOR
user: login: regexowl
created_at: 2025-04-16T13:32:41Z
body: > Looks good, I am good here. Suggestion for future: I would probably disable the "+" button to add new users as long as there is an empty user. 🤔 If you like that idea, we can do that in a followup, I do not want to block this.

Agree this would be nice, might require some digging in Patternfly though as disabling the add button doesn't seem to be something we have access to by default. Opened an issue so we can investigate later https://github.com/osbuild/image-builder-frontend/issues/3114
author_association: COLLABORATOR
user: login: regexowl
created_at: 2025-04-16T15:19:06Z
body: Squashed the commits a bit to make the history slightly cleaner.
author_association: COLLABORATOR
user: login: lucasgarfield
created_at: 2025-04-17T19:31:09Z
body: /retest
author_association: COLLABORATOR
user: login: lucasgarfield
created_at: 2025-04-18T14:26:55Z
body: /retest
author_association: COLLABORATOR