diff --git a/changes/24754-require-pw-for-pw-auth b/changes/24754-require-pw-for-pw-auth new file mode 100644 index 0000000000..d6228caf8a --- /dev/null +++ b/changes/24754-require-pw-for-pw-auth @@ -0,0 +1,4 @@ +- Update user form validation to require a password be present when switching a user from + SSO to password authentication +- Refactor upstream error logic to allow disabling submit button when form errors are present +- Add similar check for password presence on server, update integration test accordingly diff --git a/frontend/pages/admin/TeamManagementPage/TeamDetailsWrapper/UsersPage/UsersPage.tsx b/frontend/pages/admin/TeamManagementPage/TeamDetailsWrapper/UsersPage/UsersPage.tsx index ca3e1a1731..0a56b4f0c1 100644 --- a/frontend/pages/admin/TeamManagementPage/TeamDetailsWrapper/UsersPage/UsersPage.tsx +++ b/frontend/pages/admin/TeamManagementPage/TeamDetailsWrapper/UsersPage/UsersPage.tsx @@ -13,7 +13,6 @@ import PATHS from "router/paths"; import usersAPI from "services/entities/users"; import inviteAPI from "services/entities/invites"; import teamsAPI, { ILoadTeamsResponse } from "services/entities/teams"; -import { DEFAULT_USER_FORM_ERRORS } from "utilities/constants"; import TableContainer from "components/TableContainer"; import TableDataError from "components/DataError"; @@ -69,12 +68,8 @@ const UsersPage = ({ location, router }: ITeamSubnavProps): JSX.Element => { const [isUpdatingUsers, setIsUpdatingUsers] = useState(false); const [userEditing, setUserEditing] = useState(); const [searchString, setSearchString] = useState(""); - const [addUserErrors, setAddUserErrors] = useState( - DEFAULT_USER_FORM_ERRORS - ); - const [editUserErrors, setEditUserErrors] = useState( - DEFAULT_USER_FORM_ERRORS - ); + const [addUserErrors, setAddUserErrors] = useState({}); + const [editUserErrors, setEditUserErrors] = useState({}); const toggleAddUserModal = useCallback(() => { setShowAddUserModal(!showAddUserModal); @@ -129,7 +124,7 @@ const UsersPage = ({ location, router }: ITeamSubnavProps): JSX.Element => { (user?: IUser) => { setShowEditUserModal(!showEditUserModal); user ? setUserEditing(user) : setUserEditing(undefined); - setEditUserErrors(DEFAULT_USER_FORM_ERRORS); + setEditUserErrors({}); }, [showEditUserModal, setShowEditUserModal, setUserEditing] ); diff --git a/frontend/pages/admin/UserManagementPage/components/UserForm/UserForm.tests.tsx b/frontend/pages/admin/UserManagementPage/components/UserForm/UserForm.tests.tsx index 5c11d2e2ef..6700148b6e 100644 --- a/frontend/pages/admin/UserManagementPage/components/UserForm/UserForm.tests.tsx +++ b/frontend/pages/admin/UserManagementPage/components/UserForm/UserForm.tests.tsx @@ -2,7 +2,6 @@ import React from "react"; import { render, screen } from "@testing-library/react"; import { noop } from "lodash"; import { renderWithSetup, createMockRouter } from "test/test-utils"; -import { DEFAULT_USER_FORM_ERRORS } from "utilities/constants"; import UserForm from "./UserForm"; // Note: Happy path is tested e2e so these integration tests are only edge cases @@ -18,7 +17,7 @@ describe("UserForm - component", () => { canUseSso: false, isNewUser: true, router: createMockRouter(), - ancestorErrors: DEFAULT_USER_FORM_ERRORS, + ancestorErrors: {}, }; it("displays error messages for invalid inputs", async () => { diff --git a/frontend/pages/admin/UserManagementPage/components/UserForm/UserForm.tsx b/frontend/pages/admin/UserManagementPage/components/UserForm/UserForm.tsx index 0f283e948d..cb5dd38f2f 100644 --- a/frontend/pages/admin/UserManagementPage/components/UserForm/UserForm.tsx +++ b/frontend/pages/admin/UserManagementPage/components/UserForm/UserForm.tsx @@ -88,8 +88,9 @@ interface IUserFormProps { const validate = ( formData: IUserFormData, canUseSso: boolean, - isNewUser?: boolean, - isSsoEnabled?: boolean + isNewUser: boolean, + isSsoEnabled: boolean, + initiallyPasswordAuth: boolean ) => { const newErrors: IUserFormErrors = {}; @@ -110,8 +111,13 @@ const validate = ( // force to password auth if SSO is disabled globally but was enabled on the form const isExistingUserForcedToPasswordAuth = !canUseSso && isSsoEnabled; - // password required when creating a user with SSO disabled, though not when inviting a user - if (isNewAdminCreatedUserWithoutSSO || isExistingUserForcedToPasswordAuth) { + // password required when creating a user with SSO disabled and when changing a user from SSO to + // password authentication, though not when inviting a user + if ( + isNewAdminCreatedUserWithoutSSO || + isExistingUserForcedToPasswordAuth || + (!initiallyPasswordAuth && !sso_enabled) + ) { if (password !== null && !validPassword(password)) { newErrors.password = "Password must meet the criteria below"; } @@ -199,13 +205,19 @@ const UserForm = ({ const onInputChange = ({ name, value }: IFormField) => { const newFormData = { ...formData, [name]: value }; setFormData(newFormData); - const newErrs = validate(newFormData, canUseSso, isNewUser, isSsoEnabled); + const newErrs = validate( + newFormData, + canUseSso, + isNewUser, + !!isSsoEnabled, + initiallyPasswordAuth + ); // only set errors that are updates of existing errors // new errors are only set onBlur or submit const errsToSet: Record = {}; Object.keys(formErrors).forEach((k) => { // @ts-ignore - if (formErrors[k] && newErrs[k]) { + if (newErrs[k]) { // @ts-ignore errsToSet[k] = newErrs[k]; } @@ -214,7 +226,15 @@ const UserForm = ({ }; const onInputBlur = () => { - setFormErrors(validate(formData, canUseSso, isNewUser, isSsoEnabled)); + setFormErrors( + validate( + formData, + canUseSso, + isNewUser, + !!isSsoEnabled, + initiallyPasswordAuth + ) + ); }; // Used to show entire dropdown when a dropdown menu is open in scrollable component of a modal @@ -261,10 +281,20 @@ const UserForm = ({ }; const onSsoChange = (value: boolean): void => { - setFormData({ - ...formData, - sso_enabled: value, - }); + const newFormData = { ...formData, sso_enabled: value }; + setFormData(newFormData); + if (value) { + // clears password error when enabling sso, allowing submission even if password is invalid + setFormErrors( + validate( + newFormData, + canUseSso, + isNewUser, + !!isSsoEnabled, + initiallyPasswordAuth + ) + ); + } }; const onSelectedTeamChange = (teams: ITeam[]): void => { @@ -318,7 +348,13 @@ const UserForm = ({ renderFlash("error", `Please select at least one team for this user.`); return; } - const errs = validate(formData, canUseSso, isNewUser, isSsoEnabled); + const errs = validate( + formData, + canUseSso, + isNewUser, + !!isSsoEnabled, + initiallyPasswordAuth + ); if (Object.keys(errs).length > 0) { setFormErrors(errs); return; @@ -699,6 +735,7 @@ const UserForm = ({ className={`${isNewUser ? "add" : "save"}-loading `} isLoading={isUpdatingUsers} + disabled={Object.keys(formErrors).length > 0} > {isNewUser ? "Add" : "Save"} diff --git a/frontend/pages/admin/UserManagementPage/components/UsersTable/UsersTable.tsx b/frontend/pages/admin/UserManagementPage/components/UsersTable/UsersTable.tsx index 2d97a7137f..84f1440b44 100644 --- a/frontend/pages/admin/UserManagementPage/components/UsersTable/UsersTable.tsx +++ b/frontend/pages/admin/UserManagementPage/components/UsersTable/UsersTable.tsx @@ -15,7 +15,6 @@ import teamsAPI, { ILoadTeamsResponse } from "services/entities/teams"; import usersAPI from "services/entities/users"; import invitesAPI from "services/entities/invites"; -import { DEFAULT_USER_FORM_ERRORS } from "utilities/constants"; import TableContainer from "components/TableContainer"; import { ITableQueryData } from "components/TableContainer/TableContainer"; import TableCount from "components/TableContainer/TableCount"; @@ -51,12 +50,8 @@ const UsersTable = ({ router }: IUsersTableProps): JSX.Element => { const [showResetSessionsModal, setShowResetSessionsModal] = useState(false); const [isUpdatingUsers, setIsUpdatingUsers] = useState(false); const [userEditing, setUserEditing] = useState(null); - const [addUserErrors, setAddUserErrors] = useState( - DEFAULT_USER_FORM_ERRORS - ); - const [editUserErrors, setEditUserErrors] = useState( - DEFAULT_USER_FORM_ERRORS - ); + const [addUserErrors, setAddUserErrors] = useState({}); + const [editUserErrors, setEditUserErrors] = useState({}); const [querySearchText, setQuerySearchText] = useState(""); // API CALLS @@ -112,7 +107,7 @@ const UsersTable = ({ router }: IUsersTableProps): JSX.Element => { // clear errors on close if (!showAddUserModal) { - setAddUserErrors(DEFAULT_USER_FORM_ERRORS); + setAddUserErrors({}); } }, [showAddUserModal, setShowAddUserModal]); @@ -128,7 +123,7 @@ const UsersTable = ({ router }: IUsersTableProps): JSX.Element => { (user?: IUser | IInvite) => { setShowEditUserModal(!showEditUserModal); setUserEditing(!showEditUserModal ? user : null); - setEditUserErrors(DEFAULT_USER_FORM_ERRORS); + setEditUserErrors({}); }, [showEditUserModal, setShowEditUserModal, setUserEditing] ); diff --git a/frontend/utilities/constants.tsx b/frontend/utilities/constants.tsx index 5f091b0bfe..874cf8c730 100644 --- a/frontend/utilities/constants.tsx +++ b/frontend/utilities/constants.tsx @@ -377,13 +377,6 @@ export const BATTERY_TOOLTIP: Record = { ), }; -export const DEFAULT_USER_FORM_ERRORS = { - email: null, - name: null, - password: null, - sso_enabled: null, -}; - /** Must pass agent options config as empty object */ export const EMPTY_AGENT_OPTIONS = { config: {}, diff --git a/server/service/integration_core_test.go b/server/service/integration_core_test.go index c7fa9e838b..42e2c3029a 100644 --- a/server/service/integration_core_test.go +++ b/server/service/integration_core_test.go @@ -8429,6 +8429,31 @@ func (s *integrationTestSuite) TestModifyUser() { require.NoError(t, json.NewDecoder(resp.Body).Decode(&loginResp)) resp.Body.Close() require.Equal(t, u.ID, loginResp.User.ID) + + // as an admin, create a new user with SSO authentication enabled + params = fleet.UserPayload{ + Name: ptr.String("moduser1"), + Email: ptr.String("moduser1@example.com"), + SSOInvite: ptr.Bool(true), + GlobalRole: ptr.String(fleet.RoleObserver), + AdminForcedPasswordReset: ptr.Bool(false), + } + s.DoJSON("POST", "/api/latest/fleet/users/admin", params, http.StatusOK, &createResp) + require.NotZero(t, createResp.User.ID) + u = *createResp.User + + // as an admin, try to disable sso for that user without providing a password + res := s.Do("PATCH", fmt.Sprintf("/api/latest/fleet/users/%d", u.ID), fleet.UserPayload{ + SSOEnabled: ptr.Bool(false), + }, http.StatusUnprocessableEntity) + errMsg := extractServerErrorText(res.Body) + require.Contains(t, errMsg, "a new password must be provided when disabling SSO") + + // as an admin, try to disable sso for that user while providing a password + s.Do("PATCH", fmt.Sprintf("/api/latest/fleet/users/%d", u.ID), fleet.UserPayload{ + SSOEnabled: ptr.Bool(false), + NewPassword: ptr.String("Password123#"), + }, http.StatusOK) } func (s *integrationTestSuite) TestGetHostLastOpenedAt() { diff --git a/server/service/users.go b/server/service/users.go index b807266313..ab4da962b8 100644 --- a/server/service/users.go +++ b/server/service/users.go @@ -480,6 +480,9 @@ func (svc *Service) ModifyUser(ctx context.Context, userID uint, p fleet.UserPay } if p.SSOEnabled != nil { + if !*p.SSOEnabled && p.NewPassword == nil { + return nil, fleet.NewInvalidArgumentError("missing password", "a new password must be provided when disabling SSO") + } user.SSOEnabled = *p.SSOEnabled }