mirror of
https://github.com/keycloak/keycloak.git
synced 2026-05-26 13:50:48 +00:00
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:
committed by
GitHub
parent
6b3241ea1f
commit
36513bae36
@@ -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>
|
||||
|
||||
+12
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user