Users: Add Identity Provider Links tab (#1086)
This commit is contained in:
parent
299993d30b
commit
8bfd78cea9
6 changed files with 493 additions and 0 deletions
170
src/user/UserIdPModal.tsx
Normal file
170
src/user/UserIdPModal.tsx
Normal 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>
|
||||
);
|
||||
};
|
262
src/user/UserIdentityProviderLinks.tsx
Normal file
262
src/user/UserIdentityProviderLinks.tsx
Normal 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>
|
||||
</>
|
||||
);
|
||||
};
|
|
@ -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 && (
|
||||
|
|
|
@ -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.",
|
||||
},
|
||||
};
|
||||
|
|
|
@ -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.",
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
|
|
Loading…
Reference in a new issue