Identity Providers(Mappers): Implement "Add Mapper" Screen/Functionality for OIDC and SAML IdPs (#1118)

This commit is contained in:
Jenny 2021-09-13 05:17:00 -04:00 committed by GitHub
parent 7414e174b6
commit a617c4ab12
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
14 changed files with 823 additions and 90 deletions

View file

@ -7,6 +7,7 @@ import ListingPage from "../support/pages/admin_console/ListingPage";
import CreateProviderPage from "../support/pages/admin_console/manage/identity_providers/CreateProviderPage"; import CreateProviderPage from "../support/pages/admin_console/manage/identity_providers/CreateProviderPage";
import ModalUtils from "../support/util/ModalUtils"; import ModalUtils from "../support/util/ModalUtils";
import OrderDialog from "../support/pages/admin_console/manage/identity_providers/OrderDialog"; import OrderDialog from "../support/pages/admin_console/manage/identity_providers/OrderDialog";
import AddMapperPage from "../support/pages/admin_console/manage/identity_providers/AddMapperPage";
describe("Identity provider test", () => { describe("Identity provider test", () => {
const loginPage = new LoginPage(); const loginPage = new LoginPage();
@ -14,7 +15,10 @@ describe("Identity provider test", () => {
const masthead = new Masthead(); const masthead = new Masthead();
const listingPage = new ListingPage(); const listingPage = new ListingPage();
const createProviderPage = new CreateProviderPage(); const createProviderPage = new CreateProviderPage();
const addMapperPage = new AddMapperPage();
const createSuccessMsg = "Identity provider successfully created"; const createSuccessMsg = "Identity provider successfully created";
const createMapperSuccessMsg = "Mapper created successfully.";
const changeSuccessMsg = const changeSuccessMsg =
"Successfully changed display order of identity providers"; "Successfully changed display order of identity providers";
const deletePrompt = "Delete provider?"; const deletePrompt = "Delete provider?";
@ -55,20 +59,20 @@ describe("Identity provider test", () => {
modalUtils.checkModalTitle(deletePrompt).confirmModal(); modalUtils.checkModalTitle(deletePrompt).confirmModal();
masthead.checkNotificationMessage(deleteSuccessMsg); masthead.checkNotificationMessage(deleteSuccessMsg);
});
createProviderPage.checkGitHubCardVisible(); it("should create facebook provider", () => {
createProviderPage
.clickCard("facebook")
.fill("facebook", "123")
.clickAdd();
masthead.checkNotificationMessage(createSuccessMsg);
}); });
it("should change order of providers", () => { it("should change order of providers", () => {
const orderDialog = new OrderDialog(); const orderDialog = new OrderDialog();
const providers = ["facebook", identityProviderName, "bitbucket"]; const providers = ["facebook", identityProviderName, "bitbucket"];
createProviderPage
.clickCard("facebook")
.fill("facebook", "123")
.clickAdd();
cy.wait(2000);
sidebarPage.goToIdentityProviders(); sidebarPage.goToIdentityProviders();
listingPage.itemExist("facebook"); listingPage.itemExist("facebook");
@ -125,6 +129,36 @@ describe("Identity provider test", () => {
masthead.checkNotificationMessage(createSuccessMsg); masthead.checkNotificationMessage(createSuccessMsg);
}); });
it("should add facebook social mapper", () => {
sidebarPage.goToIdentityProviders();
listingPage.goToItemDetails("facebook");
addMapperPage.goToMappersTab();
addMapperPage.clickAdd();
addMapperPage.fillSocialMapper("facebook mapper");
addMapperPage.saveNewMapper();
masthead.checkNotificationMessage(createMapperSuccessMsg);
});
it("should add SAML mapper", () => {
sidebarPage.goToIdentityProviders();
listingPage.goToItemDetails("saml");
addMapperPage.goToMappersTab();
addMapperPage.clickAdd();
addMapperPage.fillSAMLorOIDCMapper("SAML mapper");
masthead.checkNotificationMessage(createMapperSuccessMsg);
});
it("clean up providers", () => { it("clean up providers", () => {
const modalUtils = new ModalUtils(); const modalUtils = new ModalUtils();

View file

@ -0,0 +1,114 @@
export default class AddMapperPage {
private mappersTab = "mappers-tab";
private noMappersAddMapperButton = "no-mappers-empty-action";
private idpMapperSelectToggle = "#identityProviderMapper";
private idpMapperSelect = "idp-mapper-select";
private mapperNameInput = "#kc-name";
private mapperRoleInput = "mapper-role-input";
private userSessionAttribute = "user-session-attribute";
private userSessionAttributeValue = "user-session-attribute-value";
private newMapperSaveButton = "new-mapper-save-button";
private regexAttributeValuesSwitch = "regex-attribute-values-switch";
private syncmodeSelectToggle = "#syncMode";
private attributesKeyInput = 'input[name="config.attributes[0].key"]';
private attributesValueInput = 'input[name="config.attributes[0].value"]';
private selectRoleButton = "select-role-button";
private radio = "[type=radio]";
private addAssociatedRolesModalButton = "add-associated-roles-button";
goToMappersTab() {
cy.getId(this.mappersTab).click();
return this;
}
clickAdd() {
cy.getId(this.noMappersAddMapperButton).click();
return this;
}
clickCreateDropdown() {
cy.contains("Add provider").click();
return this;
}
saveNewMapper() {
cy.getId(this.newMapperSaveButton).click();
return this;
}
toggleSwitch(switchName: string) {
cy.getId(switchName).click({ force: true });
return this;
}
fillSocialMapper(name: string) {
cy.get(this.mapperNameInput).clear();
cy.get(this.mapperNameInput).clear().type(name);
cy.get(this.syncmodeSelectToggle).click();
cy.getId("legacy").click();
cy.get(this.idpMapperSelectToggle).click();
cy.getId(this.idpMapperSelect).contains("Attribute Importer").click();
cy.getId(this.userSessionAttribute).clear();
cy.getId(this.userSessionAttribute).type("user session attribute");
cy.getId(this.userSessionAttributeValue).clear();
cy.getId(this.userSessionAttributeValue).type(
"user session attribute value"
);
return this;
}
addRoleToMapperForm() {
const load = "/auth/admin/realms/master/roles";
cy.intercept(load).as("load");
cy.get(this.radio).eq(0).check();
cy.getId(this.addAssociatedRolesModalButton).contains("Add").click();
cy.getId(this.mapperRoleInput).should("have.value", "admin");
return this;
}
fillSAMLorOIDCMapper(name: string) {
cy.get(this.mapperNameInput).clear();
cy.get(this.mapperNameInput).clear().type(name);
cy.get(this.syncmodeSelectToggle).click();
cy.getId("inherit").click();
cy.get(this.idpMapperSelectToggle).click();
cy.getId(this.idpMapperSelect)
.contains("Hardcoded User Session Attribute")
.click();
cy.get(this.attributesKeyInput).clear();
cy.get(this.attributesKeyInput).type("key");
cy.get(this.attributesValueInput).clear();
cy.get(this.attributesValueInput).type("value");
this.toggleSwitch(this.regexAttributeValuesSwitch);
cy.getId(this.selectRoleButton).click();
this.addRoleToMapperForm();
this.saveNewMapper();
return this;
}
}

1
package-lock.json generated
View file

@ -5,6 +5,7 @@
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "keycloak-admin-ui",
"version": "0.0.1", "version": "0.0.1",
"license": "Apache", "license": "Apache",
"dependencies": { "dependencies": {

View file

@ -24,8 +24,8 @@ export type AttributeForm = {
export type AttributesFormProps = { export type AttributesFormProps = {
form: UseFormMethods<AttributeForm>; form: UseFormMethods<AttributeForm>;
save: (model: AttributeForm) => void; save?: (model: AttributeForm) => void;
reset: () => void; reset?: () => void;
array: { array: {
fields: Partial<ArrayField<Record<string, any>, "id">>[]; fields: Partial<ArrayField<Record<string, any>, "id">>[];
append: ( append: (
@ -34,6 +34,7 @@ export type AttributesFormProps = {
) => void; ) => void;
remove: (index?: number | number[] | undefined) => void; remove: (index?: number | number[] | undefined) => void;
}; };
inConfig?: boolean;
}; };
export const arrayToAttributes = (attributeArray: KeyValueType[]) => { export const arrayToAttributes = (attributeArray: KeyValueType[]) => {
@ -61,12 +62,15 @@ export const AttributesForm = ({
array: { fields, append, remove }, array: { fields, append, remove },
reset, reset,
save, save,
inConfig,
}: AttributesFormProps) => { }: AttributesFormProps) => {
const { t } = useTranslation("roles"); const { t } = useTranslation("roles");
const columns = ["Key", "Value"]; const columns = ["Key", "Value"];
const watchLast = watch(`attributes[${fields.length - 1}].key`, ""); const watchLast = inConfig
? watch(`config.attributes[${fields.length - 1}].key`, "")
: watch(`attributes[${fields.length - 1}].key`, "");
useEffect(() => { useEffect(() => {
if (fields.length === 0) { if (fields.length === 0) {
@ -74,8 +78,13 @@ export const AttributesForm = ({
} }
}, [fields]); }, [fields]);
const noSaveCancelButtons = !save && !reset;
return ( return (
<FormAccess role="manage-realm" onSubmit={handleSubmit(save)}> <FormAccess
role="manage-realm"
onSubmit={save ? handleSubmit(save) : undefined}
>
<TableComposable <TableComposable
className="kc-attributes__table" className="kc-attributes__table"
aria-label="Role attribute keys and values" aria-label="Role attribute keys and values"
@ -101,7 +110,11 @@ export const AttributesForm = ({
dataLabel={columns[0]} dataLabel={columns[0]}
> >
<TextInput <TextInput
name={`attributes[${rowIndex}].key`} name={
inConfig
? `config.attributes[${rowIndex}].key`
: `attributes[${rowIndex}].key`
}
ref={register()} ref={register()}
aria-label="key-input" aria-label="key-input"
defaultValue={attribute.key} defaultValue={attribute.key}
@ -116,7 +129,11 @@ export const AttributesForm = ({
dataLabel={columns[1]} dataLabel={columns[1]}
> >
<TextInput <TextInput
name={`attributes[${rowIndex}].value`} name={
inConfig
? `config.attributes[${rowIndex}].value`
: `attributes[${rowIndex}].value`
}
ref={register()} ref={register()}
aria-label="value-input" aria-label="value-input"
defaultValue={attribute.value} defaultValue={attribute.value}
@ -173,14 +190,20 @@ export const AttributesForm = ({
</Tr> </Tr>
</Tbody> </Tbody>
</TableComposable> </TableComposable>
<ActionGroup className="kc-attributes__action-group"> {!noSaveCancelButtons && (
<Button variant="primary" type="submit" isDisabled={!watchLast}> <ActionGroup className="kc-attributes__action-group">
{t("common:save")} <Button variant="primary" type="submit" isDisabled={!watchLast}>
</Button> {t("common:save")}
<Button onClick={reset} variant="link" isDisabled={!formState.isDirty}> </Button>
{t("common:revert")} <Button
</Button> onClick={reset}
</ActionGroup> variant="link"
isDisabled={!formState.isDirty}
>
{t("common:revert")}
</Button>
</ActionGroup>
)}
</FormAccess> </FormAccess>
); );
}; };

View file

@ -31,6 +31,7 @@ import { HelpItem } from "../help-enabler/HelpItem";
export type ViewHeaderProps = { export type ViewHeaderProps = {
titleKey: string; titleKey: string;
className?: string;
badges?: ViewHeaderBadge[]; badges?: ViewHeaderBadge[];
isDropdownDisabled?: boolean; isDropdownDisabled?: boolean;
subKey?: string | ReactNode; subKey?: string | ReactNode;
@ -53,6 +54,7 @@ export type ViewHeaderBadge = {
export const ViewHeader = ({ export const ViewHeader = ({
actionsDropdownId, actionsDropdownId,
className,
titleKey, titleKey,
badges, badges,
isDropdownDisabled, isDropdownDisabled,
@ -87,7 +89,9 @@ export const ViewHeader = ({
<Level> <Level>
<LevelItem> <LevelItem>
<TextContent className="pf-u-mr-sm"> <TextContent className="pf-u-mr-sm">
<Text component="h1">{t(titleKey)}</Text> <Text className={className} component="h1">
{t(titleKey)}
</Text>
</TextContent> </TextContent>
</LevelItem> </LevelItem>
{badges && ( {badges && (

View file

@ -0,0 +1,451 @@
import React, { useState } from "react";
import { useHistory, useParams } from "react-router-dom";
import { useTranslation } from "react-i18next";
import { Controller, useFieldArray, useForm } from "react-hook-form";
import {
ActionGroup,
AlertVariant,
Button,
FormGroup,
PageSection,
Select,
SelectOption,
SelectVariant,
Switch,
TextInput,
ValidatedOptions,
} from "@patternfly/react-core";
import { useRealm } from "../../context/realm-context/RealmContext";
import { HelpItem } from "../../components/help-enabler/HelpItem";
import { ViewHeader } from "../../components/view-header/ViewHeader";
import {
AttributesForm,
KeyValueType,
} from "../../components/attribute-form/AttributeForm";
import { FormAccess } from "../../components/form-access/FormAccess";
import { useAdminClient, useFetch } from "../../context/auth/AdminClient";
import type IdentityProviderMapperRepresentation from "@keycloak/keycloak-admin-client/lib/defs/identityProviderMapperRepresentation";
import type { IdentityProviderAddMapperParams } from "../routes/AddMapper";
import _ from "lodash";
import { AssociatedRolesModal } from "../../realm-roles/AssociatedRolesModal";
import type { RoleRepresentation } from "../../model/role-model";
import { useAlerts } from "../../components/alert/Alerts";
type IdPMapperRepresentationWithAttributes =
IdentityProviderMapperRepresentation & {
attributes: KeyValueType[];
};
export const AddMapper = () => {
const { t } = useTranslation("identity-providers");
const form = useForm<IdPMapperRepresentationWithAttributes>();
const { handleSubmit, control, register, errors } = form;
const { addAlert, addError } = useAlerts();
const history = useHistory();
const { realm } = useRealm();
const adminClient = useAdminClient();
const { providerId, alias } = useParams<IdentityProviderAddMapperParams>();
const isSAMLorOIDC = providerId === "saml" || providerId === "oidc";
const [mapperTypes, setMapperTypes] =
useState<Record<string, IdentityProviderMapperRepresentation>>();
const [roles, setRoles] = useState<RoleRepresentation[]>([]);
const [rolesModalOpen, setRolesModalOpen] = useState(false);
const save = async (idpMapper: IdentityProviderMapperRepresentation) => {
try {
await adminClient.identityProviders.createMapper({
identityProviderMapper: {
...idpMapper,
identityProviderAlias: alias,
config: {
...idpMapper.config,
attributes: JSON.stringify(idpMapper.config.attributes),
},
},
alias: alias!,
});
addAlert(t("mapperCreateSuccess"), AlertVariant.success);
} catch (error) {
addError(t("mapperCreateError"), error);
}
};
const { fields, append, remove } = useFieldArray({
control: form.control,
name: "attributes",
});
useFetch(
async () => {
const allMapperTypes =
await adminClient.identityProviders.findMapperTypes({
alias: alias!,
});
const allRoles = await adminClient.roles.find();
return { allMapperTypes, allRoles };
},
({ allMapperTypes, allRoles }) => {
setMapperTypes(allMapperTypes);
setRoles(allRoles);
},
[]
);
const syncModes = ["inherit", "import", "legacy", "force"];
const [syncModeOpen, setSyncModeOpen] = useState(false);
const [mapperTypeOpen, setMapperTypeOpen] = useState(false);
const [selectedRole, setSelectedRole] = useState<RoleRepresentation[]>([]);
const toggleModal = () => {
setRolesModalOpen(!rolesModalOpen);
};
return (
<PageSection variant="light">
<ViewHeader
className="kc-add-mapper-title"
titleKey={t("addIdPMapper")}
divider
/>
<AssociatedRolesModal
onConfirm={(role) => setSelectedRole(role)}
allRoles={roles}
open={rolesModalOpen}
omitComposites
isRadio
toggleDialog={toggleModal}
/>
<FormAccess
role="manage-identity-providers"
isHorizontal
onSubmit={handleSubmit(save)}
className="pf-u-mt-lg"
>
<FormGroup
label={t("common:name")}
labelIcon={
<HelpItem
id="name-help-icon"
helpText="identity-providers-help:name"
forLabel={t("common:name")}
forID={t(`common:helpLabel`, { label: t("common:name") })}
/>
}
fieldId="kc-name"
isRequired
validated={
errors.name ? ValidatedOptions.error : ValidatedOptions.default
}
helperTextInvalid={t("common:required")}
>
<TextInput
ref={register({ required: true })}
type="text"
datatest-id="name-input"
id="kc-name"
name="name"
validated={
errors.name ? ValidatedOptions.error : ValidatedOptions.default
}
/>
</FormGroup>
<FormGroup
label={t("syncModeOverride")}
isRequired
labelIcon={
<HelpItem
helpText="identity-providers-help:syncModeOverride"
forLabel={t("syncModeOverride")}
forID={t(`common:helpLabel`, { label: t("syncModeOverride") })}
/>
}
fieldId="syncMode"
>
<Controller
name="config.syncMode"
defaultValue={syncModes[0]}
control={control}
render={({ onChange, value }) => (
<Select
toggleId="syncMode"
datatest-id="syncmode-select"
required
direction="down"
onToggle={() => setSyncModeOpen(!syncModeOpen)}
onSelect={(_, value) => {
onChange(value.toString().toUpperCase());
setSyncModeOpen(false);
}}
selections={t(`syncModes.${value.toLowerCase()}`)}
variant={SelectVariant.single}
aria-label={t("syncMode")}
isOpen={syncModeOpen}
>
{syncModes.map((option) => (
<SelectOption
selected={option === value}
key={option}
data-testid={option}
value={option.toUpperCase()}
>
{t(`syncModes.${option}`)}
</SelectOption>
))}
</Select>
)}
/>
</FormGroup>
<FormGroup
label={t("mapperType")}
labelIcon={
<HelpItem
helpText="identity-providers-help:mapperType"
forLabel={t("mapperType")}
forID={t(`common:helpLabel`, { label: t("mapperType") })}
/>
}
fieldId="identityProviderMapper"
>
<Controller
name="identityProviderMapper"
defaultValue={"saml-advanced-role-idp-mapper"}
control={control}
render={({ onChange, value }) => (
<Select
toggleId="identityProviderMapper"
data-testid="idp-mapper-select"
required
direction="down"
onToggle={() => setMapperTypeOpen(!mapperTypeOpen)}
onSelect={(e, value) => {
const theMapper =
mapperTypes &&
Object.values(mapperTypes).find(
(item) =>
item.name?.toLowerCase() ===
value.toString().toLowerCase()
);
onChange(theMapper?.id);
setMapperTypeOpen(false);
}}
selections={
mapperTypes &&
Object.values(mapperTypes).find(
(item) => item.id?.toLowerCase() === value
)?.name
}
variant={SelectVariant.single}
aria-label={t("syncMode")}
isOpen={mapperTypeOpen}
>
{mapperTypes &&
Object.values(mapperTypes).map((option) => (
<SelectOption
selected={option === value}
datatest-id={option.id}
key={option.name}
value={option.name?.toUpperCase()}
>
{t(`mapperTypes.${_.camelCase(option.name)}`)}
</SelectOption>
))}
</Select>
)}
/>
</FormGroup>
{isSAMLorOIDC ? (
<>
{" "}
<FormGroup
label={t("common:attributes")}
labelIcon={
<HelpItem
helpText="identity-providers-help:attributes"
forLabel={t("attributes")}
forID={t(`common:helpLabel`, { label: t("attributes") })}
/>
}
fieldId="kc-gui-order"
>
<Controller
name="config.attributes"
defaultValue={"[]"}
control={control}
render={() => {
return (
<AttributesForm
form={form}
inConfig
array={{ fields, append, remove }}
/>
);
}}
/>
</FormGroup>
<FormGroup
label={t("regexAttributeValues")}
labelIcon={
<HelpItem
helpText="identity-providers-help:regexAttributeValues"
forLabel={t("regexAttributeValues")}
forID={t(`common:helpLabel`, {
label: t("regexAttributeValues"),
})}
/>
}
fieldId="regexAttributeValues"
>
<Controller
name="config.are-attribute-values-regex"
control={control}
defaultValue="false"
render={({ onChange, value }) => (
<Switch
id="regexAttributeValues"
data-testid="regex-attribute-values-switch"
label={t("common:on")}
labelOff={t("common:off")}
isChecked={value === "true"}
onChange={(value) => onChange("" + value)}
/>
)}
/>
</FormGroup>
<FormGroup
label={t("common:role")}
labelIcon={
<HelpItem
id="name-help-icon"
helpText="identity-providers-help:role"
forLabel={t("identity-providers-help:role")}
forID={t(`identity-providers:helpLabel`, {
label: t("role"),
})}
/>
}
fieldId="kc-role"
validated={
errors.config?.role
? ValidatedOptions.error
: ValidatedOptions.default
}
helperTextInvalid={t("common:required")}
>
<TextInput
ref={register({ required: true })}
type="text"
id="kc-role"
data-testid="mapper-role-input"
name="config.role"
value={selectedRole[0]?.name}
validated={
errors.config?.role
? ValidatedOptions.error
: ValidatedOptions.default
}
/>
<Button
data-testid="select-role-button"
onClick={() => toggleModal()}
>
{t("selectRole")}
</Button>
</FormGroup>{" "}
</>
) : (
<>
<FormGroup
label={t("userSessionAttribute")}
labelIcon={
<HelpItem
id="user-session-attribute-help-icon"
helpText="identity-providers-help:userSessionAttribute"
forLabel={t("userSessionAttribute")}
forID={t(`common:helpLabel`, {
label: t("userSessionAttribute"),
})}
/>
}
fieldId="kc-user-session-attribute"
isRequired
validated={
errors.name ? ValidatedOptions.error : ValidatedOptions.default
}
helperTextInvalid={t("common:required")}
>
<TextInput
ref={register({ required: true })}
type="text"
id="kc-attribute"
data-testid="user-session-attribute"
name="config.attribute"
validated={
errors.name
? ValidatedOptions.error
: ValidatedOptions.default
}
/>
</FormGroup>
<FormGroup
label={t("userSessionAttributeValue")}
labelIcon={
<HelpItem
id="user-session-attribute-value-help-icon"
helpText="identity-providers-help:userSessionAttributeValue"
forLabel={t("userSessionAttributeValue")}
forID={t(`common:helpLabel`, {
label: t("userSessionAttributeValue"),
})}
/>
}
fieldId="kc-user-session-attribute-value"
isRequired
validated={
errors.name ? ValidatedOptions.error : ValidatedOptions.default
}
helperTextInvalid={t("common:required")}
>
<TextInput
ref={register({ required: true })}
type="text"
data-testid="user-session-attribute-value"
id="kc-user-session-attribute-value"
name="config.attribute-value"
validated={
errors.name
? ValidatedOptions.error
: ValidatedOptions.default
}
/>
</FormGroup>
</>
)}
<ActionGroup>
<Button
data-testid="new-mapper-save-button"
variant="primary"
type="submit"
>
{t("common:save")}
</Button>
<Button
variant="link"
onClick={() => history.push(`/${realm}/client-scopes`)}
>
{t("common:cancel")}
</Button>
</ActionGroup>
</FormAccess>
</PageSection>
);
};

View file

@ -1,5 +1,5 @@
import React from "react"; import React from "react";
import { useHistory, useParams } from "react-router-dom"; import { Link, useHistory, useParams } from "react-router-dom";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { Controller, FormProvider, useForm } from "react-hook-form"; import { Controller, FormProvider, useForm } from "react-hook-form";
import { import {
@ -13,6 +13,7 @@ import {
PageSection, PageSection,
Tab, Tab,
TabTitleText, TabTitleText,
ToolbarItem,
} from "@patternfly/react-core"; } from "@patternfly/react-core";
import type IdentityProviderRepresentation from "@keycloak/keycloak-admin-client/lib/defs/identityProviderRepresentation"; import type IdentityProviderRepresentation from "@keycloak/keycloak-admin-client/lib/defs/identityProviderRepresentation";
@ -36,6 +37,8 @@ import { ReqAuthnConstraints } from "./ReqAuthnConstraintsSettings";
import { KeycloakDataTable } from "../../components/table-toolbar/KeycloakDataTable"; import { KeycloakDataTable } from "../../components/table-toolbar/KeycloakDataTable";
import { ListEmptyState } from "../../components/list-empty-state/ListEmptyState"; import { ListEmptyState } from "../../components/list-empty-state/ListEmptyState";
import type IdentityProviderMapperRepresentation from "@keycloak/keycloak-admin-client/lib/defs/identityProviderMapperRepresentation"; import type IdentityProviderMapperRepresentation from "@keycloak/keycloak-admin-client/lib/defs/identityProviderMapperRepresentation";
import { toIdentityProviderAddMapper } from "../routes/AddMapper";
import { toUpperCase } from "../../util";
type HeaderProps = { type HeaderProps = {
onChange: (value: boolean) => void; onChange: (value: boolean) => void;
@ -53,12 +56,11 @@ type IdPWithMapperAttributes = IdentityProviderMapperRepresentation & {
const Header = ({ onChange, value, save, toggleDeleteDialog }: HeaderProps) => { const Header = ({ onChange, value, save, toggleDeleteDialog }: HeaderProps) => {
const { t } = useTranslation("identity-providers"); const { t } = useTranslation("identity-providers");
const { providerId, alias } = const { alias } = useParams<{ alias: string }>();
useParams<{ providerId: string; alias: string }>();
const [toggleDisableDialog, DisableConfirm] = useConfirmDialog({ const [toggleDisableDialog, DisableConfirm] = useConfirmDialog({
titleKey: "identity-providers:disableProvider", titleKey: "identity-providers:disableProvider",
messageKey: t("disableConfirm", { provider: providerId }), messageKey: t("disableConfirm", { provider: alias }),
continueButtonLabel: "common:disable", continueButtonLabel: "common:disable",
onConfirm: () => { onConfirm: () => {
onChange(!value); onChange(!value);
@ -70,7 +72,7 @@ const Header = ({ onChange, value, save, toggleDeleteDialog }: HeaderProps) => {
<> <>
<DisableConfirm /> <DisableConfirm />
<ViewHeader <ViewHeader
titleKey={alias} titleKey={toUpperCase(alias)}
divider={false} divider={false}
dropdownItems={[ dropdownItems={[
<DropdownItem key="delete" onClick={() => toggleDeleteDialog()}> <DropdownItem key="delete" onClick={() => toggleDeleteDialog()}>
@ -127,7 +129,7 @@ export const DetailSettings = () => {
const [toggleDeleteDialog, DeleteConfirm] = useConfirmDialog({ const [toggleDeleteDialog, DeleteConfirm] = useConfirmDialog({
titleKey: "identity-providers:deleteProvider", titleKey: "identity-providers:deleteProvider",
messageKey: t("identity-providers:deleteConfirm", { provider: providerId }), messageKey: t("identity-providers:deleteConfirm", { provider: alias }),
continueButtonLabel: "common:delete", continueButtonLabel: "common:delete",
continueButtonVariant: ButtonVariant.danger, continueButtonVariant: ButtonVariant.danger,
onConfirm: async () => { onConfirm: async () => {
@ -262,6 +264,7 @@ export const DetailSettings = () => {
</Tab> </Tab>
<Tab <Tab
id="mappers" id="mappers"
data-testid="mappers-tab"
eventKey="mappers" eventKey="mappers"
title={<TabTitleText>{t("common:mappers")}</TabTitleText>} title={<TabTitleText>{t("common:mappers")}</TabTitleText>}
> >
@ -271,12 +274,36 @@ export const DetailSettings = () => {
message={t("identity-providers:noMappers")} message={t("identity-providers:noMappers")}
instructions={t("identity-providers:noMappersInstructions")} instructions={t("identity-providers:noMappersInstructions")}
primaryActionText={t("identity-providers:addMapper")} primaryActionText={t("identity-providers:addMapper")}
onPrimaryAction={() =>
history.push(
toIdentityProviderAddMapper({
realm,
alias: alias!,
providerId: providerId,
})
)
}
/> />
} }
loader={loader} loader={loader}
isPaginated isPaginated
ariaLabelKey="identity-providers:mappersList" ariaLabelKey="identity-providers:mappersList"
searchPlaceholderKey="identity-providers:searchForMapper" searchPlaceholderKey="identity-providers:searchForMapper"
toolbarItem={
<ToolbarItem>
<Link
// @ts-ignore
to={toIdentityProviderAddMapper({
realm,
alias: alias!,
providerId: providerId,
})}
datatest-id="add-mapper-button"
>
{t("addMapper")}
</Link>
</ToolbarItem>
}
columns={[ columns={[
{ {
name: "name", name: "name",

View file

@ -74,29 +74,27 @@ export const SamlConnectSettings = () => {
}, [discovering]); }, [discovering]);
const fileUpload = async (obj: object) => { const fileUpload = async (obj: object) => {
if (obj) { const formData = new FormData();
const formData = new FormData(); formData.append("providerId", id);
formData.append("providerId", id); formData.append("file", new Blob([JSON.stringify(obj)]));
formData.append("file", new Blob([JSON.stringify(obj)]));
try { try {
const response = await fetch( const response = await fetch(
`${getBaseUrl( `${getBaseUrl(
adminClient adminClient
)}admin/realms/${realm}/identity-provider/import-config`, )}admin/realms/${realm}/identity-provider/import-config`,
{ {
method: "POST", method: "POST",
body: formData, body: formData,
headers: { headers: {
Authorization: `bearer ${await adminClient.getAccessToken()}`, Authorization: `bearer ${await adminClient.getAccessToken()}`,
}, },
} }
); );
const result = await response.json(); const result = await response.json();
setupForm(result); setupForm(result);
} catch (error: any) { } catch (error: any) {
setDiscoveryResult({ error }); setDiscoveryResult({ error });
}
} }
}; };
@ -116,7 +114,6 @@ export const SamlConnectSettings = () => {
forID="kc-service-provider-entity-id" forID="kc-service-provider-entity-id"
/> />
} }
isRequired
> >
<TextInput <TextInput
type="text" type="text"
@ -125,7 +122,7 @@ export const SamlConnectSettings = () => {
id="kc-service-provider-entity-id" id="kc-service-provider-entity-id"
value={entityUrl || defaultEntityUrl} value={entityUrl || defaultEntityUrl}
onChange={setEntityUrl} onChange={setEntityUrl}
ref={register({ required: true })} ref={register()}
/> />
</FormGroup> </FormGroup>
@ -161,7 +158,6 @@ export const SamlConnectSettings = () => {
forID="kc-saml-entity-descriptor" forID="kc-saml-entity-descriptor"
/> />
} }
isRequired
> >
<TextInput <TextInput
type="text" type="text"
@ -170,7 +166,7 @@ export const SamlConnectSettings = () => {
id="kc-saml-entity-descriptor" id="kc-saml-entity-descriptor"
value={descriptorUrl} value={descriptorUrl}
onChange={setDescriptorUrl} onChange={setDescriptorUrl}
ref={register({ required: true })} ref={register()}
validated={ validated={
errors.samlEntityDescriptor errors.samlEntityDescriptor
? ValidatedOptions.error ? ValidatedOptions.error
@ -191,7 +187,7 @@ export const SamlConnectSettings = () => {
/> />
} }
validated={discoveryResult?.error ? "error" : "default"} validated={discoveryResult?.error ? "error" : "default"}
helperTextInvalid={discoveryResult?.error?.toString()} helperTextInvalid={discoveryResult?.error.toString()}
> >
<JsonFileUpload <JsonFileUpload
id="kc-import-config" id="kc-import-config"

View file

@ -1,4 +1,12 @@
.keycloak__discovery-settings__metadata .pf-c-expandable-section__toggle { .keycloak__discovery-settings__metadata .pf-c-expandable-section__toggle {
margin-left: var(--pf-c-form--m-horizontal__group-label--md--GridColumnWidth); margin-left: var(--pf-c-form--m-horizontal__group-label--md--GridColumnWidth);
} }
input#kc-role {
width: 300px;
margin-right: 24px;
}
h1.kc-add-mapper-title {
margin-left: calc(-1 * var(--pf-global--spacer--md));
}

View file

@ -102,5 +102,17 @@ export default {
'Specifies the comparison method used to evaluate the requested context classes or statements. The default is "Exact".', 'Specifies the comparison method used to evaluate the requested context classes or statements. The default is "Exact".',
authnContextClassRefs: "Ordered list of requested AuthnContext ClassRefs.", authnContextClassRefs: "Ordered list of requested AuthnContext ClassRefs.",
authnContextDeclRefs: "Ordered list of requested AuthnContext DeclRefs.", authnContextDeclRefs: "Ordered list of requested AuthnContext DeclRefs.",
addIdpMapperName: "Name of the mapper.",
syncModeOverride:
"Overrides the default sync mode of the IDP for this mapper. Values are: 'legacy' to keep the behaviour before this option was introduced, 'import' to only import the user once during first login of the user with this identity provider, 'force' to always update the user during every login with this identity provider and 'inherit' to use the sync mode defined in the identity provider for this mapper.",
mapperType:
"If the set of attributes exists and can be matched, grant the user the specified realm or client role.",
attributes:
"Name and (regex) value of the attributes to search for in token. The configured name of an attribute is searched in SAML attribute name and attribute friendly name fields. Every given attribute description must be met to set the role. If the attribute is an array, then the value must be contained in the array. If an attribute can be found several times, then one match is sufficient.",
regexAttributeValues:
"If enabled attribute values are interpreted as regular expressions.",
role: "Role to grant to user if all attributes are present. Click 'Select Role' button to browse roles, or just type it in the textbox. To reference a client role the syntax is clientname.clientrole, i.e. myclient.myrole",
userSessionAttribute: "Name of user session attribute you want to hardcode",
userSessionAttributeValue: "Value you want to hardcode",
}, },
}; };

View file

@ -6,6 +6,7 @@ export default {
provider: "Provider details", provider: "Provider details",
addProvider: "Add provider", addProvider: "Add provider",
addMapper: "Add mapper", addMapper: "Add mapper",
addIdPMapper: "Add Identity Provider Mapper",
mappersList: "Mappers list", mappersList: "Mappers list",
noMappers: "No Mappers", noMappers: "No Mappers",
noMappersInstructions: noMappersInstructions:
@ -16,8 +17,11 @@ export default {
addSamlProvider: "Add SAML provider", addSamlProvider: "Add SAML provider",
manageDisplayOrder: "Manage display order", manageDisplayOrder: "Manage display order",
deleteProvider: "Delete provider?", deleteProvider: "Delete provider?",
deleteProviderMapper: "Delete mapper?",
deleteConfirm: deleteConfirm:
"Are you sure you want to permanently delete the provider '{{provider}}'", "Are you sure you want to permanently delete the provider '{{provider}}'",
deleteMapperConfirm:
"Are you sure you want to permanently delete the mapper '{{mapper}}'",
deletedSuccess: "Provider successfully deleted", deletedSuccess: "Provider successfully deleted",
deleteError: "Could not delete the provider {{error}}", deleteError: "Could not delete the provider {{error}}",
disableProvider: "Disable provider?", disableProvider: "Disable provider?",
@ -135,9 +139,27 @@ export default {
postBrokerLoginFlowAlias: "Post login flow", postBrokerLoginFlowAlias: "Post login flow",
syncMode: "Sync mode", syncMode: "Sync mode",
syncModes: { syncModes: {
inherit: "Inherit",
import: "Import", import: "Import",
legacy: "Legacy", legacy: "Legacy",
force: "Force", force: "Force",
}, },
mapperTypes: {
advancedAttributeToRole: "Advanced Attribute To Role",
usernameTemplateImporter: "Username Template Importer",
hardcodedUserSessionAttribute: "Hardcoded User Session Attribute",
attributeImporter: "Attribute Importer",
hardcodedRole: "Hardcoded Role",
hardcodedAttribute: "Hardcoded Attribute",
samlAttributeToRole: "SAML Attribute To Role",
},
syncModeOverride: "Sync mode override",
mapperType: "Mapper type",
regexAttributeValues: "Regex Attribute Values",
selectRole: "Select role",
mapperCreateSuccess: "Mapper created successfully.",
mapperCreateError: "Error creating mapper.",
userSessionAttribute: "User Session Attribute",
userSessionAttributeValue: "User Session Attribute Value",
}, },
}; };

View file

@ -5,6 +5,7 @@ import { IdentityProviderOidcRoute } from "./routes/IdentityProviderOidc";
import { IdentityProviderSamlRoute } from "./routes/IdentityProviderSaml"; import { IdentityProviderSamlRoute } from "./routes/IdentityProviderSaml";
import { IdentityProvidersRoute } from "./routes/IdentityProviders"; import { IdentityProvidersRoute } from "./routes/IdentityProviders";
import { IdentityProviderTabRoute } from "./routes/IdentityProviderTab"; import { IdentityProviderTabRoute } from "./routes/IdentityProviderTab";
import { IdentityProviderAddMapperRoute } from "./routes/AddMapper";
const routes: RouteDef[] = [ const routes: RouteDef[] = [
IdentityProvidersRoute, IdentityProvidersRoute,
@ -13,6 +14,7 @@ const routes: RouteDef[] = [
IdentityProviderKeycloakOidcRoute, IdentityProviderKeycloakOidcRoute,
IdentityProviderRoute, IdentityProviderRoute,
IdentityProviderTabRoute, IdentityProviderTabRoute,
IdentityProviderAddMapperRoute,
]; ];
export default routes; export default routes;

View file

@ -0,0 +1,22 @@
import type { LocationDescriptorObject } from "history";
import { generatePath } from "react-router-dom";
import type { RouteDef } from "../../route-config";
import { AddMapper } from "../add/AddMapper";
export type IdentityProviderAddMapperParams = {
realm: string;
providerId: string;
alias: string;
};
export const IdentityProviderAddMapperRoute: RouteDef = {
path: "/:realm/identity-providers/:providerId/:alias/mappers/create",
component: AddMapper,
access: "manage-identity-providers",
};
export const toIdentityProviderAddMapper = (
params: IdentityProviderAddMapperParams
): LocationDescriptorObject => ({
pathname: generatePath(IdentityProviderAddMapperRoute.path, params),
});

View file

@ -25,7 +25,10 @@ export type AssociatedRolesModalProps = {
open: boolean; open: boolean;
toggleDialog: () => void; toggleDialog: () => void;
onConfirm: (newReps: RoleRepresentation[]) => void; onConfirm: (newReps: RoleRepresentation[]) => void;
existingCompositeRoles: RoleRepresentation[]; existingCompositeRoles?: RoleRepresentation[];
allRoles?: RoleRepresentation[];
omitComposites?: boolean;
isRadio?: boolean;
}; };
export const AssociatedRolesModal = (props: AssociatedRolesModalProps) => { export const AssociatedRolesModal = (props: AssociatedRolesModalProps) => {
@ -47,33 +50,39 @@ export const AssociatedRolesModal = (props: AssociatedRolesModalProps) => {
const loader = async () => { const loader = async () => {
const roles = await adminClient.roles.find(); const roles = await adminClient.roles.find();
const existingAdditionalRoles = await adminClient.roles.getCompositeRoles({
id,
});
const allRoles = [...roles, ...existingAdditionalRoles];
const filterDupes: Role[] = allRoles.filter( if (!props.omitComposites) {
(thing, index, self) => const existingAdditionalRoles = await adminClient.roles.getCompositeRoles(
index === self.findIndex((t) => t.name === thing.name) {
); id,
}
);
const allRoles = [...roles, ...existingAdditionalRoles];
const clients = await adminClient.clients.find(); const filterDupes: Role[] = allRoles.filter(
filterDupes (thing, index, self) =>
.filter((role) => role.clientRole) index === self.findIndex((t) => t.name === thing.name)
.map(
(role) =>
(role.clientId = clients.find(
(client) => client.id === role.containerId
)!.clientId!)
); );
return alphabetize(filterDupes).filter((role: RoleRepresentation) => { const clients = await adminClient.clients.find();
return ( filterDupes
props.existingCompositeRoles.find( .filter((role) => role.clientRole)
(existing: RoleRepresentation) => existing.name === role.name .map(
) === undefined && role.name !== name (role) =>
); (role.clientId = clients.find(
}); (client) => client.id === role.containerId
)!.clientId!)
);
return alphabetize(filterDupes).filter((role: RoleRepresentation) => {
return (
props.existingCompositeRoles?.find(
(existing: RoleRepresentation) => existing.name === role.name
) === undefined && role.name !== name
);
});
}
return alphabetize(roles);
}; };
const AliasRenderer = ({ id, name, clientId }: Role) => { const AliasRenderer = ({ id, name, clientId }: Role) => {
@ -101,9 +110,6 @@ export const AssociatedRolesModal = (props: AssociatedRolesModalProps) => {
}); });
rolesList = [...rolesList, ...clientRolesList]; rolesList = [...rolesList, ...clientRolesList];
} }
const existingAdditionalRoles = await adminClient.roles.getCompositeRoles({
id,
});
rolesList rolesList
.filter((role) => role.clientRole) .filter((role) => role.clientRole)
@ -114,13 +120,23 @@ export const AssociatedRolesModal = (props: AssociatedRolesModalProps) => {
)!.clientId!) )!.clientId!)
); );
return alphabetize(rolesList).filter((role: RoleRepresentation) => { if (!props.omitComposites) {
return ( const existingAdditionalRoles = await adminClient.roles.getCompositeRoles(
existingAdditionalRoles.find( {
(existing: RoleRepresentation) => existing.name === role.name id,
) === undefined && role.name !== name }
); );
});
return alphabetize(rolesList).filter((role: RoleRepresentation) => {
return (
existingAdditionalRoles.find(
(existing: RoleRepresentation) => existing.name === role.name
) === undefined && role.name !== name
);
});
}
return alphabetize(rolesList);
}; };
useEffect(() => { useEffect(() => {
@ -191,6 +207,7 @@ export const AssociatedRolesModal = (props: AssociatedRolesModalProps) => {
loader={filterType === "roles" ? loader : clientRolesLoader} loader={filterType === "roles" ? loader : clientRolesLoader}
ariaLabelKey="roles:roleList" ariaLabelKey="roles:roleList"
searchPlaceholderKey="roles:searchFor" searchPlaceholderKey="roles:searchFor"
isRadio={props.isRadio}
searchTypeComponent={ searchTypeComponent={
<Dropdown <Dropdown
onSelect={() => onFilterDropdownSelect(filterType)} onSelect={() => onFilterDropdownSelect(filterType)}