diff --git a/src/user/UserIdPModal.tsx b/src/user/UserIdPModal.tsx new file mode 100644 index 0000000000..5d851cf825 --- /dev/null +++ b/src/user/UserIdPModal.tsx @@ -0,0 +1,170 @@ +import React from "react"; +import { + AlertVariant, + Button, + ButtonVariant, + Form, + FormGroup, + Modal, + ModalVariant, + TextInput, + ValidatedOptions, +} from "@patternfly/react-core"; +import { useTranslation } from "react-i18next"; +import { useForm } from "react-hook-form"; + +import type GroupRepresentation from "@keycloak/keycloak-admin-client/lib/defs/groupRepresentation"; +import { useAdminClient } from "../context/auth/AdminClient"; +import { useAlerts } from "../components/alert/Alerts"; +import _ from "lodash"; +import { useParams } from "react-router-dom"; +import type FederatedIdentityRepresentation from "@keycloak/keycloak-admin-client/lib/defs/federatedIdentityRepresentation"; +import type { UserParams } from "./routes/User"; + +type UserIdpModalProps = { + federatedId?: string; + handleModalToggle: () => void; + refresh: (group?: GroupRepresentation) => void; +}; + +export const UserIdpModal = ({ + federatedId, + handleModalToggle, + refresh, +}: UserIdpModalProps) => { + const { t } = useTranslation("users"); + const adminClient = useAdminClient(); + const { addAlert, addError } = useAlerts(); + const { register, errors, handleSubmit, formState } = useForm({ + mode: "onChange", + }); + + const { id } = useParams(); + + const submitForm = async (fedIdentity: FederatedIdentityRepresentation) => { + try { + await adminClient.users.addToFederatedIdentity({ + id: id!, + federatedIdentityId: federatedId!, + federatedIdentity: fedIdentity, + }); + addAlert(t("users:idpLinkSuccess"), AlertVariant.success); + handleModalToggle(); + refresh(); + } catch (error) { + addError("users:couldNotLinkIdP", error); + } + }; + + return ( + + {t("link")} + , + , + ]} + > +
+ + + + + + + + + +
+
+ ); +}; diff --git a/src/user/UserIdentityProviderLinks.tsx b/src/user/UserIdentityProviderLinks.tsx new file mode 100644 index 0000000000..ea696252e9 --- /dev/null +++ b/src/user/UserIdentityProviderLinks.tsx @@ -0,0 +1,262 @@ +import React, { useState } from "react"; +import { useTranslation } from "react-i18next"; +import { + AlertVariant, + Button, + ButtonVariant, + Label, + PageSection, + Text, + TextContent, +} from "@patternfly/react-core"; +import { FormPanel } from "../components/scroll-form/FormPanel"; +import { KeycloakDataTable } from "../components/table-toolbar/KeycloakDataTable"; +import { cellWidth } from "@patternfly/react-table"; +import { Link, useParams } from "react-router-dom"; +import { useAdminClient } from "../context/auth/AdminClient"; +import { emptyFormatter, upperCaseFormatter } from "../util"; +import type IdentityProviderRepresentation from "@keycloak/keycloak-admin-client/lib/defs/identityProviderRepresentation"; +import type FederatedIdentityRepresentation from "@keycloak/keycloak-admin-client/lib/defs/federatedIdentityRepresentation"; +import { useRealm } from "../context/realm-context/RealmContext"; +import { useServerInfo } from "../context/server-info/ServerInfoProvider"; +import _ from "lodash"; +import { useConfirmDialog } from "../components/confirm-dialog/ConfirmDialog"; +import { useAlerts } from "../components/alert/Alerts"; +import { UserIdpModal } from "./UserIdPModal"; +import { toIdentityProviderTab } from "../identity-providers/routes/IdentityProviderTab"; + +export const UserIdentityProviderLinks = () => { + const [key, setKey] = useState(0); + const [federatedId, setFederatedId] = useState(""); + const [isLinkIdPModalOpen, setIsLinkIdPModalOpen] = useState(false); + + const adminClient = useAdminClient(); + const { id } = useParams<{ id: string }>(); + const { realm } = useRealm(); + const { addAlert, addError } = useAlerts(); + const { t } = useTranslation("users"); + + const refresh = () => setKey(new Date().getTime()); + + const handleModalToggle = () => { + setIsLinkIdPModalOpen(!isLinkIdPModalOpen); + }; + + const identityProviders = useServerInfo().identityProviders; + + const getFederatedIdentities = async () => { + return await adminClient.users.listFederatedIdentities({ id }); + }; + + const getAvailableIdPs = async () => { + return (await adminClient.realms.findOne({ realm })).identityProviders; + }; + + const linkedIdPsLoader = async () => { + return getFederatedIdentities(); + }; + + const availableIdPsLoader = async () => { + const linkedNames = (await getFederatedIdentities()).map( + (x) => x.identityProvider + ); + + return (await getAvailableIdPs())?.filter( + (item) => !linkedNames.includes(item.alias) + )!; + }; + + const [toggleUnlinkDialog, UnlinkConfirm] = useConfirmDialog({ + titleKey: t("users:unlinkAccountTitle", { + provider: _.capitalize(federatedId), + }), + messageKey: t("users:unlinkAccountConfirm", { + provider: _.capitalize(federatedId), + }), + continueButtonLabel: "users:unlink", + continueButtonVariant: ButtonVariant.primary, + onConfirm: async () => { + try { + await adminClient.users.delFromFederatedIdentity({ + id, + federatedIdentityId: federatedId, + }); + addAlert(t("common:mappingDeletedSuccess"), AlertVariant.success); + refresh(); + } catch (error) { + addError("common:mappingDeletedError", error); + } + }, + }); + + const idpLinkRenderer = (idp: FederatedIdentityRepresentation) => { + return ( + + {_.capitalize(idp.identityProvider)} + + ); + }; + + const badgeRenderer1 = (idp: FederatedIdentityRepresentation) => { + const groupName = identityProviders?.find( + (provider) => provider["id"] === idp.identityProvider + )?.groupName!; + return ( + + ); + }; + + const badgeRenderer2 = (idp: IdentityProviderRepresentation) => { + const groupName = identityProviders?.find( + (provider) => provider["id"] === idp.providerId + )?.groupName!; + return ( + + ); + }; + + const unlinkRenderer = (fedIdentity: FederatedIdentityRepresentation) => { + return ( + + ); + }; + + const linkRenderer = (idp: IdentityProviderRepresentation) => { + return ( + + ); + }; + + return ( + <> + {isLinkIdPModalOpen && ( + + )} + + + + + + {t("linkedIdPsText")} + + + + {t("users:noProvidersLinked")} + + } + /> + + + + + {t("availableIdPsText")} + + + + {t("users:noAvailableIdentityProviders")} + + } + /> + + + + ); +}; diff --git a/src/user/UsersTabs.tsx b/src/user/UsersTabs.tsx index 97a83b5eec..e30ec5d7d3 100644 --- a/src/user/UsersTabs.tsx +++ b/src/user/UsersTabs.tsx @@ -19,6 +19,7 @@ import { UserGroups } from "./UserGroups"; import { UserConsents } from "./UserConsents"; import type GroupRepresentation from "@keycloak/keycloak-admin-client/lib/defs/groupRepresentation"; import { useRealm } from "../context/realm-context/RealmContext"; +import { UserIdentityProviderLinks } from "./UserIdentityProviderLinks"; export const UsersTabs = () => { const { t } = useTranslation("roles"); @@ -103,6 +104,15 @@ export const UsersTabs = () => { > + {t("users:identityProviderLinks")} + } + > + + )} {!id && ( diff --git a/src/user/help.ts b/src/user/help.ts index 97585f5403..12911a10c8 100644 --- a/src/user/help.ts +++ b/src/user/help.ts @@ -6,5 +6,9 @@ export default { "Require an action when the user logs in. 'Verify email' sends an email to the user to verify their email address. 'Update profile' requires user to enter in new personal information. 'Update password' requires user to enter in a new password. 'Configure OTP' requires setup of a mobile password generator.", groups: "Groups where the user has membership. To leave a group, select it and click Leave.", + userIdHelperText: + "Enter the unique ID of the user for this identity provider.", + usernameHelperText: + "Enter the username of the user for this identity provider.", }, }; diff --git a/src/user/messages.ts b/src/user/messages.ts index f2262d8590..3279d4c917 100644 --- a/src/user/messages.ts +++ b/src/user/messages.ts @@ -49,12 +49,23 @@ export default { "Are you sure you want to permanently delete {{count}} selected user", deleteConfirmDialog_plural: "Are you sure you want to permanently delete {{count}} selected users", + userID: "User ID", userCreated: "The user has been created", userSaved: "The user has been saved", userDetails: "User details", userCreateError: "Could not create user: {{error}}", userDeletedSuccess: "The user has been deleted", userDeletedError: "The user could not be deleted {{error}}", + linkAccount: "Link account", + unlink: "Unlink", + unlinkAccount: "Unlink account", + unlinkAccountTitle: "Unlink account from {{provider}}?", + unlinkAccountConfirm: + "Are you sure you want to permanently unlink this account from {{provider}}?", + link: "Link", + linkAccountTitle: "Link account to {{provider}}?", + idpLinkSuccess: "Identity provider has been linked", + couldNotLinkIdP: "Could not link identity provider {{error}}", configureOTP: "Configure OTP", updatePassword: "Update Password", updateProfile: "Update Profile", @@ -64,6 +75,17 @@ export default { noConsents: "No consents", noConsentsText: "The consents will only be recorded when users try to access a client that is configured to require consent. In that case, users will get a consent page which asks them to grant access to the client.", + identityProvider: "Identity provider", + identityProviderLinks: "Identity provider links", + noProvidersLinked: + "No identity providers linked. Choose one from the list below.", + noAvailableIdentityProviders: "No available identity providers.", + linkedIdPs: "Linked identity providers", + linkedIdPsText: + "The identity providers which are already linked to this user account", + availableIdPs: "Available identity providers", + availableIdPsText: + "All the configured identity providers in this realm are listed here. You can link the user account to any of the IdP accounts.", whoWillAppearLinkText: "Who will appear in this group list?", whoWillAppearPopoverText: "Groups are hierarchical. When you select Direct Membership, you see only the child group that the user joined. Ancestor groups are not included.", diff --git a/src/user/user-section.css b/src/user/user-section.css index fbe2871ba7..117c05ad6d 100644 --- a/src/user/user-section.css +++ b/src/user/user-section.css @@ -88,3 +88,28 @@ div.pf-c-chip-group.kc-consents-chip-group margin-left: var(--pf-global--spacer--md); color: var(--pf-global--Color--400); } + +article.pf-c-card.pf-m-flat.kc-linked-idps, +article.pf-c-card.pf-m-flat.kc-available-idps { + border: 0px; +} + +article.pf-c-card.pf-m-flat.kc-linked-idps > div > div > h1 { + margin-bottom: 0px; +} + +article.pf-c-card.pf-m-flat.kc-available-idps > div > div > h1 { + margin-bottom: 0px; +} + +.kc-available-idps-text { + color: var(--pf-global--Color--200); +} + +.kc-linked-idps-text { + color: var(--pf-global--Color--200); +} + +.kc-no-providers-text { + text-align: center; +}