mirror of
https://github.com/keycloak/keycloak.git
synced 2026-01-09 23:12:06 -03:30
[OID4VCI]: Add UI for OID4VCI Protocol Mapper Configuration (#44390)
Closes: #43901 Signed-off-by: forkimenjeckayang <forkimenjeckayang@gmail.com>
This commit is contained in:
parent
44cf6d6808
commit
3099cc2294
@ -3658,3 +3658,11 @@ workflowEnabled=Workflow enabled
|
|||||||
workflowDisabled=Workflow disabled
|
workflowDisabled=Workflow disabled
|
||||||
workflowUpdated=Workflow updated successfully
|
workflowUpdated=Workflow updated successfully
|
||||||
workflowUpdateError=Could not update the workflow\: {{error}}
|
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 { BooleanComponent } from "./BooleanComponent";
|
||||||
import { ClientSelectComponent } from "./ClientSelectComponent";
|
import { ClientSelectComponent } from "./ClientSelectComponent";
|
||||||
|
import { ClaimDisplayComponent } from "./ClaimDisplayComponent";
|
||||||
import { IdentityProviderMultiSelectComponent } from "./IdentityProviderMultiSelectComponent";
|
import { IdentityProviderMultiSelectComponent } from "./IdentityProviderMultiSelectComponent";
|
||||||
import { FileComponent } from "./FileComponent";
|
import { FileComponent } from "./FileComponent";
|
||||||
import { GroupComponent } from "./GroupComponent";
|
import { GroupComponent } from "./GroupComponent";
|
||||||
@ -51,7 +52,8 @@ type ComponentType =
|
|||||||
| "MultivaluedString"
|
| "MultivaluedString"
|
||||||
| "File"
|
| "File"
|
||||||
| "Password"
|
| "Password"
|
||||||
| "Url";
|
| "Url"
|
||||||
|
| "ClaimDisplay";
|
||||||
|
|
||||||
export const COMPONENTS: {
|
export const COMPONENTS: {
|
||||||
[index in ComponentType]: FunctionComponent<ComponentProps>;
|
[index in ComponentType]: FunctionComponent<ComponentProps>;
|
||||||
@ -74,6 +76,7 @@ export const COMPONENTS: {
|
|||||||
File: FileComponent,
|
File: FileComponent,
|
||||||
Password: PasswordComponent,
|
Password: PasswordComponent,
|
||||||
Url: UrlComponent,
|
Url: UrlComponent,
|
||||||
|
ClaimDisplay: ClaimDisplayComponent,
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
export const isValidComponentType = (value: string): value is ComponentType =>
|
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
|
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 name;
|
||||||
protected String label;
|
protected String label;
|
||||||
protected String helpText;
|
protected String helpText;
|
||||||
|
|||||||
@ -29,6 +29,7 @@ import org.keycloak.models.KeycloakSessionFactory;
|
|||||||
import org.keycloak.models.ProtocolMapperModel;
|
import org.keycloak.models.ProtocolMapperModel;
|
||||||
import org.keycloak.models.UserSessionModel;
|
import org.keycloak.models.UserSessionModel;
|
||||||
import org.keycloak.models.oid4vci.CredentialScopeModel;
|
import org.keycloak.models.oid4vci.CredentialScopeModel;
|
||||||
|
import org.keycloak.models.oid4vci.Oid4vcProtocolMapperModel;
|
||||||
import org.keycloak.protocol.ProtocolMapper;
|
import org.keycloak.protocol.ProtocolMapper;
|
||||||
import org.keycloak.protocol.oid4vc.OID4VCEnvironmentProviderFactory;
|
import org.keycloak.protocol.oid4vc.OID4VCEnvironmentProviderFactory;
|
||||||
import org.keycloak.protocol.oid4vc.OID4VCLoginProtocolFactory;
|
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 CLAIM_NAME = "claim.name";
|
||||||
public static final String USER_ATTRIBUTE_KEY = "userAttribute";
|
public static final String USER_ATTRIBUTE_KEY = "userAttribute";
|
||||||
private static final List<ProviderConfigProperty> OID4VC_CONFIG_PROPERTIES = new ArrayList<>();
|
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 ProtocolMapperModel mapperModel;
|
||||||
protected String format;
|
protected String format;
|
||||||
|
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user