mirror of
https://github.com/keycloak/keycloak.git
synced 2026-05-26 13:50:48 +00:00
verifiable credential tab in admin ui
Closes #48575 Signed-off-by: Giuseppe Graziano <g.graziano94@gmail.com>
This commit is contained in:
committed by
Marek Posolda
parent
37601ac438
commit
2a9dadefce
+14
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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)}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -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[]>
|
||||
|
||||
Reference in New Issue
Block a user