From 25030a790f236d02f165ed5d97dff65b8bf85d17 Mon Sep 17 00:00:00 2001 From: agagancarczyk Date: Wed, 24 Nov 2021 15:37:30 +0000 Subject: [PATCH] User credentials (#1597) * user credentials - wip * user credentials - wip * user credentials - wip * user credentials - wip * user credentials - wip * user credentials - wip * user credentials - wip * user credentials - wip * user credentials - wip * added deleting credentials * replaced DataList with Table * added reset password - wip * added reset password * added show data dialog - wip * added show data dialog - wip * added password data dialog * added few translations * added sorting to password data * tidied up * clean up rows code * feedback fixes Co-authored-by: Agnieszka Gancarczyk Co-authored-by: Jon Koops --- src/common-messages.ts | 7 + src/user/UserCredentials.tsx | 505 +++++++++++++++++++++++++++++++++++ src/user/UsersTabs.tsx | 8 + src/user/messages.ts | 50 ++++ src/user/user-section.css | 27 ++ 5 files changed, 597 insertions(+) create mode 100644 src/user/UserCredentials.tsx diff --git a/src/common-messages.ts b/src/common-messages.ts index 5008d32c3e..ae1feeab26 100644 --- a/src/common-messages.ts +++ b/src/common-messages.ts @@ -119,6 +119,7 @@ export default { }, attributes: "Attributes", + credentials: "Credentials", clientId: "Client ID", id: "ID", @@ -153,5 +154,11 @@ export default { onDragFinish: "Dragging finished {{list}}", notFound: "Could not find the resource that you are looking for", + + password: "Password", + passwordConfirmation: "Password confirmation", + temporaryPassword: "Temporary", + temporaryPasswordHelpText: + "If enabled, the user must change the password on next login", }, }; diff --git a/src/user/UserCredentials.tsx b/src/user/UserCredentials.tsx new file mode 100644 index 0000000000..86935e9895 --- /dev/null +++ b/src/user/UserCredentials.tsx @@ -0,0 +1,505 @@ +import React, { FunctionComponent, useMemo, useState } from "react"; +import { + AlertVariant, + Button, + ButtonVariant, + Dropdown, + DropdownItem, + DropdownPosition, + Form, + FormGroup, + KebabToggle, + Modal, + ModalVariant, + Switch, + Text, + TextVariants, + ValidatedOptions, +} from "@patternfly/react-core"; +import { + Table, + TableBody, + TableComposable, + TableHeader, + TableVariant, + Tbody, + Td, + Th, + Thead, + Tr, +} from "@patternfly/react-table"; +import type UserRepresentation from "@keycloak/keycloak-admin-client/lib/defs/userRepresentation"; +import { useTranslation } from "react-i18next"; +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, useWatch } from "react-hook-form"; +import { PasswordInput } from "../components/password-input/PasswordInput"; +import { HelpItem } from "../components/help-enabler/HelpItem"; +import "./user-section.css"; +import { useConfirmDialog } from "../components/confirm-dialog/ConfirmDialog"; +import type CredentialRepresentation from "@keycloak/keycloak-admin-client/lib/defs/credentialRepresentation"; + +type UserCredentialsProps = { + user: UserRepresentation; +}; + +type CredentialsForm = { + password: string; + passwordConfirmation: string; + temporaryPassword: boolean; +}; + +const defaultValues: CredentialsForm = { + password: "", + passwordConfirmation: "", + temporaryPassword: true, +}; + +type DisplayDialogProps = { + titleKey: string; + onClose: () => void; +}; + +const DisplayDialog: FunctionComponent = ({ + titleKey, + onClose, + children, +}) => { + const { t } = useTranslation("users"); + return ( + + {children} + + ); +}; + +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 [kebabOpen, setKebabOpen] = useState(false); + const adminClient = useAdminClient(); + const form = useForm({ defaultValues }); + const { control, errors, handleSubmit, register } = form; + const [credentials, setCredentials] = useState(); + const [userCredentials, setUserCredentials] = useState< + CredentialRepresentation[] + >([]); + const [selectedCredential, setSelectedCredential] = + useState({}); + const [isResetPassword, setIsResetPassword] = useState(false); + const [showData, setShowData] = useState(false); + + useFetch( + () => adminClient.users.getCredentials({ id: user.id! }), + (credentials) => { + setUserCredentials(credentials); + }, + [key] + ); + + const passwordWatcher = useWatch({ + control, + name: "password", + }); + + const passwordConfirmationWatcher = useWatch< + CredentialsForm["passwordConfirmation"] + >({ + control, + name: "passwordConfirmation", + }); + + const isNotDisabled = + passwordWatcher !== "" && passwordConfirmationWatcher !== ""; + + const toggleModal = () => { + setOpen(!open); + }; + + 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 ? t("resetPasswordError") : t("savePasswordError"), + error + ); + } + } + }; + + const resetPassword = () => { + setIsResetPassword(true); + setOpen(true); + }; + + const [toggleDeleteDialog, DeleteConfirm] = useConfirmDialog({ + titleKey: t("deleteCredentialsConfirmTitle"), + messageKey: t("deleteCredentialsConfirm"), + continueButtonLabel: t("common:delete"), + continueButtonVariant: ButtonVariant.danger, + onConfirm: async () => { + try { + await adminClient.users.deleteCredential({ + id: user.id!, + credentialId: selectedCredential.id!, + }); + addAlert(t("deleteCredentialsSuccess"), AlertVariant.success); + setKey((key) => key + 1); + } catch (error) { + addError(t("deleteCredentialsError"), error); + } + }, + }); + + 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]; + } + + return [key, JSON.stringify(value)]; + }); + }, [selectedCredential.credentialData]); + + 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" + )}`} + + + )} + + {showData && Object.keys(selectedCredential).length !== 0 && ( + { + setShowData(false); + setSelectedCredential({}); + }} + > + + + +
+
+ )} + {userCredentials.length !== 0 ? ( + + + + + + + {t("type")} + {t("userLabel")} + {t("data")} + + + + + + + {userCredentials.map((credential) => ( + <> + + + {credential.type?.charAt(0).toUpperCase()! + + credential.type?.slice(1)} + + My Password + + + + + + + + setKebabOpen(open)} /> + } + isOpen={kebabOpen} + onSelect={() => setSelectedCredential(credential)} + dropdownItems={[ + { + toggleDeleteDialog(); + setKebabOpen(false); + }} + > + {t("deleteBtn")} + , + ]} + /> + + + ))} + + + + ) : ( + + )} + + ); +}; diff --git a/src/user/UsersTabs.tsx b/src/user/UsersTabs.tsx index d63c3510af..df484eb310 100644 --- a/src/user/UsersTabs.tsx +++ b/src/user/UsersTabs.tsx @@ -27,6 +27,7 @@ import { toUser } from "./routes/User"; import { toUsers } from "./routes/Users"; import { UserRoleMapping } from "./UserRoleMapping"; import { UserAttributes } from "./UserAttributes"; +import { UserCredentials } from "./UserCredentials"; import { useAccess } from "../context/access/Access"; const UsersTabs = () => { @@ -185,6 +186,13 @@ const UsersTabs = () => { > + {t("common:credentials")}} + > + + div > div > h1 { .kc-no-providers-text { text-align: center; } + +.kc-temporaryPassword { + margin: 6px 0 10px 35px; +} + +.kc-password, .kc-passwordConfirmation { + width: 355px; + float: right; +} + +.pf-c-form__group-label { + width: max-content; +} + +.pf-m-error { + display: inline-block; + margin-left: var(--pf-global--spacer--xl); +} + +.kc-edit-icon { + color: var(--pf-global--Color--200); + margin-left: 5px; +} + +.kc-showData-btn { + padding-left: 0; +}