diff --git a/js/apps/admin-ui/maven-resources/theme/keycloak.v2/admin/messages/messages_en.properties b/js/apps/admin-ui/maven-resources/theme/keycloak.v2/admin/messages/messages_en.properties index 42901e989ec..27704a63860 100644 --- a/js/apps/admin-ui/maven-resources/theme/keycloak.v2/admin/messages/messages_en.properties +++ b/js/apps/admin-ui/maven-resources/theme/keycloak.v2/admin/messages/messages_en.properties @@ -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. diff --git a/js/apps/admin-ui/src/components/dynamic/ClaimDisplayComponent.tsx b/js/apps/admin-ui/src/components/dynamic/ClaimDisplayComponent.tsx new file mode 100644 index 00000000000..c601c20e1cf --- /dev/null +++ b/js/apps/admin-ui/src/components/dynamic/ClaimDisplayComponent.tsx @@ -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([]); + const fieldName = convertToName(name!); + const debounceTimeoutRef = useRef(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 ? ( + } + fieldId={name!} + isRequired={required} + > + + + + {t("claimDisplayName")} + + + {t("claimDisplayLocale")} + + + {displays.map((display, index) => ( + + + updateName(index, value)} + onBlur={() => flushUpdate()} + isDisabled={isDisabled} + placeholder={t("claimDisplayNamePlaceholder")} + /> + + + updateLocale(index, value)} + onBlur={() => flushUpdate()} + isDisabled={isDisabled} + placeholder={t("claimDisplayLocalePlaceholder")} + /> + + + + + + ))} + + + + + + + + ) : ( + + {t("noClaimDisplayEntries")} + + + + + ); +}; diff --git a/js/apps/admin-ui/src/components/dynamic/components.ts b/js/apps/admin-ui/src/components/dynamic/components.ts index 1df06b8e200..dd94adc37bd 100644 --- a/js/apps/admin-ui/src/components/dynamic/components.ts +++ b/js/apps/admin-ui/src/components/dynamic/components.ts @@ -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; @@ -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 => diff --git a/js/apps/admin-ui/test/client-scope/oid4vci-mappers.spec.ts b/js/apps/admin-ui/test/client-scope/oid4vci-mappers.spec.ts new file mode 100644 index 00000000000..9f75efbc5d9 --- /dev/null +++ b/js/apps/admin-ui/test/client-scope/oid4vci-mappers.spec.ts @@ -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>; + + 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(); + } + }); +}); diff --git a/server-spi/src/main/java/org/keycloak/provider/ProviderConfigProperty.java b/server-spi/src/main/java/org/keycloak/provider/ProviderConfigProperty.java index 2bfd963d0e4..3b2f482e763 100755 --- a/server-spi/src/main/java/org/keycloak/provider/ProviderConfigProperty.java +++ b/server-spi/src/main/java/org/keycloak/provider/ProviderConfigProperty.java @@ -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; diff --git a/services/src/main/java/org/keycloak/protocol/oid4vc/issuance/mappers/OID4VCMapper.java b/services/src/main/java/org/keycloak/protocol/oid4vc/issuance/mappers/OID4VCMapper.java index 51e30d88bba..5d97cd32617 100644 --- a/services/src/main/java/org/keycloak/protocol/oid4vc/issuance/mappers/OID4VCMapper.java +++ b/services/src/main/java/org/keycloak/protocol/oid4vc/issuance/mappers/OID4VCMapper.java @@ -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 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;