From 36513bae361ffc8298a2e343b796cf3c5d36f2b1 Mon Sep 17 00:00:00 2001 From: Giuseppe Graziano Date: Fri, 22 May 2026 15:48:56 +0200 Subject: [PATCH] show/update credential attributes in admin/account ui (#49218) Closes #48926 Signed-off-by: Giuseppe Graziano --- .../verifiable-credentials/CredentialRow.tsx | 138 +++++++++++------- .../UserAttributesDialog.tsx | 54 +++++++ .../VerifiableCredentials.tsx | 3 + .../admin/messages/messages_en.properties | 12 ++ .../src/user/UserAttributesDialog.tsx | 54 +++++++ .../src/user/UserVerifiableCredentials.tsx | 73 ++++++++- .../userVerifiableCredentialRepresentation.ts | 1 + .../src/resources/users.ts | 12 ++ .../account/messages/messages_en.properties | 6 + 9 files changed, 296 insertions(+), 57 deletions(-) create mode 100644 js/apps/account-ui/src/verifiable-credentials/UserAttributesDialog.tsx create mode 100644 js/apps/admin-ui/src/user/UserAttributesDialog.tsx diff --git a/js/apps/account-ui/src/verifiable-credentials/CredentialRow.tsx b/js/apps/account-ui/src/verifiable-credentials/CredentialRow.tsx index b31797340d2..76ded92d8c7 100644 --- a/js/apps/account-ui/src/verifiable-credentials/CredentialRow.tsx +++ b/js/apps/account-ui/src/verifiable-credentials/CredentialRow.tsx @@ -13,12 +13,14 @@ import { FlexItem, } from "@patternfly/react-core"; import { ExternalLinkAltIcon } from "@patternfly/react-icons"; +import { useState } from "react"; import { useTranslation } from "react-i18next"; import { deleteVerifiableCredential } from "../api/methods"; import { UserVerifiableCredentialRepresentation } from "../api/representations"; import { formatDate, FORMAT_DATE_ONLY } from "../utils/formatDate"; import { useAccountAlerts } from "../utils/useAccountAlerts"; +import { UserAttributesDialog } from "./UserAttributesDialog"; type CredentialRowProps = { credential: UserVerifiableCredentialRepresentation; @@ -29,6 +31,11 @@ export const CredentialRow = ({ credential, refresh }: CredentialRowProps) => { const { t } = useTranslation(); const context = useEnvironment(); const { addAlert, addError } = useAccountAlerts(); + const [showAttributesDialog, setShowAttributesDialog] = useState(false); + + const hasUserAttributes = + credential.userAttributes != null && + Object.keys(credential.userAttributes).length > 0; const hasManageRole = () => { const token = context.keycloak.tokenParsed; @@ -73,63 +80,86 @@ export const CredentialRow = ({ credential, refresh }: CredentialRowProps) => { }; return ( - - - - {credential.credentialScopeName} - , - - {credential.createdDate - ? formatDate( - new Date(credential.createdDate), - undefined, - FORMAT_DATE_ONLY, - ) - : "—"} - , - ]} + <> + {showAttributesDialog && hasUserAttributes && ( + setShowAttributesDialog(false)} /> - - - - - - {hasManageRole() && ( + )} + + + + {credential.credentialScopeName} + , + + {credential.createdDate + ? formatDate( + new Date(credential.createdDate), + undefined, + FORMAT_DATE_ONLY, + ) + : "—"} + , + + {hasUserAttributes ? ( + + ) : ( + + {t("credentialNoUserAttributes")} + + )} + , + ]} + /> + + - } > - {t("deleteCredentialConfirm", { - credentialName: credential.credentialScopeName, - })} - + {t("issueToWallet")} + - )} - - - - + {hasManageRole() && ( + + + {t("deleteCredentialConfirm", { + credentialName: credential.credentialScopeName, + })} + + + )} + + + + + ); }; diff --git a/js/apps/account-ui/src/verifiable-credentials/UserAttributesDialog.tsx b/js/apps/account-ui/src/verifiable-credentials/UserAttributesDialog.tsx new file mode 100644 index 00000000000..6e074ae64aa --- /dev/null +++ b/js/apps/account-ui/src/verifiable-credentials/UserAttributesDialog.tsx @@ -0,0 +1,54 @@ +import { useTranslation } from "react-i18next"; +import { Modal, ModalVariant } from "@patternfly/react-core"; +import { + Table, + TableVariant, + Tbody, + Td, + Th, + Thead, + Tr, +} from "@patternfly/react-table"; + +type UserAttributesDialogProps = { + credentialScopeName: string; + userAttributes: Record; + onClose: () => void; +}; + +export const UserAttributesDialog = ({ + credentialScopeName, + userAttributes, + onClose, +}: UserAttributesDialogProps) => { + const { t } = useTranslation(); + + return ( + + + + + + + + + + {Object.entries(userAttributes).map(([key, values]) => ( + + + + + ))} + +
{t("credentialAttributeName")}{t("credentialAttributeValue")}
{key}{values.join(", ")}
+
+ ); +}; diff --git a/js/apps/account-ui/src/verifiable-credentials/VerifiableCredentials.tsx b/js/apps/account-ui/src/verifiable-credentials/VerifiableCredentials.tsx index 05ac0e1c820..0c0544e41dc 100644 --- a/js/apps/account-ui/src/verifiable-credentials/VerifiableCredentials.tsx +++ b/js/apps/account-ui/src/verifiable-credentials/VerifiableCredentials.tsx @@ -61,6 +61,9 @@ export const VerifiableCredentials = () => { {t("credentialCreatedDate")} , + + {t("userAttributes")} + , ]} /> 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 85fba3c2708..d736567d509 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 @@ -1,4 +1,5 @@ cancel=Cancel +update=Update deleteConfirm_other=Are you sure you want to delete these groups? trusted-hosts.label=Trusted Hosts registration-web-origins.label=Allowed Registration Web Origins @@ -3595,6 +3596,17 @@ revokeVerifiableCredentialTitle=Revoke verifiable credential? revokeVerifiableCredentialConfirm=Are you sure you want to revoke the verifiable credential "{{credentialScopeName}}" from this user? revokeVerifiableCredentialSuccess=Verifiable credential successfully revoked. revokeVerifiableCredentialError=Could not revoke verifiable credential\: {{error}} +updateVerifiableCredentialTitle=Update verifiable credential? +updateVerifiableCredentialConfirm=Are you sure you want to update the verifiable credential "{{credentialScopeName}}"? This will refresh the user attributes snapshot and increment the revision. +updateVerifiableCredentialSuccess=Verifiable credential successfully updated. +updateVerifiableCredentialError=Could not update verifiable credential\: {{error}} +updateCredential=Update credential +credentialUserAttributes=User attributes +credentialNoUserAttributes=No user attributes +credentialViewAttributes=View attributes +credentialUserAttributesFor=User attributes for "{{credentialScopeName}}" +credentialAttributeName=Attribute name +credentialAttributeValue=Attribute value selectCredentialScope=Select a credential scope noOid4vcScopesAvailable=No OID4VC client scopes are available in this realm. Create OID4VC client scopes first. organizations=Organizations diff --git a/js/apps/admin-ui/src/user/UserAttributesDialog.tsx b/js/apps/admin-ui/src/user/UserAttributesDialog.tsx new file mode 100644 index 00000000000..6e074ae64aa --- /dev/null +++ b/js/apps/admin-ui/src/user/UserAttributesDialog.tsx @@ -0,0 +1,54 @@ +import { useTranslation } from "react-i18next"; +import { Modal, ModalVariant } from "@patternfly/react-core"; +import { + Table, + TableVariant, + Tbody, + Td, + Th, + Thead, + Tr, +} from "@patternfly/react-table"; + +type UserAttributesDialogProps = { + credentialScopeName: string; + userAttributes: Record; + onClose: () => void; +}; + +export const UserAttributesDialog = ({ + credentialScopeName, + userAttributes, + onClose, +}: UserAttributesDialogProps) => { + const { t } = useTranslation(); + + return ( + + + + + + + + + + {Object.entries(userAttributes).map(([key, values]) => ( + + + + + ))} + +
{t("credentialAttributeName")}{t("credentialAttributeValue")}
{key}{values.join(", ")}
+
+ ); +}; diff --git a/js/apps/admin-ui/src/user/UserVerifiableCredentials.tsx b/js/apps/admin-ui/src/user/UserVerifiableCredentials.tsx index 8b23fc2e2b1..f21f11cd4dc 100644 --- a/js/apps/admin-ui/src/user/UserVerifiableCredentials.tsx +++ b/js/apps/admin-ui/src/user/UserVerifiableCredentials.tsx @@ -13,6 +13,7 @@ import { Action, KeycloakDataTable } from "@keycloak/keycloak-ui-shared"; import { emptyFormatter } from "../util"; import useFormatDate from "../utils/useFormatDate"; import { CreateVerifiableCredentialModal } from "./CreateVerifiableCredentialModal"; +import { UserAttributesDialog } from "./UserAttributesDialog"; type UserVerifiableCredentialsProps = { userId: string; @@ -29,6 +30,8 @@ export const UserVerifiableCredentials = ({ const [selectedCredential, setSelectedCredential] = useState(); const [isCreateModalOpen, setIsCreateModalOpen] = useState(false); + const [attributesDialogCredential, setAttributesDialogCredential] = + useState(); const refresh = () => setKey(new Date().getTime()); @@ -60,9 +63,41 @@ export const UserVerifiableCredentials = ({ }, }); + const [toggleUpdateDialog, UpdateConfirm] = useConfirmDialog({ + titleKey: "updateVerifiableCredentialTitle", + messageKey: t("updateVerifiableCredentialConfirm", { + credentialScopeName: selectedCredential?.credentialScopeName, + }), + continueButtonLabel: "update", + continueButtonVariant: ButtonVariant.primary, + onConfirm: async () => { + try { + await adminClient.users.updateVerifiableCredential({ + id: userId, + credentialScopeName: selectedCredential!.credentialScopeName!, + }); + refresh(); + addAlert(t("updateVerifiableCredentialSuccess"), AlertVariant.success); + } catch (error) { + addError("updateVerifiableCredentialError", error); + } + }, + }); + return ( <> + + {attributesDialogCredential?.userAttributes && + Object.keys(attributesDialogCredential.userAttributes).length > 0 && ( + setAttributesDialogCredential(undefined)} + /> + )} {isCreateModalOpen && ( createdDate ? formatDate(new Date(createdDate)) : "—", }, + { + name: "userAttributes", + displayKey: "userAttributes", + transforms: [cellWidth(40)], + cellRenderer: (credential) => { + if ( + !credential.userAttributes || + Object.keys(credential.userAttributes).length === 0 + ) { + return ( + + {t("credentialNoUserAttributes")} + + ); + } + return ( + + ); + }, + }, ]} actions={[ + { + title: t("updateCredential"), + onRowClick: (credential) => { + setSelectedCredential(credential); + toggleUpdateDialog(); + }, + } as Action, { title: t("revoke"), onRowClick: (credential) => { diff --git a/js/libs/keycloak-admin-client/src/defs/userVerifiableCredentialRepresentation.ts b/js/libs/keycloak-admin-client/src/defs/userVerifiableCredentialRepresentation.ts index 22c25faf7bf..9fa92761a35 100644 --- a/js/libs/keycloak-admin-client/src/defs/userVerifiableCredentialRepresentation.ts +++ b/js/libs/keycloak-admin-client/src/defs/userVerifiableCredentialRepresentation.ts @@ -5,4 +5,5 @@ export default interface UserVerifiableCredentialRepresentation { credentialScopeName?: string; revision?: string; createdDate?: number; + userAttributes?: Record; } diff --git a/js/libs/keycloak-admin-client/src/resources/users.ts b/js/libs/keycloak-admin-client/src/resources/users.ts index 9661264970b..c57807ad874 100644 --- a/js/libs/keycloak-admin-client/src/resources/users.ts +++ b/js/libs/keycloak-admin-client/src/resources/users.ts @@ -532,6 +532,18 @@ export class Users extends Resource<{ realm?: string }> { urlParamKeys: ["id", "credentialScopeName"], }); + /** + * update a verifiable credential for a user (refreshes user attributes snapshot and increments revision) + */ + public updateVerifiableCredential = this.makeRequest< + { id: string; credentialScopeName: string }, + UserVerifiableCredentialRepresentation + >({ + method: "PUT", + path: "/{id}/vc/credentials/{credentialScopeName}", + urlParamKeys: ["id", "credentialScopeName"], + }); + public getUnmanagedAttributes = this.makeRequest< { id: string }, Record diff --git a/themes/src/main/resources/theme/base/account/messages/messages_en.properties b/themes/src/main/resources/theme/base/account/messages/messages_en.properties index d1518ac0c13..3b2d6f5afc4 100755 --- a/themes/src/main/resources/theme/base/account/messages/messages_en.properties +++ b/themes/src/main/resources/theme/base/account/messages/messages_en.properties @@ -415,3 +415,9 @@ deleteCredentialConfirm=Are you sure you want to delete the credential "{{creden credentialScopeName=Credential Type credentialCreatedDate=Created issueToWallet=Issue to Wallet +credentialUserAttributes=User Attributes +credentialNoUserAttributes=No user attributes available +credentialViewAttributes=View attributes +credentialUserAttributesFor=User attributes for "{{credentialScopeName}}" +credentialAttributeName=Attribute name +credentialAttributeValue=Attribute value