verifiable credential tab in admin ui

Closes #48575

Signed-off-by: Giuseppe Graziano <g.graziano94@gmail.com>
This commit is contained in:
Giuseppe Graziano
2026-05-11 12:16:31 +02:00
committed by Marek Posolda
parent 37601ac438
commit 2a9dadefce
7 changed files with 335 additions and 1 deletions
@@ -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
@@ -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<ClientScopeRepresentation[]>(
[],
);
const form = useForm<FormValues>({
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 (
<Modal
variant={ModalVariant.small}
title={t("createVerifiableCredential")}
isOpen
onClose={onClose}
actions={[
<Button
key="create"
variant="primary"
onClick={handleSubmit(onSubmit)}
isDisabled={!isValid || isSubmitting || oid4vcScopes.length === 0}
isLoading={isSubmitting}
>
{t("create")}
</Button>,
<Button key="cancel" variant="link" onClick={onClose}>
{t("cancel")}
</Button>,
]}
>
{oid4vcScopes.length === 0 ? (
<Alert
variant="warning"
isInline
title={t("noOid4vcScopesAvailable")}
/>
) : (
<FormProvider {...form}>
<SelectControl
name="credentialScopeName"
label={t("selectCredentialScope")}
labelIcon={t("credentialScopeName")}
controller={{
defaultValue: "",
rules: { required: t("required") },
}}
options={oid4vcScopes.map((scope) => ({
key: scope.name!,
value: scope.name!,
}))}
/>
</FormProvider>
)}
</Modal>
);
};
+28
View File
@@ -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() {
</Tabs>
</Tab>
)}
{showVerifiableCredentials && (
<Tab
data-testid="verifiable-credentials-tab"
title={
<TabTitleText>{t("verifiableCredentials")}</TabTitleText>
}
{...verifiableCredentialsTab}
>
<Tabs
activeKey={activeVcTab}
onSelect={(_, key) => setActiveVcTab(key as string)}
>
<Tab
eventKey="vcCredentials"
title={<TabTitleText>{t("credentials")}</TabTitleText>}
>
<UserVerifiableCredentials userId={user.id!} />
</Tab>
</Tabs>
</Tab>
)}
{isFeatureEnabled(Feature.Workflows) && (
<Tab
data-testid="workflows-tab"
@@ -0,0 +1,129 @@
import type UserVerifiableCredentialRepresentation from "@keycloak/keycloak-admin-client/lib/defs/userVerifiableCredentialRepresentation";
import { AlertVariant, Button, ButtonVariant } from "@patternfly/react-core";
import { CubesIcon } from "@patternfly/react-icons";
import { cellWidth } from "@patternfly/react-table";
import { sortBy } from "lodash-es";
import { useState } from "react";
import { useTranslation } from "react-i18next";
import { useAdminClient } from "../admin-client";
import { useAlerts } from "@keycloak/keycloak-ui-shared";
import { useConfirmDialog } from "../components/confirm-dialog/ConfirmDialog";
import { ListEmptyState } from "@keycloak/keycloak-ui-shared";
import { Action, KeycloakDataTable } from "@keycloak/keycloak-ui-shared";
import { emptyFormatter } from "../util";
import useFormatDate from "../utils/useFormatDate";
import { CreateVerifiableCredentialModal } from "./CreateVerifiableCredentialModal";
type UserVerifiableCredentialsProps = {
userId: string;
};
export const UserVerifiableCredentials = ({
userId,
}: UserVerifiableCredentialsProps) => {
const { adminClient } = useAdminClient();
const { t } = useTranslation();
const { addAlert, addError } = useAlerts();
const formatDate = useFormatDate();
const [key, setKey] = useState(0);
const [selectedCredential, setSelectedCredential] =
useState<UserVerifiableCredentialRepresentation>();
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 (
<>
<DeleteConfirm />
{isCreateModalOpen && (
<CreateVerifiableCredentialModal
userId={userId}
onClose={() => setIsCreateModalOpen(false)}
onCreated={() => {
refresh();
setIsCreateModalOpen(false);
}}
/>
)}
<KeycloakDataTable
loader={loader}
key={key}
ariaLabelKey="verifiableCredentials"
searchPlaceholderKey=" "
toolbarItem={
<Button onClick={() => setIsCreateModalOpen(true)}>
{t("createVerifiableCredential")}
</Button>
}
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<UserVerifiableCredentialRepresentation>,
]}
emptyState={
<ListEmptyState
hasIcon={true}
icon={CubesIcon}
message={t("noVerifiableCredentials")}
instructions={t("noVerifiableCredentialsText")}
primaryActionText={t("createVerifiableCredential")}
onPrimaryAction={() => setIsCreateModalOpen(true)}
/>
}
/>
</>
);
};
+2 -1
View File
@@ -14,7 +14,8 @@ export type UserTab =
| "role-mapping"
| "identity-provider-links"
| "events"
| "workflows";
| "workflows"
| "verifiable-credentials";
export type UserParams = {
realm: string;
@@ -0,0 +1,8 @@
/**
* Represents a verifiable credential associated with a user.
* */
export default interface UserVerifiableCredentialRepresentation {
credentialScopeName?: string;
revision?: string;
createdDate?: number;
}
@@ -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<string, string[]>