diff --git a/cypress/integration/users_test.spec.ts b/cypress/integration/users_test.spec.ts index 8c6765e2c6..c5bb195071 100644 --- a/cypress/integration/users_test.spec.ts +++ b/cypress/integration/users_test.spec.ts @@ -197,8 +197,9 @@ describe("User creation", () => { .goToCredentialsTab() .clickEmptyStateResetBtn() .fillResetCredentialForm(); - masthead.checkNotificationMessage("Failed to send email to user."); - modalUtils.cancelModal(); + masthead.checkNotificationMessage( + "Failed: Failed to send execute actions email" + ); }); it("Reset credential of User with existing credentials", () => { @@ -208,8 +209,9 @@ describe("User creation", () => { .clickResetBtn() .fillResetCredentialForm(); - masthead.checkNotificationMessage("Failed to send email to user."); - modalUtils.cancelModal(); + masthead.checkNotificationMessage( + "Failed: Failed to send execute actions email" + ); }); it("Delete user test", () => { diff --git a/cypress/support/pages/admin_console/manage/users/CredentialsPage.ts b/cypress/support/pages/admin_console/manage/users/CredentialsPage.ts index e71cd6cfab..9ea81e2aa6 100644 --- a/cypress/support/pages/admin_console/manage/users/CredentialsPage.ts +++ b/cypress/support/pages/admin_console/manage/users/CredentialsPage.ts @@ -3,7 +3,7 @@ export default class CredentialsPage { private readonly emptyStatePasswordBtn = "no-credentials-empty-action"; private readonly emptyStateResetBtn = "credential-reset-empty-action"; private readonly resetBtn = "credentialResetBtn"; - private readonly setPasswordBtn = "setPasswordBtn"; + private readonly setPasswordBtn = "confirm"; private readonly credentialResetModal = "credential-reset-modal"; private readonly resetModalActionsToggleBtn = "[data-testid=credential-reset-modal] #actions"; @@ -16,7 +16,7 @@ export default class CredentialsPage { "UPDATE_PASSWORD-option", "terms_and_conditions-option", ]; - private readonly confirmationButton = "okBtn"; + private readonly confirmationButton = "confirm"; goToCredentialsTab() { cy.findByTestId(this.credentialsTab).click(); diff --git a/src/user/UserCredentials.tsx b/src/user/UserCredentials.tsx index 4f45e7f140..a39fe4532f 100644 --- a/src/user/UserCredentials.tsx +++ b/src/user/UserCredentials.tsx @@ -1,248 +1,52 @@ -import React, { Fragment, FunctionComponent, useMemo, useState } from "react"; +import React, { Fragment, useState } from "react"; import { AlertVariant, Button, ButtonVariant, Divider, - Dropdown, - DropdownItem, - DropdownPosition, - Form, - FormGroup, - KebabToggle, - Modal, - ModalVariant, - Select, - SelectOption, - SelectVariant, - Switch, - Text, - TextInput, - TextVariants, - ValidatedOptions, } from "@patternfly/react-core"; import { - Table, - TableBody, TableComposable, - TableHeader, - TableVariant, + Tbody, Td, Th, Thead, Tr, } from "@patternfly/react-table"; -import { PencilAltIcon, CheckIcon, TimesIcon } from "@patternfly/react-icons"; import type UserRepresentation from "@keycloak/keycloak-admin-client/lib/defs/userRepresentation"; import { useTranslation } from "react-i18next"; -import { isEmpty } from "lodash/fp"; import { useAlerts } from "../components/alert/Alerts"; import { ListEmptyState } from "../components/list-empty-state/ListEmptyState"; import { useAdminClient, useFetch } from "../context/auth/AdminClient"; -import { useWhoAmI } from "../context/whoami/WhoAmI"; -import { Controller, useForm, UseFormMethods, useWatch } from "react-hook-form"; -import { PasswordInput } from "../components/password-input/PasswordInput"; import { HelpItem } from "../components/help-enabler/HelpItem"; -import "./user-credentials.css"; import { useConfirmDialog } from "../components/confirm-dialog/ConfirmDialog"; import type CredentialRepresentation from "@keycloak/keycloak-admin-client/lib/defs/credentialRepresentation"; -import { FormAccess } from "../components/form-access/FormAccess"; -import { RequiredActionAlias } from "@keycloak/keycloak-admin-client/lib/defs/requiredActionProviderRepresentation"; -import { TimeSelector } from "../components/time-selector/TimeSelector"; +import { ResetPasswordDialog } from "./user-credentials/ResetPasswordDialog"; +import { ResetCredentialDialog } from "./user-credentials/ResetCredentialDialog"; +import { InlineLabelEdit } from "./user-credentials/InlineLabelEdit"; + +import "./user-credentials.css"; +import { CredentialRow } from "./user-credentials/CredentialRow"; +import { toUpperCase } from "../util"; type UserCredentialsProps = { user: UserRepresentation; }; -type CredentialsForm = { - password: string; - passwordConfirmation: string; - temporaryPassword: boolean; -}; - -type CredentialResetForm = { - actions: RequiredActionAlias[]; - lifespan: number; -}; - -const credFormDefaultValues: CredentialsForm = { - password: "", - passwordConfirmation: "", - temporaryPassword: true, -}; - -const credResetFormDefaultValues: CredentialResetForm = { - actions: [], - lifespan: 43200, // 12 hours -}; - -type DisplayDialogProps = { - titleKey: string; - onClose: () => void; -}; - -type UserLabelForm = { - userLabel: string; -}; - -const userLabelDefaultValues: UserLabelForm = { - userLabel: "", -}; - type ExpandableCredentialRepresentation = { key: string; value: CredentialRepresentation[]; isExpanded: boolean; }; -const DisplayDialog: FunctionComponent = ({ - titleKey, - onClose, - children, -}) => { - const { t } = useTranslation("users"); - return ( - - {children} - - ); -}; - -const CredentialsResetActionMultiSelect = (props: { - form: UseFormMethods; -}) => { - const { t } = useTranslation("users"); - const [open, setOpen] = useState(false); - const { form } = props; - const { control } = form; - - return ( - - } - fieldId="actions" - > - ( - - )} - /> - - ); -}; - -const LifespanField = ({ - form: { control }, -}: { - form: UseFormMethods; -}) => { - const { t } = useTranslation("users"); - - return ( - - } - > - ( - - )} - /> - - ); -}; - export const UserCredentials = ({ user }: UserCredentialsProps) => { const { t } = useTranslation("users"); - const { whoAmI } = useWhoAmI(); const { addAlert, addError } = useAlerts(); const [key, setKey] = useState(0); const refresh = () => setKey(key + 1); - const [open, setOpen] = useState(false); - const [openSaveConfirm, setOpenSaveConfirm] = useState(false); + const [isOpen, setIsOpen] = useState(false); const [openCredentialReset, setOpenCredentialReset] = useState(false); - const [kebabOpen, setKebabOpen] = useState({ - status: false, - rowKey: "", - }); const adminClient = useAdminClient(); - const form = useForm({ - defaultValues: credFormDefaultValues, - }); - const resetForm = useForm({ - defaultValues: credResetFormDefaultValues, - }); - const userLabelForm = useForm({ - defaultValues: userLabelDefaultValues, - }); - const { control, errors, handleSubmit, register } = form; - const { control: resetControl, handleSubmit: resetHandleSubmit } = resetForm; - const { - getValues: getValues1, - handleSubmit: handleSubmit1, - register: register1, - } = userLabelForm; - const [credentials, setCredentials] = useState(); - const [credentialsReset, setCredentialReset] = useState( - {} as CredentialResetForm - ); const [userCredentials, setUserCredentials] = useState< CredentialRepresentation[] >([]); @@ -252,9 +56,6 @@ export const UserCredentials = ({ user }: UserCredentialsProps) => { const [selectedCredential, setSelectedCredential] = useState({}); const [isResetPassword, setIsResetPassword] = useState(false); - const [showData, setShowData] = useState(false); - const [editedUserCredential, setEditedUserCredential] = - useState({}); const [isUserLabelEdit, setIsUserLabelEdit] = useState<{ status: boolean; rowKey: string; @@ -289,107 +90,15 @@ export const UserCredentials = ({ user }: UserCredentialsProps) => { (credential) => credential.type === "password" ); - const passwordWatcher = useWatch({ - control, - name: "password", - }); - - const resetActionWatcher = useWatch({ - control: resetControl, - name: "actions", - }); - - const passwordConfirmationWatcher = useWatch< - CredentialsForm["passwordConfirmation"] - >({ - control, - name: "passwordConfirmation", - }); - - const isNotDisabled = - passwordWatcher !== "" && passwordConfirmationWatcher !== ""; - - const resetIsNotDisabled = !isEmpty(resetActionWatcher); - - const toggleModal = () => { - setOpen(!open); - }; + const toggleModal = () => setIsOpen(!isOpen); const toggleCredentialsResetModal = () => { setOpenCredentialReset(!openCredentialReset); }; - const toggleConfirmSaveModal = () => { - setOpenSaveConfirm(!openSaveConfirm); - }; - - const saveUserPassword = async () => { - if (!credentials) { - return; - } - - const passwordsMatch = - credentials.password === credentials.passwordConfirmation; - - if (!passwordsMatch) { - addAlert( - isResetPassword - ? t("resetPasswordNotMatchError") - : t("savePasswordNotMatchError"), - AlertVariant.danger - ); - } else { - try { - await adminClient.users.resetPassword({ - id: user.id!, - credential: { - temporary: credentials.temporaryPassword, - type: "password", - value: credentials.password, - }, - }); - refresh(); - addAlert( - isResetPassword - ? t("resetCredentialsSuccess") - : t("savePasswordSuccess"), - AlertVariant.success - ); - setIsResetPassword(false); - setOpenSaveConfirm(false); - } catch (error) { - addError( - isResetPassword - ? "users:resetPasswordError" - : "users:savePasswordError", - error - ); - } - } - }; - - const sendCredentialsResetEmail = async () => { - if (isEmpty(credentialsReset.actions)) { - return; - } - - try { - await adminClient.users.executeActionsEmail({ - id: user.id!, - actions: credentialsReset.actions, - lifespan: credentialsReset.lifespan, - }); - refresh(); - addAlert(t("credentialResetEmailSuccess"), AlertVariant.success); - setOpenCredentialReset(false); - } catch (error) { - addError(t("credentialResetEmailError"), error); - } - }; - const resetPassword = () => { setIsResetPassword(true); - setOpen(true); + toggleModal(); }; const [toggleDeleteDialog, DeleteConfirm] = useConfirmDialog({ @@ -411,287 +120,53 @@ export const UserCredentials = ({ user }: UserCredentialsProps) => { }, }); - const rows = useMemo(() => { - if (!selectedCredential.credentialData) { - return []; - } - - const credentialData = JSON.parse(selectedCredential.credentialData); - const locale = whoAmI.getLocale(); - - return Object.entries(credentialData) - .sort(([a], [b]) => a.localeCompare(b, locale)) - .map<[string, string]>(([key, value]) => { - if (typeof value === "string") { - return [key, value]; + const Row = ({ credential }: { credential: CredentialRepresentation }) => ( + { + setSelectedCredential(credential); + toggleDeleteDialog(); + }} + resetPassword={resetPassword} + > + { - const credentialToEdit = userCredentials.find( - (credential) => credential.id === editedUserCredential.id - ); - - const userLabelFormValue = getValues1(); - - if (!credentialToEdit) { - return; - } - - try { - await adminClient.users.updateCredentialLabel( - { - id: user.id!, - credentialId: credentialToEdit.id!, - }, - userLabelFormValue.userLabel || "" - ); - refresh(); - addAlert(t("updateCredentialUserLabelSuccess"), AlertVariant.success); - setEditedUserCredential({}); - } catch (error) { - addError("users:updateCredentialUserLabelError", error); - } - - setIsUserLabelEdit({ - status: false, - rowKey: credentialToEdit.id!, - }); - }; - + toggle={() => { + setIsUserLabelEdit({ + status: !isUserLabelEdit?.status, + rowKey: credential.id!, + }); + if (isUserLabelEdit?.status) { + refresh(); + } + }} + /> + + ); return ( <> - {open && ( - { - setIsResetPassword(false); - setOpen(false); - }} - actions={[ - , - , - ]} - > -
- - - - - - - - } - fieldId="kc-temporaryPassword" - > - ( - onChange(value)} - isChecked={value} - label={t("common:on")} - labelOff={t("common:off")} - /> - )} - /> - -
-
- )} - {openSaveConfirm && ( - setOpenSaveConfirm(false)} - actions={[ - , - , - ]} - > - - {isResetPassword - ? `${t("resetPasswordConfirmText")} ${user.username} ${t( - "questionMark" - )}` - : `${t("setPasswordConfirmText")} ${user.username} ${t( - "questionMark" - )}`} - - + {isOpen && ( + setIsOpen(false)} + /> )} {openCredentialReset && ( - { - setOpenCredentialReset(false); - }} - data-testid="credential-reset-modal" - actions={[ - , - , - ]} - > -
- - - -
+ setOpenCredentialReset(false)} + /> )} - {showData && Object.keys(selectedCredential).length !== 0 && ( - { - setShowData(false); - setSelectedCredential({}); - }} - > - - - -
-
- )} {userCredentials.length !== 0 && passwordTypeFinder === undefined && ( <> - - {credential.type === "password" ? ( - - - - ) : ( - - )} - - - setKebabOpen({ - status, - rowKey: credential.id!, - }) - } - /> - } - isOpen={ - kebabOpen.status && - kebabOpen.rowKey === credential.id - } - onSelect={() => { - setSelectedCredential(credential); - }} - dropdownItems={[ - { - toggleDeleteDialog(); - setKebabOpen({ - status: false, - rowKey: credential.id!, - }); - }} - > - {t("deleteBtn")} - , - ]} - /> - - - ))} - - {groupedCredential.isExpanded && - groupedCredential.value.map((credential) => ( - - + + {groupedUserCredentials.map((groupedCredential, rowIndex) => ( + + + {groupedCredential.value.length > 1 ? ( - {credential.type!.charAt(0).toUpperCase()! + - credential.type!.slice(1)} - - - - -
- {isUserLabelEdit?.status && - isUserLabelEdit.rowKey === credential.id ? ( - <> - -
-
- - ) : ( - <> - {credential.userLabel ?? ""} -
-
-
- - - - + className="kc-expandRow-btn" + expand={{ + rowIndex, + isExpanded: groupedCredential.isExpanded, + onToggle: (_, rowIndex) => { + const rows = groupedUserCredentials.map( + (credential, index) => + index === rowIndex + ? { + ...credential, + isExpanded: !credential.isExpanded, + } + : credential + ); + setGroupedUserCredentials(rows); + }, + }} + /> + ) : ( - - - setKebabOpen({ - status, - rowKey: credential.id!, - }) - } - /> - } - isOpen={ - kebabOpen.status && - kebabOpen.rowKey === credential.id - } - onSelect={() => { - setSelectedCredential(credential); - }} - dropdownItems={[ - { - toggleDeleteDialog(); - setKebabOpen({ - status: false, - rowKey: credential.id!, - }); - }} - > - {t("deleteBtn")} - , - ]} + )} + + {toUpperCase(groupedCredential.key)} + + {groupedCredential.value.length <= 1 && + groupedCredential.value.map((credential) => ( + - - - ))} -
- ))} + ))} + + {groupedCredential.isExpanded && + groupedCredential.value.map((credential) => ( + + + + {toUpperCase(credential.type!)} + + + + ))} + + ))} + ) : ( diff --git a/src/user/messages.ts b/src/user/messages.ts index 63257afb72..60e3e1caee 100644 --- a/src/user/messages.ts +++ b/src/user/messages.ts @@ -126,19 +126,17 @@ export default { cancel: "Cancel", savePasswordSuccess: "The password has been set successfully.", savePasswordError: "Error saving password: {{error}}", - savePasswordNotMatchError: - "Error saving password: Password and confirmation does not match.", + confirmPasswordDoesNotMatch: "Password and confirmation does not match.", credentialType: "Type", credentialUserLabel: "User Label", credentialData: "Data", credentialsList: "Credentials List", setPasswordConfirm: "Set password?", setPasswordConfirmText: - "Are you sure you want to set the password for the user", + "Are you sure you want to set the password for the user {{username}}?", password: "Password", passwordConfirmation: "Password confirmation", resetPasswordConfirmation: "New password confirmation", - questionMark: "?", savePassword: "Save password", deleteCredentialsConfirmTitle: "Delete credentials?", deleteCredentialsConfirm: @@ -149,12 +147,10 @@ export default { resetPasswordFor: "Reset password for {{username}}", resetPasswordConfirm: "Reset password?", resetPasswordConfirmText: - "Are you sure you want to reset the password for the user", + "Are you sure you want to reset the password for the user {{username}}?", resetPassword: "Reset password", resetCredentialsSuccess: "The password has been reset successfully.", resetCredentialsError: "Error resetting users credentials: {{error}}", - resetPasswordNotMatchError: - "Error resetting password: Password and confirmation does not match.", resetPasswordError: "Error resetting password: {{error}}", resetPasswordBtn: "Reset password", showPasswordDataName: "Name", @@ -185,6 +181,6 @@ export default { credentialResetConfirm: "Send Email", credentialResetConfirmText: "Are you sure you want to send email to user", credentialResetEmailSuccess: "Email sent to user.", - credentialResetEmailError: "Failed to send email to user.", + credentialResetEmailError: "Failed: {{error}}", }, }; diff --git a/src/user/user-credentials/CredentialDataDialog.tsx b/src/user/user-credentials/CredentialDataDialog.tsx new file mode 100644 index 0000000000..3005e74b1b --- /dev/null +++ b/src/user/user-credentials/CredentialDataDialog.tsx @@ -0,0 +1,40 @@ +import React from "react"; +import { useTranslation } from "react-i18next"; +import { Modal, ModalVariant } from "@patternfly/react-core"; +import { + Table, + TableBody, + TableHeader, + TableVariant, +} from "@patternfly/react-table"; + +type CredentialDataDialogProps = { + credentialData: [string, string][]; + onClose: () => void; +}; + +export const CredentialDataDialog = ({ + credentialData, + onClose, +}: CredentialDataDialogProps) => { + const { t } = useTranslation("users"); + return ( + + + + +
+
+ ); +}; diff --git a/src/user/user-credentials/CredentialRow.tsx b/src/user/user-credentials/CredentialRow.tsx new file mode 100644 index 0000000000..c957a7874a --- /dev/null +++ b/src/user/user-credentials/CredentialRow.tsx @@ -0,0 +1,115 @@ +import React, { ReactNode, useMemo } from "react"; +import { useTranslation } from "react-i18next"; +import { Td } from "@patternfly/react-table"; +import { + Button, + Dropdown, + DropdownPosition, + KebabToggle, + DropdownItem, +} from "@patternfly/react-core"; + +import type CredentialRepresentation from "@keycloak/keycloak-admin-client/lib/defs/credentialRepresentation"; +import { useWhoAmI } from "../../context/whoami/WhoAmI"; +import useToggle from "../../utils/useToggle"; +import { CredentialDataDialog } from "./CredentialDataDialog"; + +type CredentialRowProps = { + credential: CredentialRepresentation; + resetPassword: () => void; + toggleDelete: () => void; + children: ReactNode; +}; + +export const CredentialRow = ({ + credential, + resetPassword, + toggleDelete, + children, +}: CredentialRowProps) => { + const { t } = useTranslation("users"); + const [showData, toggleShow] = useToggle(); + const [kebabOpen, toggleKebab] = useToggle(); + + const { whoAmI } = useWhoAmI(); + + const rows = useMemo(() => { + if (!credential.credentialData) { + return []; + } + + const credentialData: Record = JSON.parse( + credential.credentialData + ); + const locale = whoAmI.getLocale(); + + return Object.entries(credentialData) + .sort(([a], [b]) => a.localeCompare(b, locale)) + .map<[string, string]>(([key, value]) => { + if (typeof value === "string") { + return [key, value]; + } + + return [key, JSON.stringify(value)]; + }); + }, [credential.credentialData]); + + return ( + <> + {showData && Object.keys(credential).length !== 0 && ( + { + toggleShow(); + }} + /> + )} + + {children} + + + + {credential.type === "password" ? ( + + + + ) : ( + + )} + + } + isOpen={kebabOpen} + dropdownItems={[ + { + toggleDelete(); + toggleKebab(); + }} + > + {t("deleteBtn")} + , + ]} + /> + + + ); +}; diff --git a/src/user/user-credentials/CredentialsResetActionMultiSelect.tsx b/src/user/user-credentials/CredentialsResetActionMultiSelect.tsx new file mode 100644 index 0000000000..c3f5df3e30 --- /dev/null +++ b/src/user/user-credentials/CredentialsResetActionMultiSelect.tsx @@ -0,0 +1,72 @@ +import React, { useState } from "react"; +import { useTranslation } from "react-i18next"; +import { Controller, useFormContext } from "react-hook-form"; +import { + FormGroup, + Select, + SelectOption, + SelectVariant, +} from "@patternfly/react-core"; + +import { RequiredActionAlias } from "@keycloak/keycloak-admin-client/lib/defs/requiredActionProviderRepresentation"; +import { HelpItem } from "../../components/help-enabler/HelpItem"; + +export const CredentialsResetActionMultiSelect = () => { + const { t } = useTranslation("users"); + const { control } = useFormContext(); + const [open, setOpen] = useState(false); + + return ( + + } + fieldId="actions" + > + ( + + )} + /> + + ); +}; diff --git a/src/user/user-credentials/InlineLabelEdit.tsx b/src/user/user-credentials/InlineLabelEdit.tsx new file mode 100644 index 0000000000..b81a0f9675 --- /dev/null +++ b/src/user/user-credentials/InlineLabelEdit.tsx @@ -0,0 +1,106 @@ +import React from "react"; +import { useTranslation } from "react-i18next"; +import { useForm } from "react-hook-form"; +import { + AlertVariant, + Button, + Form, + FormGroup, + TextInput, +} from "@patternfly/react-core"; +import { CheckIcon, PencilAltIcon, TimesIcon } from "@patternfly/react-icons"; + +import type CredentialRepresentation from "@keycloak/keycloak-admin-client/lib/defs/credentialRepresentation"; +import { useAdminClient } from "../../context/auth/AdminClient"; +import { useAlerts } from "../../components/alert/Alerts"; + +type UserLabelForm = { + userLabel: string; +}; + +type InlineLabelEditProps = { + userId: string; + credential: CredentialRepresentation; + isEditable: boolean; + toggle: () => void; +}; + +export const InlineLabelEdit = ({ + userId, + credential, + isEditable, + toggle, +}: InlineLabelEditProps) => { + const { t } = useTranslation("users"); + const { register, handleSubmit } = useForm(); + + const adminClient = useAdminClient(); + const { addAlert, addError } = useAlerts(); + + const saveUserLabel = async (userLabel: UserLabelForm) => { + try { + await adminClient.users.updateCredentialLabel( + { + id: userId, + credentialId: credential.id!, + }, + userLabel.userLabel || "" + ); + addAlert(t("updateCredentialUserLabelSuccess"), AlertVariant.success); + toggle(); + } catch (error) { + addError("users:updateCredentialUserLabelError", error); + } + }; + + return ( +
+ +
+ {isEditable ? ( + <> + +
+
+ + ) : ( + <> + {credential.userLabel} +
+
+
+ ); +}; diff --git a/src/user/user-credentials/LifespanField.tsx b/src/user/user-credentials/LifespanField.tsx new file mode 100644 index 0000000000..1f79b609df --- /dev/null +++ b/src/user/user-credentials/LifespanField.tsx @@ -0,0 +1,38 @@ +import React from "react"; +import { useTranslation } from "react-i18next"; +import { Controller, useFormContext } from "react-hook-form"; +import { FormGroup } from "@patternfly/react-core"; + +import { HelpItem } from "../../components/help-enabler/HelpItem"; +import { TimeSelector } from "../../components/time-selector/TimeSelector"; +import { credResetFormDefaultValues } from "./ResetCredentialDialog"; + +export const LifespanField = () => { + const { t } = useTranslation("users"); + const { control } = useFormContext(); + + return ( + + } + > + ( + + )} + /> + + ); +}; diff --git a/src/user/user-credentials/ResetCredentialDialog.tsx b/src/user/user-credentials/ResetCredentialDialog.tsx new file mode 100644 index 0000000000..9a8146479a --- /dev/null +++ b/src/user/user-credentials/ResetCredentialDialog.tsx @@ -0,0 +1,94 @@ +import React from "react"; +import { useTranslation } from "react-i18next"; +import { FormProvider, useForm, useWatch } from "react-hook-form"; +import { ModalVariant, Form, AlertVariant } from "@patternfly/react-core"; + +import type { RequiredActionAlias } from "@keycloak/keycloak-admin-client/lib/defs/requiredActionProviderRepresentation"; +import { CredentialsResetActionMultiSelect } from "./CredentialsResetActionMultiSelect"; +import { ConfirmDialogModal } from "../../components/confirm-dialog/ConfirmDialog"; +import { useAdminClient } from "../../context/auth/AdminClient"; +import { useAlerts } from "../../components/alert/Alerts"; +import { LifespanField } from "./LifespanField"; +import { isEmpty } from "lodash-es"; + +type ResetCredentialDialogProps = { + userId: string; + onClose: () => void; +}; + +type CredentialResetForm = { + actions: RequiredActionAlias[]; + lifespan: number; +}; + +export const credResetFormDefaultValues: CredentialResetForm = { + actions: [], + lifespan: 43200, // 12 hours +}; + +export const ResetCredentialDialog = ({ + userId, + onClose, +}: ResetCredentialDialogProps) => { + const { t } = useTranslation("users"); + const form = useForm({ + defaultValues: credResetFormDefaultValues, + }); + const { handleSubmit, control } = form; + + const resetActionWatcher = useWatch({ + control: control, + name: "actions", + }); + const resetIsNotDisabled = !isEmpty(resetActionWatcher); + + const adminClient = useAdminClient(); + const { addAlert, addError } = useAlerts(); + + const sendCredentialsResetEmail = async ({ + actions, + lifespan, + }: CredentialResetForm) => { + if (isEmpty(actions)) { + return; + } + + try { + await adminClient.users.executeActionsEmail({ + id: userId, + actions, + lifespan, + }); + addAlert(t("credentialResetEmailSuccess"), AlertVariant.success); + onClose(); + } catch (error) { + addError("users:credentialResetEmailError", error); + } + }; + + return ( + { + handleSubmit(sendCredentialsResetEmail)(); + }} + confirmButtonDisabled={!resetIsNotDisabled} + > +
+ + + + +
+
+ ); +}; diff --git a/src/user/user-credentials/ResetPasswordDialog.tsx b/src/user/user-credentials/ResetPasswordDialog.tsx new file mode 100644 index 0000000000..0ccb2b6118 --- /dev/null +++ b/src/user/user-credentials/ResetPasswordDialog.tsx @@ -0,0 +1,212 @@ +import React from "react"; +import { useTranslation } from "react-i18next"; +import { Controller, useForm } from "react-hook-form"; +import { + AlertVariant, + ButtonVariant, + Form, + FormGroup, + Switch, + ValidatedOptions, +} from "@patternfly/react-core"; + +import type UserRepresentation from "@keycloak/keycloak-admin-client/lib/defs/userRepresentation"; +import { HelpItem } from "../../components/help-enabler/HelpItem"; +import { PasswordInput } from "../../components/password-input/PasswordInput"; +import { + ConfirmDialogModal, + useConfirmDialog, +} from "../../components/confirm-dialog/ConfirmDialog"; +import { useAdminClient } from "../../context/auth/AdminClient"; +import { useAlerts } from "../../components/alert/Alerts"; +import useToggle from "../../utils/useToggle"; + +type ResetPasswordDialogProps = { + user: UserRepresentation; + isResetPassword: boolean; + refresh: () => void; + onClose: () => void; +}; + +export type CredentialsForm = { + password: string; + passwordConfirmation: string; + temporaryPassword: boolean; +}; + +const credFormDefaultValues: CredentialsForm = { + password: "", + passwordConfirmation: "", + temporaryPassword: true, +}; + +export const ResetPasswordDialog = ({ + user, + isResetPassword, + refresh, + onClose, +}: ResetPasswordDialogProps) => { + const { t } = useTranslation("users"); + const { + register, + control, + errors, + formState: { isValid }, + watch, + handleSubmit, + } = useForm({ + defaultValues: credFormDefaultValues, + mode: "onChange", + shouldUnregister: false, + }); + + const [confirm, toggle] = useToggle(true); + const password = watch("password", ""); + + const adminClient = useAdminClient(); + const { addAlert, addError } = useAlerts(); + + const [toggleConfirmSaveModal, ConfirmSaveModal] = useConfirmDialog({ + titleKey: isResetPassword + ? "users:resetPasswordConfirm" + : "users:setPasswordConfirm", + messageKey: isResetPassword + ? t("resetPasswordConfirmText", { username: user.username }) + : t("setPasswordConfirmText", { username: user.username }), + continueButtonLabel: isResetPassword + ? "users:resetPassword" + : "users:savePassword", + continueButtonVariant: ButtonVariant.danger, + onConfirm: () => handleSubmit(saveUserPassword)(), + }); + + const saveUserPassword = async ({ + password, + temporaryPassword, + }: CredentialsForm) => { + try { + await adminClient.users.resetPassword({ + id: user.id!, + credential: { + temporary: temporaryPassword, + type: "password", + value: password, + }, + }); + addAlert( + isResetPassword + ? t("resetCredentialsSuccess") + : t("savePasswordSuccess"), + AlertVariant.success + ); + refresh(); + } catch (error) { + addError( + isResetPassword + ? "users:resetPasswordError" + : "users:savePasswordError", + error + ); + } + + onClose(); + }; + + return ( + <> + + +
+ + + + + + value === password || + t("confirmPasswordDoesNotMatch").toString(), + })} + /> + + + } + fieldId="kc-temporaryPassword" + > + ( + + )} + /> + +
+
+ + ); +};