From 2a9dadefce05950a1e79a39ad7a3db4e9e19d76b Mon Sep 17 00:00:00 2001 From: Giuseppe Graziano Date: Mon, 11 May 2026 12:16:31 +0200 Subject: [PATCH] verifiable credential tab in admin ui Closes #48575 Signed-off-by: Giuseppe Graziano --- .../admin/messages/messages_en.properties | 14 ++ .../user/CreateVerifiableCredentialModal.tsx | 116 ++++++++++++++++ js/apps/admin-ui/src/user/EditUser.tsx | 28 ++++ .../src/user/UserVerifiableCredentials.tsx | 129 ++++++++++++++++++ js/apps/admin-ui/src/user/routes/User.tsx | 3 +- .../userVerifiableCredentialRepresentation.ts | 8 ++ .../src/resources/users.ts | 38 ++++++ 7 files changed, 335 insertions(+), 1 deletion(-) create mode 100644 js/apps/admin-ui/src/user/CreateVerifiableCredentialModal.tsx create mode 100644 js/apps/admin-ui/src/user/UserVerifiableCredentials.tsx create mode 100644 js/libs/keycloak-admin-client/src/defs/userVerifiableCredentialRepresentation.ts 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 4ca8aecba9c..7a868b72b4d 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 @@ -3326,6 +3326,20 @@ adminPermissionsEnabled=Admin Permissions adminPermissionsEnabledHelp=If enabled, allows managing admin permissions in the realm. verifiableCredentialsEnabled=Verifiable Credentials verifiableCredentialsEnabledHelp=If enabled, allows managing verifiable credentials in this realm. +verifiableCredentials=Verifiable credentials +credentialScopeName=Credential scope name +revision=Revision +noVerifiableCredentials=No verifiable credentials +noVerifiableCredentialsText=No verifiable credentials have been created for this user. +createVerifiableCredential=Create verifiable credential +createVerifiableCredentialSuccess=Verifiable credential successfully created. +createVerifiableCredentialError=Could not create verifiable credential\: {{error}} +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}} +selectCredentialScope=Select a credential scope +noOid4vcScopesAvailable=No OID4VC client scopes are available in this realm. Create OID4VC client scopes first. organizations=Organizations organizationDetails=Organization details organizationsList=Organizations diff --git a/js/apps/admin-ui/src/user/CreateVerifiableCredentialModal.tsx b/js/apps/admin-ui/src/user/CreateVerifiableCredentialModal.tsx new file mode 100644 index 00000000000..f4dc3efae08 --- /dev/null +++ b/js/apps/admin-ui/src/user/CreateVerifiableCredentialModal.tsx @@ -0,0 +1,116 @@ +import type ClientScopeRepresentation from "@keycloak/keycloak-admin-client/lib/defs/clientScopeRepresentation"; +import { + Alert, + AlertVariant, + Button, + Modal, + ModalVariant, +} from "@patternfly/react-core"; +import { useState } from "react"; +import { FormProvider, useForm } from "react-hook-form"; +import { useTranslation } from "react-i18next"; +import { useAdminClient } from "../admin-client"; +import { useAlerts } from "@keycloak/keycloak-ui-shared"; +import { SelectControl } from "@keycloak/keycloak-ui-shared"; +import { useFetch } from "@keycloak/keycloak-ui-shared"; + +type CreateVerifiableCredentialModalProps = { + userId: string; + onClose: () => void; + onCreated: () => void; +}; + +type FormValues = { + credentialScopeName: string; +}; + +export const CreateVerifiableCredentialModal = ({ + userId, + onClose, + onCreated, +}: CreateVerifiableCredentialModalProps) => { + const { adminClient } = useAdminClient(); + const { t } = useTranslation(); + const { addAlert, addError } = useAlerts(); + const [oid4vcScopes, setOid4vcScopes] = useState( + [], + ); + + const form = useForm({ + mode: "onChange", + }); + + const { + handleSubmit, + formState: { isValid, isSubmitting }, + } = form; + + useFetch( + async () => { + const scopes = await adminClient.clientScopes.find(); + return scopes.filter((scope) => scope.protocol === "oid4vc"); + }, + (scopes) => setOid4vcScopes(scopes), + [], + ); + + const onSubmit = async (data: FormValues) => { + try { + await adminClient.users.createVerifiableCredential( + { id: userId }, + { credentialScopeName: data.credentialScopeName }, + ); + addAlert(t("createVerifiableCredentialSuccess"), AlertVariant.success); + onCreated(); + } catch (error) { + addError("createVerifiableCredentialError", error); + } + }; + + return ( + + {t("create")} + , + , + ]} + > + {oid4vcScopes.length === 0 ? ( + + ) : ( + + ({ + key: scope.name!, + value: scope.name!, + }))} + /> + + )} + + ); +}; diff --git a/js/apps/admin-ui/src/user/EditUser.tsx b/js/apps/admin-ui/src/user/EditUser.tsx index f1f52b3f301..4e364bd133c 100644 --- a/js/apps/admin-ui/src/user/EditUser.tsx +++ b/js/apps/admin-ui/src/user/EditUser.tsx @@ -49,6 +49,7 @@ import { UserIdentityProviderLinks } from "./UserIdentityProviderLinks"; import { UserRoleMapping } from "./UserRoleMapping"; import { UserSessions } from "./UserSessions"; import { UserEvents } from "../events/UserEvents"; +import { UserVerifiableCredentials } from "./UserVerifiableCredentials"; import { UserWorkflows } from "./UserWorkflows"; import { UIUserRepresentation, @@ -98,6 +99,8 @@ export default function EditUser() { const isFeatureEnabled = useIsFeatureEnabled(); const showOrganizations = isFeatureEnabled(Feature.Organizations) && realm.organizationsEnabled; + const showVerifiableCredentials = + isFeatureEnabled(Feature.OpenId4VCI) && realm.verifiableCredentialsEnabled; const toTab = (tab: UserTab) => toUser({ @@ -107,6 +110,7 @@ export default function EditUser() { }); const [activeEventsTab, setActiveEventsTab] = useState("userEvents"); + const [activeVcTab, setActiveVcTab] = useState("vcCredentials"); const settingsTab = useRoutableTab(toTab("settings")); const attributesTab = useRoutableTab(toTab("attributes")); @@ -121,6 +125,9 @@ export default function EditUser() { const sessionsTab = useRoutableTab(toTab("sessions")); const eventsTab = useRoutableTab(toTab("events")); const workflowsTab = useRoutableTab(toTab("workflows")); + const verifiableCredentialsTab = useRoutableTab( + toTab("verifiable-credentials"), + ); useFetch( async () => @@ -466,6 +473,27 @@ export default function EditUser() { )} + {showVerifiableCredentials && ( + {t("verifiableCredentials")} + } + {...verifiableCredentialsTab} + > + setActiveVcTab(key as string)} + > + {t("credentials")}} + > + + + + + )} {isFeatureEnabled(Feature.Workflows) && ( { + const { adminClient } = useAdminClient(); + const { t } = useTranslation(); + const { addAlert, addError } = useAlerts(); + const formatDate = useFormatDate(); + const [key, setKey] = useState(0); + const [selectedCredential, setSelectedCredential] = + useState(); + const [isCreateModalOpen, setIsCreateModalOpen] = useState(false); + + const refresh = () => setKey(new Date().getTime()); + + const loader = async () => { + const credentials = await adminClient.users.listVerifiableCredentials({ + id: userId, + }); + return sortBy(credentials, (c) => c.credentialScopeName?.toUpperCase()); + }; + + const [toggleDeleteDialog, DeleteConfirm] = useConfirmDialog({ + titleKey: "revokeVerifiableCredentialTitle", + messageKey: t("revokeVerifiableCredentialConfirm", { + credentialScopeName: selectedCredential?.credentialScopeName, + }), + continueButtonLabel: "revoke", + continueButtonVariant: ButtonVariant.danger, + onConfirm: async () => { + try { + await adminClient.users.revokeVerifiableCredential({ + id: userId, + credentialScopeName: selectedCredential!.credentialScopeName!, + }); + refresh(); + addAlert(t("revokeVerifiableCredentialSuccess"), AlertVariant.success); + } catch (error) { + addError("revokeVerifiableCredentialError", error); + } + }, + }); + + return ( + <> + + {isCreateModalOpen && ( + setIsCreateModalOpen(false)} + onCreated={() => { + refresh(); + setIsCreateModalOpen(false); + }} + /> + )} + setIsCreateModalOpen(true)}> + {t("createVerifiableCredential")} + + } + columns={[ + { + name: "credentialScopeName", + displayKey: "credentialScopeName", + cellFormatters: [emptyFormatter()], + transforms: [cellWidth(40)], + }, + { + name: "revision", + displayKey: "revision", + cellFormatters: [emptyFormatter()], + transforms: [cellWidth(30)], + }, + { + name: "createdDate", + displayKey: "created", + transforms: [cellWidth(30)], + cellRenderer: ({ createdDate }) => + createdDate ? formatDate(new Date(createdDate)) : "—", + }, + ]} + actions={[ + { + title: t("revoke"), + onRowClick: (credential) => { + setSelectedCredential(credential); + toggleDeleteDialog(); + }, + } as Action, + ]} + emptyState={ + setIsCreateModalOpen(true)} + /> + } + /> + + ); +}; diff --git a/js/apps/admin-ui/src/user/routes/User.tsx b/js/apps/admin-ui/src/user/routes/User.tsx index 613bd7c65e3..39c91e6aab1 100644 --- a/js/apps/admin-ui/src/user/routes/User.tsx +++ b/js/apps/admin-ui/src/user/routes/User.tsx @@ -14,7 +14,8 @@ export type UserTab = | "role-mapping" | "identity-provider-links" | "events" - | "workflows"; + | "workflows" + | "verifiable-credentials"; export type UserParams = { realm: string; diff --git a/js/libs/keycloak-admin-client/src/defs/userVerifiableCredentialRepresentation.ts b/js/libs/keycloak-admin-client/src/defs/userVerifiableCredentialRepresentation.ts new file mode 100644 index 00000000000..22c25faf7bf --- /dev/null +++ b/js/libs/keycloak-admin-client/src/defs/userVerifiableCredentialRepresentation.ts @@ -0,0 +1,8 @@ +/** + * Represents a verifiable credential associated with a user. + * */ +export default interface UserVerifiableCredentialRepresentation { + credentialScopeName?: string; + revision?: string; + createdDate?: number; +} diff --git a/js/libs/keycloak-admin-client/src/resources/users.ts b/js/libs/keycloak-admin-client/src/resources/users.ts index 99745824eed..9661264970b 100644 --- a/js/libs/keycloak-admin-client/src/resources/users.ts +++ b/js/libs/keycloak-admin-client/src/resources/users.ts @@ -13,6 +13,7 @@ import type { } from "../defs/userProfileMetadata.js"; import type UserRepresentation from "../defs/userRepresentation.js"; import type UserSessionRepresentation from "../defs/userSessionRepresentation.js"; +import type UserVerifiableCredentialRepresentation from "../defs/userVerifiableCredentialRepresentation.js"; import Resource from "./resource.js"; export interface SearchQuery { @@ -494,6 +495,43 @@ export class Users extends Resource<{ realm?: string }> { urlParamKeys: ["id", "clientId"], }); + /** + * list verifiable credentials for a user + */ + public listVerifiableCredentials = this.makeRequest< + { id: string }, + UserVerifiableCredentialRepresentation[] + >({ + method: "GET", + path: "/{id}/vc/credentials", + urlParamKeys: ["id"], + }); + + /** + * create a verifiable credential for a user + */ + public createVerifiableCredential = this.makeUpdateRequest< + { id: string }, + UserVerifiableCredentialRepresentation, + UserVerifiableCredentialRepresentation + >({ + method: "POST", + path: "/{id}/vc/credentials", + urlParamKeys: ["id"], + }); + + /** + * revoke a verifiable credential from a user + */ + public revokeVerifiableCredential = this.makeRequest< + { id: string; credentialScopeName: string }, + void + >({ + method: "DELETE", + path: "/{id}/vc/credentials/{credentialScopeName}", + urlParamKeys: ["id", "credentialScopeName"], + }); + public getUnmanagedAttributes = this.makeRequest< { id: string }, Record