Users: Add Identity Provider Links tab (#1086)

This commit is contained in:
Jenny 2021-08-31 04:13:25 -04:00 committed by GitHub
parent 299993d30b
commit 8bfd78cea9
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
6 changed files with 493 additions and 0 deletions

170
src/user/UserIdPModal.tsx Normal file
View file

@ -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<UserParams>();
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 (
<Modal
variant={ModalVariant.small}
title={t("users:linkAccountTitle", {
provider: _.capitalize(federatedId),
})}
isOpen={true}
onClose={handleModalToggle}
actions={[
<Button
data-testid={t("link")}
key="confirm"
variant="primary"
type="submit"
form="group-form"
isDisabled={!formState.isValid}
>
{t("link")}
</Button>,
<Button
id="modal-cancel"
key="cancel"
variant={ButtonVariant.link}
onClick={() => {
handleModalToggle();
}}
>
{t("common:cancel")}
</Button>,
]}
>
<Form id="group-form" onSubmit={handleSubmit(submitForm)}>
<FormGroup
name="idp-name-group"
label={t("users:identityProvider")}
fieldId="idp-name"
helperTextInvalid={t("common:required")}
validated={
errors.identityProvider
? ValidatedOptions.error
: ValidatedOptions.default
}
>
<TextInput
data-testid="idpNameInput"
aria-label="Identity provider name input"
ref={register({ required: true })}
autoFocus
isReadOnly
type="text"
id="link-idp-name"
name="identityProvider"
value={_.capitalize(federatedId)}
validated={
errors.identityProvider
? ValidatedOptions.error
: ValidatedOptions.default
}
/>
</FormGroup>
<FormGroup
name="user-id-group"
label={t("users:userID")}
fieldId="user-id"
helperText={t("users-help:userIdHelperText")}
helperTextInvalid={t("common:required")}
validated={
errors.userId ? ValidatedOptions.error : ValidatedOptions.default
}
isRequired
>
<TextInput
data-testid="userIdInput"
aria-label="user ID input"
ref={register({ required: true })}
autoFocus
type="text"
id="link-idp-user-id"
name="userId"
validated={
errors.userId ? ValidatedOptions.error : ValidatedOptions.default
}
/>
</FormGroup>
<FormGroup
name="username-group"
label={t("users:username")}
fieldId="username"
helperText={t("users-help:usernameHelperText")}
helperTextInvalid={t("common:required")}
validated={
errors.name ? ValidatedOptions.error : ValidatedOptions.default
}
isRequired
>
<TextInput
data-testid="usernameInput"
aria-label="username input"
ref={register({ required: true })}
autoFocus
type="text"
id="link-idp-username"
name="userName"
validated={
errors.name ? ValidatedOptions.error : ValidatedOptions.default
}
/>
</FormGroup>
</Form>
</Modal>
);
};

View file

@ -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 (
<Link
to={toIdentityProviderTab({
realm,
id: idp.identityProvider!,
tab: "settings",
})}
>
{_.capitalize(idp.identityProvider)}
</Link>
);
};
const badgeRenderer1 = (idp: FederatedIdentityRepresentation) => {
const groupName = identityProviders?.find(
(provider) => provider["id"] === idp.identityProvider
)?.groupName!;
return (
<Label color={groupName === "Social" ? "blue" : "orange"}>
{groupName === "Social" ? "Social" : "Custom"}
</Label>
);
};
const badgeRenderer2 = (idp: IdentityProviderRepresentation) => {
const groupName = identityProviders?.find(
(provider) => provider["id"] === idp.providerId
)?.groupName!;
return (
<Label color={groupName === "User-defined" ? "orange" : "blue"}>
{groupName === "User-defined" ? "Custom" : groupName!}
</Label>
);
};
const unlinkRenderer = (fedIdentity: FederatedIdentityRepresentation) => {
return (
<Button
variant="link"
onClick={() => {
setFederatedId(fedIdentity.identityProvider!);
toggleUnlinkDialog();
}}
>
{t("unlinkAccount")}
</Button>
);
};
const linkRenderer = (idp: IdentityProviderRepresentation) => {
return (
<Button
variant="link"
onClick={() => {
setFederatedId(idp.alias!);
setIsLinkIdPModalOpen(true);
}}
>
{t("linkAccount")}
</Button>
);
};
return (
<>
{isLinkIdPModalOpen && (
<UserIdpModal
federatedId={federatedId}
handleModalToggle={handleModalToggle}
refresh={refresh}
/>
)}
<UnlinkConfirm />
<PageSection variant="light">
<FormPanel title={t("linkedIdPs")} className="kc-linked-idps">
<TextContent>
<Text className="kc-available-idps-text">
{t("linkedIdPsText")}
</Text>
</TextContent>
<KeycloakDataTable
loader={linkedIdPsLoader}
key={key}
isPaginated={false}
ariaLabelKey="users:LinkedIdPs"
className="kc-linked-IdPs-table"
columns={[
{
name: "identityProvider",
displayKey: "common:name",
cellFormatters: [emptyFormatter()],
cellRenderer: idpLinkRenderer,
transforms: [cellWidth(20)],
},
{
name: "type",
displayKey: "common:type",
cellFormatters: [emptyFormatter()],
cellRenderer: badgeRenderer1,
transforms: [cellWidth(10)],
},
{
name: "userId",
displayKey: "users:userID",
cellFormatters: [emptyFormatter()],
transforms: [cellWidth(30)],
},
{
name: "userName",
displayKey: "users:username",
cellFormatters: [emptyFormatter()],
transforms: [cellWidth(20)],
},
{
name: "",
cellFormatters: [emptyFormatter()],
cellRenderer: unlinkRenderer,
transforms: [cellWidth(20)],
},
]}
emptyState={
<TextContent className="kc-no-providers-text">
<Text>{t("users:noProvidersLinked")}</Text>
</TextContent>
}
/>
</FormPanel>
<FormPanel className="kc-available-idps" title={t("availableIdPs")}>
<TextContent>
<Text className="kc-available-idps-text">
{t("availableIdPsText")}
</Text>
</TextContent>
<KeycloakDataTable
loader={availableIdPsLoader}
key={key}
isPaginated={false}
ariaLabelKey="users:LinkedIdPs"
className="kc-linked-IdPs-table"
columns={[
{
name: "alias",
displayKey: "common:name",
cellFormatters: [emptyFormatter(), upperCaseFormatter()],
transforms: [cellWidth(20)],
},
{
name: "type",
displayKey: "common:type",
cellFormatters: [emptyFormatter()],
cellRenderer: badgeRenderer2,
transforms: [cellWidth(60)],
},
{
name: "",
cellFormatters: [emptyFormatter()],
cellRenderer: linkRenderer,
},
]}
emptyState={
<TextContent className="kc-no-providers-text">
<Text>{t("users:noAvailableIdentityProviders")}</Text>
</TextContent>
}
/>
</FormPanel>
</PageSection>
</>
);
};

View file

@ -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 = () => {
>
<UserConsents />
</Tab>
<Tab
eventKey="identity-provider-links"
data-testid="identity-provider-links-tab"
title={
<TabTitleText>{t("users:identityProviderLinks")}</TabTitleText>
}
>
<UserIdentityProviderLinks />
</Tab>
</KeycloakTabs>
)}
{!id && (

View file

@ -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.",
},
};

View file

@ -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.",

View file

@ -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;
}