mirror of
https://github.com/keycloak/keycloak.git
synced 2025-12-16 20:15:46 -06:00
[OID4VCI]: Add UI for OID4VCI Protocol Mapper Configuration (#44390)
Closes: #43901 Signed-off-by: forkimenjeckayang <forkimenjeckayang@gmail.com>
This commit is contained in:
committed by
GitHub
parent
44cf6d6808
commit
3099cc2294
@@ -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.
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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 =>
|
||||
|
||||
232
js/apps/admin-ui/test/client-scope/oid4vci-mappers.spec.ts
Normal file
232
js/apps/admin-ui/test/client-scope/oid4vci-mappers.spec.ts
Normal 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();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
Reference in New Issue
Block a user