import type CredentialRepresentation from "@keycloak/keycloak-admin-client/lib/defs/credentialRepresentation"; import type UserRepresentation from "@keycloak/keycloak-admin-client/lib/defs/userRepresentation"; import { AlertVariant, Button, ButtonVariant, Divider, PageSection, PageSectionVariants, } from "@patternfly/react-core"; import styles from "@patternfly/react-styles/css/components/Table/table"; import { TableComposable, Tbody, Td, Th, Thead, Tr, } from "@patternfly/react-table"; import { Fragment, DragEvent as ReactDragEvent, useMemo, useRef, useState, } from "react"; import { useTranslation } from "react-i18next"; import { HelpItem } from "ui-shared"; import { adminClient } from "../admin-client"; import { useAlerts } from "../components/alert/Alerts"; import { useConfirmDialog } from "../components/confirm-dialog/ConfirmDialog"; import { KeycloakSpinner } from "../components/keycloak-spinner/KeycloakSpinner"; import { ListEmptyState } from "../components/list-empty-state/ListEmptyState"; import { toUpperCase } from "../util"; import { useFetch } from "../utils/useFetch"; import { FederatedUserLink } from "./FederatedUserLink"; import { CredentialRow } from "./user-credentials/CredentialRow"; import { InlineLabelEdit } from "./user-credentials/InlineLabelEdit"; import { ResetCredentialDialog } from "./user-credentials/ResetCredentialDialog"; import { ResetPasswordDialog } from "./user-credentials/ResetPasswordDialog"; import "./user-credentials.css"; type UserCredentialsProps = { user: UserRepresentation; }; type ExpandableCredentialRepresentation = { key: string; value: CredentialRepresentation[]; isExpanded: boolean; }; export const UserCredentials = ({ user }: UserCredentialsProps) => { const { t } = useTranslation(); const { addAlert, addError } = useAlerts(); const [key, setKey] = useState(0); const refresh = () => setKey(key + 1); const [isOpen, setIsOpen] = useState(false); const [openCredentialReset, setOpenCredentialReset] = useState(false); const [userCredentials, setUserCredentials] = useState< CredentialRepresentation[] >([]); const [groupedUserCredentials, setGroupedUserCredentials] = useState< ExpandableCredentialRepresentation[] >([]); const [selectedCredential, setSelectedCredential] = useState({}); const [isResetPassword, setIsResetPassword] = useState(false); const [isUserLabelEdit, setIsUserLabelEdit] = useState<{ status: boolean; rowKey: string; }>(); const bodyRef = useRef(null); const [state, setState] = useState({ draggedItemId: "", draggingToItemIndex: -1, dragging: false, tempItemOrder: [""], }); useFetch( () => adminClient.users.getCredentials({ id: user.id! }), (credentials) => { setUserCredentials(credentials); const groupedCredentials = credentials.reduce((r, a) => { r[a.type!] = r[a.type!] || []; r[a.type!].push(a); return r; }, Object.create(null)); const groupedCredentialsArray = Object.keys(groupedCredentials).map( (key) => ({ key, value: groupedCredentials[key] }), ); setGroupedUserCredentials( groupedCredentialsArray.map((groupedCredential) => ({ ...groupedCredential, isExpanded: false, })), ); }, [key], ); const passwordTypeFinder = userCredentials.find( (credential) => credential.type === "password", ); const toggleModal = () => setIsOpen(!isOpen); const toggleCredentialsResetModal = () => { setOpenCredentialReset(!openCredentialReset); }; const resetPassword = () => { setIsResetPassword(true); toggleModal(); }; const [toggleDeleteDialog, DeleteConfirm] = useConfirmDialog({ titleKey: t("deleteCredentialsConfirmTitle"), messageKey: t("deleteCredentialsConfirm"), continueButtonLabel: t("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("deleteCredentialsError", error); } }, }); const Row = ({ credential }: { credential: CredentialRepresentation }) => ( { setSelectedCredential(credential); toggleDeleteDialog(); }} resetPassword={resetPassword} > { setIsUserLabelEdit({ status: !isUserLabelEdit?.status, rowKey: credential.id!, }); if (isUserLabelEdit?.status) { refresh(); } }} /> ); const itemOrder = useMemo( () => groupedUserCredentials.flatMap((groupedCredential) => [ groupedCredential.value.map(({ id }) => id).toString(), ...(groupedCredential.isExpanded ? groupedCredential.value.map((c) => c.id!) : []), ]), [groupedUserCredentials], ); const onDragStart = (evt: ReactDragEvent) => { evt.dataTransfer.effectAllowed = "move"; evt.dataTransfer.setData("text/plain", evt.currentTarget.id); const draggedItemId = evt.currentTarget.id; evt.currentTarget.classList.add(styles.modifiers.ghostRow); evt.currentTarget.setAttribute("aria-pressed", "true"); setState({ ...state, draggedItemId, dragging: true }); }; const moveItem = (items: string[], targetItem: string, toIndex: number) => { const fromIndex = items.indexOf(targetItem); if (fromIndex === toIndex) { return items; } const result = [...items]; result.splice(toIndex, 0, result.splice(fromIndex, 1)[0]); return result; }; const move = (itemOrder: string[]) => { if (!bodyRef.current) return; const ulNode = bodyRef.current; const nodes = Array.from(ulNode.children); if (nodes.every(({ id }, i) => id === itemOrder[i])) { return; } ulNode.replaceChildren(); itemOrder.forEach((itemId) => { ulNode.appendChild(nodes.find(({ id }) => id === itemId)!); }); }; const onDragCancel = () => { if (!bodyRef.current) return; Array.from(bodyRef.current.children).forEach((el) => { el.classList.remove(styles.modifiers.ghostRow); el.setAttribute("aria-pressed", "false"); }); setState({ ...state, draggedItemId: "", draggingToItemIndex: -1, dragging: false, }); }; const onDragLeave = (evt: ReactDragEvent) => { if (!isValidDrop(evt)) { move(itemOrder); setState({ ...state, draggingToItemIndex: -1 }); } }; const isValidDrop = (evt: ReactDragEvent) => { if (!bodyRef.current) return false; const ulRect = bodyRef.current.getBoundingClientRect(); return ( evt.clientX > ulRect.x && evt.clientX < ulRect.x + ulRect.width && evt.clientY > ulRect.y && evt.clientY < ulRect.y + ulRect.height ); }; const onDrop = (evt: ReactDragEvent) => { if (isValidDrop(evt)) { onDragFinish(state.draggedItemId, state.tempItemOrder); } else { onDragCancel(); } }; const onDragOver = (evt: ReactDragEvent) => { evt.preventDefault(); const td = evt.target as HTMLTableCellElement; const curListItem = td.closest("tr"); if ( !curListItem || (bodyRef.current && !bodyRef.current.contains(curListItem)) || curListItem.id === state.draggedItemId ) { return; } else { const dragId = curListItem.id; const draggingToItemIndex = Array.from( bodyRef.current?.children || [], ).findIndex((item) => item.id === dragId); if (draggingToItemIndex === state.draggingToItemIndex) { return; } const tempItemOrder = moveItem( itemOrder, state.draggedItemId, draggingToItemIndex, ); move(tempItemOrder); setState({ ...state, draggingToItemIndex, tempItemOrder, }); } }; const onDragEnd = ({ target }: ReactDragEvent) => { if (!(target instanceof HTMLTableRowElement)) { return; } target.classList.remove(styles.modifiers.ghostRow); target.setAttribute("aria-pressed", "false"); setState({ ...state, draggedItemId: "", draggingToItemIndex: -1, dragging: false, }); }; const onDragFinish = async (dragged: string, newOrder: string[]) => { const oldIndex = itemOrder.findIndex((key) => key === dragged); const newIndex = newOrder.findIndex((key) => key === dragged); const times = newIndex - oldIndex; const ids = dragged.split(","); try { for (const id of ids) for (let index = 0; index < Math.abs(times); index++) { if (times > 0) { await adminClient.users.moveCredentialPositionDown({ id: user.id!, credentialId: id, newPreviousCredentialId: itemOrder[newIndex], }); } else { await adminClient.users.moveCredentialPositionUp({ id: user.id!, credentialId: id, }); } } refresh(); addAlert(t("updatedCredentialMoveSuccess"), AlertVariant.success); } catch (error) { addError("updatedCredentialMoveError", error); } }; const useFederatedCredentials = user.federationLink || user.origin; const [credentialTypes, setCredentialTypes] = useState([]); useFetch( () => adminClient.users.getUserStorageCredentialTypes({ id: user.id! }), setCredentialTypes, [], ); if (!credentialTypes) { return ; } const hasCredentialTypes = credentialTypes.length > 0; const noCredentials = groupedUserCredentials.length === 0; const noFederatedCredentials = !user.credentials || user.credentials.length === 0; const emptyState = noCredentials && noFederatedCredentials && !hasCredentialTypes; return ( <> {isOpen && ( setIsOpen(false)} /> )} {openCredentialReset && ( setOpenCredentialReset(false)} /> )} {user.email && !emptyState && ( )} {userCredentials.length !== 0 && passwordTypeFinder === undefined && ( <> )} {groupedUserCredentials.length !== 0 && ( {t("type")} {t("userLabel")} {t("createdAt")} {t("data")} {groupedUserCredentials.map((groupedCredential, rowIndex) => ( id).toString()} draggable={groupedUserCredentials.length > 1} onDrop={onDrop} onDragEnd={onDragEnd} onDragStart={onDragStart} > id, )}`, }} /> {groupedCredential.value.length > 1 ? ( { const rows = groupedUserCredentials.map( (credential, index) => index === rowIndex ? { ...credential, isExpanded: !credential.isExpanded, } : credential, ); setGroupedUserCredentials(rows); }, }} /> ) : ( )} {toUpperCase(groupedCredential.key)} {groupedCredential.value.length <= 1 && groupedCredential.value.map((credential) => ( ))} {groupedCredential.isExpanded && groupedCredential.value.map((credential) => ( id, )}`, }} /> {toUpperCase(credential.type!)} ))} ))} )} {useFederatedCredentials && hasCredentialTypes && ( {t("type")} {t("providedBy")} {credentialTypes.map((credential) => ( {credential} {credential === "password" && ( )} ))} )} {emptyState && ( )} ); };