[OID4VCI]: Add UI for OID4VCI Protocol Mapper Configuration (#44390)

Closes: #43901


Signed-off-by: forkimenjeckayang <forkimenjeckayang@gmail.com>
This commit is contained in:
forkimenjeckayang
2025-12-04 14:18:37 +01:00
committed by GitHub
parent 44cf6d6808
commit 3099cc2294
6 changed files with 498 additions and 1 deletions

View File

@@ -3658,3 +3658,11 @@ workflowEnabled=Workflow enabled
workflowDisabled=Workflow disabled
workflowUpdated=Workflow updated successfully
workflowUpdateError=Could not update the workflow\: {{error}}
# OID4VCI Protocol Mapper UI
claimDisplayName=Display Name
claimDisplayLocale=Locale
claimDisplayNamePlaceholder=e.g., Email Address
claimDisplayLocalePlaceholder=e.g., en, de, fr
addClaimDisplay=Add display entry
removeClaimDisplay=Remove display entry
noClaimDisplayEntries=No display entries. Display entries provide user-friendly claim names for different locales in wallet applications.

View File

@@ -0,0 +1,223 @@
import {
ActionList,
ActionListItem,
Button,
EmptyState,
EmptyStateBody,
EmptyStateFooter,
Flex,
FlexItem,
FormGroup,
TextInput,
} from "@patternfly/react-core";
import { MinusCircleIcon, PlusCircleIcon } from "@patternfly/react-icons";
import { useEffect, useState, useRef } from "react";
import { useFormContext } from "react-hook-form";
import { useTranslation } from "react-i18next";
import { HelpItem } from "@keycloak/keycloak-ui-shared";
import type { ComponentProps } from "./components";
type ClaimDisplayEntry = {
name: string;
locale: string;
};
type IdClaimDisplayEntry = ClaimDisplayEntry & {
id: string;
};
const generateId = () => crypto.randomUUID();
export const ClaimDisplayComponent = ({
name,
label,
helpText,
required,
isDisabled,
defaultValue,
convertToName,
}: ComponentProps) => {
const { t } = useTranslation();
const { getValues, setValue, register } = useFormContext();
const [displays, setDisplays] = useState<IdClaimDisplayEntry[]>([]);
const fieldName = convertToName(name!);
const debounceTimeoutRef = useRef<number | null>(null);
useEffect(() => {
register(fieldName);
const value = getValues(fieldName) || defaultValue;
try {
const parsed: ClaimDisplayEntry[] = value
? typeof value === "string"
? JSON.parse(value)
: value
: [];
setDisplays(parsed.map((entry) => ({ ...entry, id: generateId() })));
} catch {
setDisplays([]);
}
}, [defaultValue, fieldName, getValues, register]);
useEffect(() => {
return () => {
if (debounceTimeoutRef.current !== null) {
clearTimeout(debounceTimeoutRef.current);
}
};
}, []);
const appendNew = () => {
const newDisplays = [
...displays,
{ name: "", locale: "", id: generateId() },
];
setDisplays(newDisplays);
syncFormValue(newDisplays);
};
const syncFormValue = (val = displays) => {
const filteredEntries = val
.filter((e) => e.name !== "" && e.locale !== "")
.map((entry) => ({ name: entry.name, locale: entry.locale }));
setValue(fieldName, JSON.stringify(filteredEntries), {
shouldDirty: true,
shouldValidate: true,
});
};
const debouncedUpdate = (val: IdClaimDisplayEntry[]) => {
if (debounceTimeoutRef.current !== null) {
clearTimeout(debounceTimeoutRef.current);
}
debounceTimeoutRef.current = window.setTimeout(() => {
syncFormValue(val);
debounceTimeoutRef.current = null;
}, 300);
};
const flushUpdate = () => {
if (debounceTimeoutRef.current !== null) {
clearTimeout(debounceTimeoutRef.current);
debounceTimeoutRef.current = null;
}
syncFormValue();
};
const updateName = (index: number, name: string) => {
const newDisplays = [
...displays.slice(0, index),
{ ...displays[index], name },
...displays.slice(index + 1),
];
setDisplays(newDisplays);
debouncedUpdate(newDisplays);
};
const updateLocale = (index: number, locale: string) => {
const newDisplays = [
...displays.slice(0, index),
{ ...displays[index], locale },
...displays.slice(index + 1),
];
setDisplays(newDisplays);
debouncedUpdate(newDisplays);
};
const remove = (index: number) => {
const value = [...displays.slice(0, index), ...displays.slice(index + 1)];
setDisplays(value);
syncFormValue(value);
};
return displays.length !== 0 ? (
<FormGroup
label={t(label!)}
labelIcon={<HelpItem helpText={t(helpText!)} fieldLabelId={`${label}`} />}
fieldId={name!}
isRequired={required}
>
<Flex direction={{ default: "column" }}>
<Flex>
<FlexItem flex={{ default: "flex_1" }}>
<strong>{t("claimDisplayName")}</strong>
</FlexItem>
<FlexItem flex={{ default: "flex_1" }}>
<strong>{t("claimDisplayLocale")}</strong>
</FlexItem>
</Flex>
{displays.map((display, index) => (
<Flex key={display.id} data-testid="claim-display-row">
<FlexItem flex={{ default: "flex_1" }}>
<TextInput
id={`${fieldName}.${index}.name`}
data-testid={`${fieldName}.${index}.name`}
value={display.name}
onChange={(_event, value) => updateName(index, value)}
onBlur={() => flushUpdate()}
isDisabled={isDisabled}
placeholder={t("claimDisplayNamePlaceholder")}
/>
</FlexItem>
<FlexItem flex={{ default: "flex_1" }}>
<TextInput
id={`${fieldName}.${index}.locale`}
data-testid={`${fieldName}.${index}.locale`}
value={display.locale}
onChange={(_event, value) => updateLocale(index, value)}
onBlur={() => flushUpdate()}
isDisabled={isDisabled}
placeholder={t("claimDisplayLocalePlaceholder")}
/>
</FlexItem>
<FlexItem>
<Button
variant="link"
title={t("removeClaimDisplay")}
isDisabled={isDisabled}
onClick={() => remove(index)}
data-testid={`${fieldName}.${index}.remove`}
>
<MinusCircleIcon />
</Button>
</FlexItem>
</Flex>
))}
</Flex>
<ActionList>
<ActionListItem>
<Button
data-testid={`${fieldName}-add-row`}
className="pf-v5-u-px-0 pf-v5-u-mt-sm"
variant="link"
icon={<PlusCircleIcon />}
onClick={() => appendNew()}
>
{t("addClaimDisplay")}
</Button>
</ActionListItem>
</ActionList>
</FormGroup>
) : (
<EmptyState
data-testid={`${fieldName}-empty-state`}
className="pf-v5-u-p-0"
variant="xs"
>
<EmptyStateBody>{t("noClaimDisplayEntries")}</EmptyStateBody>
<EmptyStateFooter>
<Button
data-testid={`${fieldName}-add-row`}
variant="link"
icon={<PlusCircleIcon />}
size="sm"
onClick={appendNew}
isDisabled={isDisabled}
>
{t("addClaimDisplay")}
</Button>
</EmptyStateFooter>
</EmptyState>
);
};

View File

@@ -3,6 +3,7 @@ import { FunctionComponent } from "react";
import { BooleanComponent } from "./BooleanComponent";
import { ClientSelectComponent } from "./ClientSelectComponent";
import { ClaimDisplayComponent } from "./ClaimDisplayComponent";
import { IdentityProviderMultiSelectComponent } from "./IdentityProviderMultiSelectComponent";
import { FileComponent } from "./FileComponent";
import { GroupComponent } from "./GroupComponent";
@@ -51,7 +52,8 @@ type ComponentType =
| "MultivaluedString"
| "File"
| "Password"
| "Url";
| "Url"
| "ClaimDisplay";
export const COMPONENTS: {
[index in ComponentType]: FunctionComponent<ComponentProps>;
@@ -74,6 +76,7 @@ export const COMPONENTS: {
File: FileComponent,
Password: PasswordComponent,
Url: UrlComponent,
ClaimDisplay: ClaimDisplayComponent,
} as const;
export const isValidComponentType = (value: string): value is ComponentType =>

View File

@@ -0,0 +1,232 @@
import { type Page, expect, test } from "@playwright/test";
import { createTestBed } from "../support/testbed.ts";
import { goToClientScopes } from "../utils/sidebar.ts";
import { clickSaveButton, selectItem } from "../utils/form.ts";
import { clickTableRowItem, clickTableToolbarItem } from "../utils/table.ts";
import { login } from "../utils/login.ts";
import { toClientScopes } from "../../src/client-scopes/routes/ClientScopes.tsx";
import { assertNotificationMessage } from "../utils/masthead.ts";
async function goToMappersTab(page: Page) {
await page.getByTestId("mappers").click();
}
async function createOid4vcClientScope(page: Page, scopeName: string) {
await goToClientScopes(page);
await clickTableToolbarItem(page, "Create client scope");
await selectItem(page, "#kc-protocol", "OpenID for Verifiable Credentials");
await page.getByTestId("name").fill(scopeName);
await clickSaveButton(page);
await assertNotificationMessage(page, "Client scope created");
await page.waitForURL(/.*\/client-scopes\/.+/);
}
async function selectMapperType(page: Page, mapperType: string) {
await page.getByText(mapperType, { exact: true }).click();
await page.getByTestId("name").waitFor({ state: "visible" });
}
async function setupMapperConfiguration(
page: Page,
scopeName: string,
mapperType: string = "Static Claim Mapper",
) {
await createOid4vcClientScope(page, scopeName);
await goToMappersTab(page);
await page.getByRole("button", { name: "Configure a new mapper" }).click();
await selectMapperType(page, mapperType);
}
async function fillBasicMapperFields(
page: Page,
mapperName: string,
propertyName: string,
propertyValue: string,
) {
await page.getByTestId("name").fill(mapperName);
await page
.getByRole("textbox", { name: "Static Claim Property Name" })
.fill(propertyName);
await page
.getByRole("textbox", { name: "Static Claim Value" })
.fill(propertyValue);
}
async function addDisplayEntry(
page: Page,
index: number,
name: string,
locale: string,
) {
await page.getByRole("button", { name: "Add display entry" }).click();
await page
.locator(`[data-testid="config.vc🍺display.${index}.name"]`)
.fill(name);
await page
.locator(`[data-testid="config.vc🍺display.${index}.locale"]`)
.fill(locale);
}
async function assertMandatoryClaimAndDisplayButtonVisible(page: Page) {
await expect(page.getByText("Mandatory Claim")).toBeVisible();
await expect(
page.getByRole("checkbox", { name: "Mandatory Claim" }),
).toBeVisible();
await expect(
page.getByRole("button", { name: "Add display entry" }),
).toBeVisible();
}
async function saveMapperAndAssertSuccess(page: Page) {
await clickSaveButton(page);
await assertNotificationMessage(page, "Mapping successfully created");
}
test.describe("OID4VCI Protocol Mapper Configuration", () => {
let testBed: Awaited<ReturnType<typeof createTestBed>>;
test.beforeEach(async ({ page }) => {
testBed = await createTestBed();
await login(page, { to: toClientScopes({ realm: testBed.realm }) });
});
test.afterEach(async () => {
if (testBed) {
await testBed[Symbol.asyncDispose]();
}
});
test("should display mandatory claim toggle and claim display fields", async ({
page,
}) => {
const scopeName = `oid4vci-mapper-test-${Date.now()}`;
await setupMapperConfiguration(page, scopeName);
await assertMandatoryClaimAndDisplayButtonVisible(page);
});
test("should save and persist mandatory claim and display fields", async ({
page,
}) => {
const scopeName = `oid4vci-test-persist-${Date.now()}`;
const mapperName = "test-persistent-mapper";
await setupMapperConfiguration(page, scopeName);
await fillBasicMapperFields(page, mapperName, "testClaim", "testValue");
await page.getByText("Mandatory Claim").click();
const mandatoryToggle = page.getByRole("checkbox", {
name: "Mandatory Claim",
});
await expect(mandatoryToggle).toBeChecked();
await addDisplayEntry(page, 0, "Test Claim Name", "en");
await saveMapperAndAssertSuccess(page);
await page.getByTestId("nav-item-client-scopes").click();
await page.getByPlaceholder("Search for client scope").fill(scopeName);
await clickTableRowItem(page, scopeName);
await goToMappersTab(page);
await clickTableRowItem(page, mapperName);
await expect(
page.getByRole("checkbox", { name: "Mandatory Claim" }),
).toBeChecked();
await expect(
page.locator('[data-testid="config.vc🍺display.0.name"]'),
).toHaveValue("Test Claim Name");
await expect(
page.locator('[data-testid="config.vc🍺display.0.locale"]'),
).toHaveValue("en");
});
test("should allow adding multiple display entries", async ({ page }) => {
const scopeName = `oid4vci-multi-display-${Date.now()}`;
await setupMapperConfiguration(page, scopeName);
await fillBasicMapperFields(
page,
"multi-lang-mapper",
"email",
"user@example.com",
);
const displayEntries = [
{ name: "Email Address", locale: "en" },
{ name: "E-Mail-Adresse", locale: "de" },
{ name: "Adresse e-mail", locale: "fr" },
];
for (let i = 0; i < displayEntries.length; i++) {
await addDisplayEntry(
page,
i,
displayEntries[i].name,
displayEntries[i].locale,
);
}
for (let i = 0; i < displayEntries.length; i++) {
await expect(
page.locator(`[data-testid="config.vc🍺display.${i}.name"]`),
).toHaveValue(displayEntries[i].name);
}
await saveMapperAndAssertSuccess(page);
});
test("should allow removing display entries", async ({ page }) => {
const scopeName = `oid4vci-remove-display-${Date.now()}`;
await setupMapperConfiguration(page, scopeName);
await fillBasicMapperFields(page, "remove-test-mapper", "test", "value");
await addDisplayEntry(page, 0, "First Entry", "en");
await addDisplayEntry(page, 1, "Second Entry", "de");
await page.locator('[data-testid="config.vc🍺display.0.remove"]').click();
await expect(
page.locator('[data-testid="config.vc🍺display.0.name"]'),
).toHaveValue("Second Entry");
await expect(
page.locator('[data-testid="config.vc🍺display.0.locale"]'),
).toHaveValue("de");
await saveMapperAndAssertSuccess(page);
});
test("should work with all OID4VC mapper types", async ({ page }) => {
const scopeName = `oid4vci-all-types-${Date.now()}`;
const mapperTypes = [
"User Attribute Mapper",
"Static Claim Mapper",
"CredentialSubject ID Mapper",
];
await createOid4vcClientScope(page, scopeName);
await goToMappersTab(page);
for (const mapperType of mapperTypes) {
const addButton = page
.getByRole("button", { name: "Configure a new mapper" })
.or(page.getByRole("button", { name: "Add mapper" }))
.first();
await addButton.click();
// Handle different UI states: first mapper shows no dropdown menu,
// subsequent mappers show "Add mapper" dropdown with "By configuration" option
const byConfigMenuItem = page.getByRole("menuitem", {
name: "By configuration",
});
const menuItemExists = (await byConfigMenuItem.count()) > 0;
// eslint-disable-next-line playwright/no-conditional-in-test
if (menuItemExists) {
await byConfigMenuItem.click();
}
await selectMapperType(page, mapperType);
await assertMandatoryClaimAndDisplayButtonVisible(page);
const cancelButton = page
.getByRole("button", { name: "Cancel" })
.or(page.getByRole("link", { name: "Cancel" }));
await cancelButton.click();
}
});
});

View File

@@ -86,6 +86,11 @@ public class ProviderConfigProperty {
public static final String IDENTITY_PROVIDER_MULTI_LIST_TYPE="IdentityProviderMultiList"; // only in admin console, not in themes
/**
* Display metadata for wallet applications to show user-friendly claim names
*/
public static final String CLAIM_DISPLAY_TYPE="ClaimDisplay";
protected String name;
protected String label;
protected String helpText;

View File

@@ -29,6 +29,7 @@ import org.keycloak.models.KeycloakSessionFactory;
import org.keycloak.models.ProtocolMapperModel;
import org.keycloak.models.UserSessionModel;
import org.keycloak.models.oid4vci.CredentialScopeModel;
import org.keycloak.models.oid4vci.Oid4vcProtocolMapperModel;
import org.keycloak.protocol.ProtocolMapper;
import org.keycloak.protocol.oid4vc.OID4VCEnvironmentProviderFactory;
import org.keycloak.protocol.oid4vc.OID4VCLoginProtocolFactory;
@@ -50,6 +51,31 @@ public abstract class OID4VCMapper implements ProtocolMapper, OID4VCEnvironmentP
public static final String CLAIM_NAME = "claim.name";
public static final String USER_ATTRIBUTE_KEY = "userAttribute";
private static final List<ProviderConfigProperty> OID4VC_CONFIG_PROPERTIES = new ArrayList<>();
static {
ProviderConfigProperty property;
// Add vc.mandatory property - indicates whether this claim is mandatory in the credential
property = new ProviderConfigProperty();
property.setName(Oid4vcProtocolMapperModel.MANDATORY);
property.setLabel("Mandatory Claim");
property.setHelpText("Indicates whether this claim must be present in the issued credential. " +
"This information is included in the credential metadata for wallet applications.");
property.setType(ProviderConfigProperty.BOOLEAN_TYPE);
property.setDefaultValue(false);
OID4VC_CONFIG_PROPERTIES.add(property);
// Add vc.display property - display information for wallet UIs
property = new ProviderConfigProperty();
property.setName(Oid4vcProtocolMapperModel.DISPLAY);
property.setLabel("Claim Display Information");
property.setHelpText("Display metadata for wallet applications to show user-friendly claim names. " +
"Provide display entries with name and locale for internationalization support.");
property.setType(ProviderConfigProperty.CLAIM_DISPLAY_TYPE);
property.setDefaultValue(null);
OID4VC_CONFIG_PROPERTIES.add(property);
}
protected ProtocolMapperModel mapperModel;
protected String format;