show/update credential attributes in admin/account ui (#49218)

Closes #48926

Signed-off-by: Giuseppe Graziano <g.graziano94@gmail.com>
This commit is contained in:
Giuseppe Graziano
2026-05-22 15:48:56 +02:00
committed by GitHub
parent 6b3241ea1f
commit 36513bae36
9 changed files with 296 additions and 57 deletions
@@ -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 (
<DataListItem
id={`credential-${credential.credentialScopeName}`}
key={credential.credentialScopeName}
aria-label={t("verifiableCredentials")}
>
<DataListItemRow>
<DataListItemCells
dataListCells={[
<DataListCell key="name" width={2}>
{credential.credentialScopeName}
</DataListCell>,
<DataListCell key="created" width={2}>
{credential.createdDate
? formatDate(
new Date(credential.createdDate),
undefined,
FORMAT_DATE_ONLY,
)
: "—"}
</DataListCell>,
]}
<>
{showAttributesDialog && hasUserAttributes && (
<UserAttributesDialog
credentialScopeName={credential.credentialScopeName!}
userAttributes={credential.userAttributes!}
onClose={() => setShowAttributesDialog(false)}
/>
<DataListAction
aria-labelledby={t("actions")}
aria-label={t("credentialActions")}
id="credentialActions"
>
<Flex>
<FlexItem>
<Button
id={`credential-${credential.credentialScopeName}-issue`}
variant="link"
onClick={handleIssueToWallet}
icon={<ExternalLinkAltIcon />}
>
{t("issueToWallet")}
</Button>
</FlexItem>
{hasManageRole() && (
)}
<DataListItem
id={`credential-${credential.credentialScopeName}`}
key={credential.credentialScopeName}
aria-label={t("verifiableCredentials")}
>
<DataListItemRow>
<DataListItemCells
dataListCells={[
<DataListCell key="name" width={2}>
{credential.credentialScopeName}
</DataListCell>,
<DataListCell key="created" width={2}>
{credential.createdDate
? formatDate(
new Date(credential.createdDate),
undefined,
FORMAT_DATE_ONLY,
)
: "—"}
</DataListCell>,
<DataListCell key="attributes" width={2}>
{hasUserAttributes ? (
<Button
variant="link"
onClick={() => setShowAttributesDialog(true)}
>
{t("credentialViewAttributes")}
</Button>
) : (
<span className="pf-v5-u-color-200">
{t("credentialNoUserAttributes")}
</span>
)}
</DataListCell>,
]}
/>
<DataListAction
aria-labelledby={t("actions")}
aria-label={t("credentialActions")}
id="credentialActions"
>
<Flex>
<FlexItem>
<ContinueCancelModal
buttonTitle={t("delete")}
modalTitle={t("deleteCredential")}
continueLabel={t("delete")}
cancelLabel={t("cancel")}
buttonVariant="link"
onContinue={handleDelete}
<Button
id={`credential-${credential.credentialScopeName}-issue`}
variant="link"
onClick={handleIssueToWallet}
icon={<ExternalLinkAltIcon />}
>
{t("deleteCredentialConfirm", {
credentialName: credential.credentialScopeName,
})}
</ContinueCancelModal>
{t("issueToWallet")}
</Button>
</FlexItem>
)}
</Flex>
</DataListAction>
</DataListItemRow>
</DataListItem>
{hasManageRole() && (
<FlexItem>
<ContinueCancelModal
buttonTitle={t("delete")}
modalTitle={t("deleteCredential")}
continueLabel={t("delete")}
cancelLabel={t("cancel")}
buttonVariant="link"
onContinue={handleDelete}
>
{t("deleteCredentialConfirm", {
credentialName: credential.credentialScopeName,
})}
</ContinueCancelModal>
</FlexItem>
)}
</Flex>
</DataListAction>
</DataListItemRow>
</DataListItem>
</>
);
};
@@ -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<string, string[]>;
onClose: () => void;
};
export const UserAttributesDialog = ({
credentialScopeName,
userAttributes,
onClose,
}: UserAttributesDialogProps) => {
const { t } = useTranslation();
return (
<Modal
variant={ModalVariant.medium}
title={t("credentialUserAttributesFor", { credentialScopeName })}
isOpen
onClose={onClose}
>
<Table
aria-label={t("credentialUserAttributes")}
variant={TableVariant.compact}
>
<Thead>
<Tr>
<Th>{t("credentialAttributeName")}</Th>
<Th>{t("credentialAttributeValue")}</Th>
</Tr>
</Thead>
<Tbody>
{Object.entries(userAttributes).map(([key, values]) => (
<Tr key={key}>
<Td>{key}</Td>
<Td>{values.join(", ")}</Td>
</Tr>
))}
</Tbody>
</Table>
</Modal>
);
};
@@ -61,6 +61,9 @@ export const VerifiableCredentials = () => {
<DataListCell key="credential-created-header" width={2}>
<strong>{t("credentialCreatedDate")}</strong>
</DataListCell>,
<DataListCell key="credential-attributes-header" width={2}>
<strong>{t("userAttributes")}</strong>
</DataListCell>,
]}
/>
</DataListItemRow>
@@ -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
@@ -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<string, string[]>;
onClose: () => void;
};
export const UserAttributesDialog = ({
credentialScopeName,
userAttributes,
onClose,
}: UserAttributesDialogProps) => {
const { t } = useTranslation();
return (
<Modal
variant={ModalVariant.medium}
title={t("credentialUserAttributesFor", { credentialScopeName })}
isOpen
onClose={onClose}
>
<Table
aria-label={t("credentialUserAttributes")}
variant={TableVariant.compact}
>
<Thead>
<Tr>
<Th>{t("credentialAttributeName")}</Th>
<Th>{t("credentialAttributeValue")}</Th>
</Tr>
</Thead>
<Tbody>
{Object.entries(userAttributes).map(([key, values]) => (
<Tr key={key}>
<Td>{key}</Td>
<Td>{values.join(", ")}</Td>
</Tr>
))}
</Tbody>
</Table>
</Modal>
);
};
@@ -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<UserVerifiableCredentialRepresentation>();
const [isCreateModalOpen, setIsCreateModalOpen] = useState(false);
const [attributesDialogCredential, setAttributesDialogCredential] =
useState<UserVerifiableCredentialRepresentation>();
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 (
<>
<DeleteConfirm />
<UpdateConfirm />
{attributesDialogCredential?.userAttributes &&
Object.keys(attributesDialogCredential.userAttributes).length > 0 && (
<UserAttributesDialog
credentialScopeName={
attributesDialogCredential.credentialScopeName ?? ""
}
userAttributes={attributesDialogCredential.userAttributes}
onClose={() => setAttributesDialogCredential(undefined)}
/>
)}
{isCreateModalOpen && (
<CreateVerifiableCredentialModal
userId={userId}
@@ -88,23 +123,55 @@ export const UserVerifiableCredentials = ({
name: "credentialScopeName",
displayKey: "credentialScopeName",
cellFormatters: [emptyFormatter()],
transforms: [cellWidth(40)],
transforms: [cellWidth(25)],
},
{
name: "revision",
displayKey: "revision",
cellFormatters: [emptyFormatter()],
transforms: [cellWidth(30)],
transforms: [cellWidth(15)],
},
{
name: "createdDate",
displayKey: "created",
transforms: [cellWidth(30)],
transforms: [cellWidth(20)],
cellRenderer: ({ createdDate }) =>
createdDate ? formatDate(new Date(createdDate)) : "—",
},
{
name: "userAttributes",
displayKey: "userAttributes",
transforms: [cellWidth(40)],
cellRenderer: (credential) => {
if (
!credential.userAttributes ||
Object.keys(credential.userAttributes).length === 0
) {
return (
<span className="pf-v5-u-color-200">
{t("credentialNoUserAttributes")}
</span>
);
}
return (
<Button
variant="link"
onClick={() => setAttributesDialogCredential(credential)}
>
{t("credentialViewAttributes")}
</Button>
);
},
},
]}
actions={[
{
title: t("updateCredential"),
onRowClick: (credential) => {
setSelectedCredential(credential);
toggleUpdateDialog();
},
} as Action<UserVerifiableCredentialRepresentation>,
{
title: t("revoke"),
onRowClick: (credential) => {
@@ -5,4 +5,5 @@ export default interface UserVerifiableCredentialRepresentation {
credentialScopeName?: string;
revision?: string;
createdDate?: number;
userAttributes?: Record<string, string[]>;
}
@@ -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<string, string[]>
@@ -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