{t("userEvents")}}
{...userEventsTab}
>
-
- event.details !== undefined,
- cellRenderer: DetailCell,
- },
- ]}
- isPaginated
- ariaLabelKey="titleEvents"
- toolbarItem={userEventSearchFormDisplay()}
- columns={[
- {
- name: "time",
- displayKey: "time",
- cellRenderer: (row) =>
- formatDate(new Date(row.time!), FORMAT_DATE_AND_TIME),
- },
- {
- name: "userId",
- displayKey: "user",
- cellRenderer: UserDetailLink,
- },
- {
- name: "type",
- displayKey: "eventType",
- cellRenderer: StatusRow,
- },
- {
- name: "ipAddress",
- displayKey: "ipAddress",
- transforms: [cellWidth(10)],
- },
- {
- name: "clientId",
- displayKey: "client",
- },
- ]}
- emptyState={
-
- }
- isSearching={Object.keys(activeFilters).length > 0}
- />
-
+
{t("adminEvents")}}
diff --git a/js/apps/admin-ui/src/events/UserEvents.tsx b/js/apps/admin-ui/src/events/UserEvents.tsx
new file mode 100644
index 0000000000..f11e22cb27
--- /dev/null
+++ b/js/apps/admin-ui/src/events/UserEvents.tsx
@@ -0,0 +1,476 @@
+import type EventRepresentation from "@keycloak/keycloak-admin-client/lib/defs/eventRepresentation";
+import type EventType from "@keycloak/keycloak-admin-client/lib/defs/eventTypes";
+import type { RealmEventsConfigRepresentation } from "@keycloak/keycloak-admin-client/lib/defs/realmEventsConfigRepresentation";
+import {
+ KeycloakDataTable,
+ KeycloakSelect,
+ ListEmptyState,
+ SelectVariant,
+ TextControl,
+ useFetch,
+} from "@keycloak/keycloak-ui-shared";
+import {
+ ActionGroup,
+ Button,
+ Chip,
+ ChipGroup,
+ DatePicker,
+ DescriptionList,
+ DescriptionListDescription,
+ DescriptionListGroup,
+ DescriptionListTerm,
+ Flex,
+ FlexItem,
+ Form,
+ FormGroup,
+ Icon,
+ SelectOption,
+ Tooltip,
+} from "@patternfly/react-core";
+import { CheckCircleIcon, WarningTriangleIcon } from "@patternfly/react-icons";
+import { cellWidth } from "@patternfly/react-table";
+import { pickBy } from "lodash-es";
+import { useState } from "react";
+import { Controller, FormProvider, useForm } from "react-hook-form";
+import { useTranslation } from "react-i18next";
+import { Link } from "react-router-dom";
+import { useAdminClient } from "../admin-client";
+import DropdownPanel from "../components/dropdown-panel/DropdownPanel";
+import { useRealm } from "../context/realm-context/RealmContext";
+import { toUser } from "../user/routes/User";
+import useFormatDate, { FORMAT_DATE_AND_TIME } from "../utils/useFormatDate";
+
+import "./events.css";
+
+type UserEventSearchForm = {
+ client: string;
+ dateFrom: string;
+ dateTo: string;
+ user: string;
+ type: EventType[];
+ ipAddress: string;
+};
+
+const StatusRow = (event: EventRepresentation) =>
+ !event.error ? (
+
+
+
+
+ {event.type}
+
+ ) : (
+
+
+
+
+
+ {event.type}
+
+
+ );
+
+const DetailCell = (event: EventRepresentation) => (
+
+ {event.details &&
+ Object.entries(event.details).map(([key, value]) => (
+
+ {key}
+ {value}
+
+ ))}
+ {event.error && (
+
+ error
+ {event.error}
+
+ )}
+
+);
+
+const UserDetailLink = (event: EventRepresentation) => {
+ const { t } = useTranslation();
+ const { realm } = useRealm();
+
+ return (
+ <>
+ {event.userId && (
+
+ {event.userId}
+
+ )}
+ {!event.userId && t("noUserDetails")}
+ >
+ );
+};
+
+type UserEventsProps = {
+ user?: string;
+ client?: string;
+};
+
+export const UserEvents = ({ user, client }: UserEventsProps) => {
+ const { adminClient } = useAdminClient();
+
+ const { t } = useTranslation();
+ const { realm } = useRealm();
+ const formatDate = useFormatDate();
+ const [key, setKey] = useState(0);
+ const [searchDropdownOpen, setSearchDropdownOpen] = useState(false);
+ const [selectOpen, setSelectOpen] = useState(false);
+ const [events, setEvents] = useState();
+ const [activeFilters, setActiveFilters] = useState<
+ Partial
+ >({
+ ...(user && { user }),
+ ...(client && { client }),
+ });
+
+ const defaultValues: UserEventSearchForm = {
+ client: client ? client : "",
+ dateFrom: "",
+ dateTo: "",
+ user: user ? user : "",
+ type: [],
+ ipAddress: "",
+ };
+
+ const filterLabels: Record = {
+ client: t("client"),
+ dateFrom: t("dateFrom"),
+ dateTo: t("dateTo"),
+ user: t("userId"),
+ type: t("eventType"),
+ ipAddress: t("ipAddress"),
+ };
+
+ const form = useForm({
+ mode: "onChange",
+ defaultValues,
+ });
+
+ const {
+ getValues,
+ reset,
+ formState: { isDirty },
+ control,
+ handleSubmit,
+ } = form;
+
+ useFetch(
+ () => adminClient.realms.getConfigEvents({ realm }),
+ (events) => setEvents(events),
+ [],
+ );
+
+ function loader(first?: number, max?: number) {
+ return adminClient.realms.findEvents({
+ // The admin client wants 'dateFrom' and 'dateTo' to be Date objects, however it cannot actually handle them so we need to cast to any.
+ ...(activeFilters as any),
+ realm,
+ first,
+ max,
+ });
+ }
+
+ function onSubmit() {
+ setSearchDropdownOpen(false);
+ commitFilters();
+ }
+
+ function resetSearch() {
+ reset();
+ commitFilters();
+ }
+
+ function removeFilter(key: keyof UserEventSearchForm) {
+ const formValues: UserEventSearchForm = { ...getValues() };
+ delete formValues[key];
+
+ reset({ ...defaultValues, ...formValues });
+ commitFilters();
+ }
+
+ function removeFilterValue(
+ key: keyof UserEventSearchForm,
+ valueToRemove: EventType,
+ ) {
+ const formValues = getValues();
+ const fieldValue = formValues[key];
+ const newFieldValue = Array.isArray(fieldValue)
+ ? fieldValue.filter((val) => val !== valueToRemove)
+ : fieldValue;
+
+ reset({ ...formValues, [key]: newFieldValue });
+ commitFilters();
+ }
+
+ function commitFilters() {
+ const newFilters: Partial = pickBy(
+ getValues(),
+ (value) => value !== "" || (Array.isArray(value) && value.length > 0),
+ );
+
+ setActiveFilters(newFilters);
+ setKey(key + 1);
+ }
+
+ const userEventSearchFormDisplay = () => {
+ return (
+
+
+
+
+
+
+
+
+ {Object.entries(activeFilters).length > 0 && (
+
+ {Object.entries(activeFilters).map((filter) => {
+ const [key, value] = filter as [
+ keyof UserEventSearchForm,
+ string | EventType[],
+ ];
+
+ const disableClose =
+ (key === "user" && !!user) ||
+ (key === "client" && !!client);
+
+ return (
+ removeFilter(key)}
+ >
+ {typeof value === "string" ? (
+ {value}
+ ) : (
+ value.map((entry) => (
+ removeFilterValue(key, entry)}
+ >
+ {t(`eventTypes.${entry}.name`)}
+
+ ))
+ )}
+
+ );
+ })}
+
+ )}
+
+
+
+ );
+ };
+
+ return (
+
+ event.details !== undefined,
+ cellRenderer: DetailCell,
+ },
+ ]}
+ isPaginated
+ ariaLabelKey="titleEvents"
+ toolbarItem={userEventSearchFormDisplay()}
+ columns={[
+ {
+ name: "time",
+ displayKey: "time",
+ cellRenderer: (row) =>
+ formatDate(new Date(row.time!), FORMAT_DATE_AND_TIME),
+ },
+ {
+ name: "userId",
+ displayKey: "user",
+ cellRenderer: UserDetailLink,
+ },
+ {
+ name: "type",
+ displayKey: "eventType",
+ cellRenderer: StatusRow,
+ },
+ {
+ name: "ipAddress",
+ displayKey: "ipAddress",
+ transforms: [cellWidth(10)],
+ },
+ {
+ name: "clientId",
+ displayKey: "client",
+ },
+ ]}
+ emptyState={
+
+ }
+ isSearching={Object.keys(activeFilters).length > 0}
+ />
+
+ );
+};
diff --git a/js/apps/admin-ui/src/user/EditUser.tsx b/js/apps/admin-ui/src/user/EditUser.tsx
index 6bf00df777..b8bf43987a 100644
--- a/js/apps/admin-ui/src/user/EditUser.tsx
+++ b/js/apps/admin-ui/src/user/EditUser.tsx
@@ -47,6 +47,7 @@ import { UserGroups } from "./UserGroups";
import { UserIdentityProviderLinks } from "./UserIdentityProviderLinks";
import { UserRoleMapping } from "./UserRoleMapping";
import { UserSessions } from "./UserSessions";
+import { UserEvents } from "../events/UserEvents";
import {
UIUserRepresentation,
UserFormFields,
@@ -112,6 +113,7 @@ export default function EditUser() {
const consentsTab = useTab("consents");
const identityProviderLinksTab = useTab("identity-provider-links");
const sessionsTab = useTab("sessions");
+ const userEventsTab = useTab("user-events");
useFetch(
async () =>
@@ -424,6 +426,15 @@ export default function EditUser() {
>
+ {hasAccess("view-events") && (
+ {t("userEvents")}}
+ {...userEventsTab}
+ >
+
+
+ )}
diff --git a/js/apps/admin-ui/src/user/routes/User.tsx b/js/apps/admin-ui/src/user/routes/User.tsx
index f72ae3c23f..5bc9c7d9be 100644
--- a/js/apps/admin-ui/src/user/routes/User.tsx
+++ b/js/apps/admin-ui/src/user/routes/User.tsx
@@ -12,7 +12,8 @@ export type UserTab =
| "sessions"
| "credentials"
| "role-mapping"
- | "identity-provider-links";
+ | "identity-provider-links"
+ | "user-events";
export type UserParams = {
realm: string;