diff --git a/js/apps/admin-ui/cypress/e2e/clients_test.spec.ts b/js/apps/admin-ui/cypress/e2e/clients_test.spec.ts index 2e93a53897..d912ee9f2b 100644 --- a/js/apps/admin-ui/cypress/e2e/clients_test.spec.ts +++ b/js/apps/admin-ui/cypress/e2e/clients_test.spec.ts @@ -1221,7 +1221,8 @@ describe("Clients test", () => { .checkTabExists(ClientsDetailsTab.Sessions, true) .checkTabExists(ClientsDetailsTab.Permissions, true) .checkTabExists(ClientsDetailsTab.Advanced, true) - .checkNumberOfTabsIsEqual(5); + .checkTabExists(ClientsDetailsTab.UserEvents, true) + .checkNumberOfTabsIsEqual(6); }); it("Hides the delete action", () => { diff --git a/js/apps/admin-ui/cypress/support/pages/admin-ui/manage/clients/client_details/ClientDetailsPage.ts b/js/apps/admin-ui/cypress/support/pages/admin-ui/manage/clients/client_details/ClientDetailsPage.ts index 6d6b95cd01..06b3dbdd9a 100644 --- a/js/apps/admin-ui/cypress/support/pages/admin-ui/manage/clients/client_details/ClientDetailsPage.ts +++ b/js/apps/admin-ui/cypress/support/pages/admin-ui/manage/clients/client_details/ClientDetailsPage.ts @@ -19,6 +19,7 @@ export enum ClientsDetailsTab { ServiceAccountsRoles = "Service accounts roles", Advanced = "Advanced", Scope = "Scope", + UserEvents = "User events", } export default class ClientDetailsPage extends CommonPage { diff --git a/js/apps/admin-ui/src/clients/ClientDetails.tsx b/js/apps/admin-ui/src/clients/ClientDetails.tsx index 64a4736108..9f3645f6d2 100644 --- a/js/apps/admin-ui/src/clients/ClientDetails.tsx +++ b/js/apps/admin-ui/src/clients/ClientDetails.tsx @@ -72,6 +72,7 @@ import { ClientScopes } from "./scopes/ClientScopes"; import { EvaluateScopes } from "./scopes/EvaluateScopes"; import { ServiceAccount } from "./service-account/ServiceAccount"; import { getProtocolName, isRealmClient } from "./utils"; +import { UserEvents } from "../events/UserEvents"; type ClientDetailHeaderProps = { onChange: (value: boolean) => void; @@ -244,6 +245,7 @@ export default function ClientDetails() { const sessionsTab = useTab("sessions"); const permissionsTab = useTab("permissions"); const advancedTab = useTab("advanced"); + const userEventsTab = useTab("user-events"); const useClientScopesTab = (tab: ClientScopesTab) => useRoutableTab( @@ -663,6 +665,15 @@ export default function ClientDetails() { > + {hasAccess("view-events") && ( + {t("userEvents")}} + {...userEventsTab} + > + + + )} diff --git a/js/apps/admin-ui/src/clients/routes/Client.tsx b/js/apps/admin-ui/src/clients/routes/Client.tsx index 2701af4f0c..871f95e934 100644 --- a/js/apps/admin-ui/src/clients/routes/Client.tsx +++ b/js/apps/admin-ui/src/clients/routes/Client.tsx @@ -14,7 +14,8 @@ export type ClientTab = | "authorization" | "serviceAccount" | "permissions" - | "sessions"; + | "sessions" + | "user-events"; export type ClientParams = { realm: string; diff --git a/js/apps/admin-ui/src/events/EventsSection.tsx b/js/apps/admin-ui/src/events/EventsSection.tsx index 1de72d6f08..c404c41147 100644 --- a/js/apps/admin-ui/src/events/EventsSection.tsx +++ b/js/apps/admin-ui/src/events/EventsSection.tsx @@ -1,44 +1,6 @@ -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, - PageSection, - SelectOption, - Tab, - TabTitleText, - 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 { PageSection, Tab, TabTitleText } from "@patternfly/react-core"; import { Trans, useTranslation } from "react-i18next"; import { Link } from "react-router-dom"; -import { useAdminClient } from "../admin-client"; -import DropdownPanel from "../components/dropdown-panel/DropdownPanel"; import { RoutableTabs, useRoutableTab, @@ -47,383 +9,21 @@ import { ViewHeader } from "../components/view-header/ViewHeader"; import { useRealm } from "../context/realm-context/RealmContext"; import helpUrls from "../help-urls"; import { toRealmSettings } from "../realm-settings/routes/RealmSettings"; -import { toUser } from "../user/routes/User"; -import useFormatDate, { FORMAT_DATE_AND_TIME } from "../utils/useFormatDate"; import { AdminEvents } from "./AdminEvents"; +import { UserEvents } from "./UserEvents"; import { EventsTab, toEvents } from "./routes/Events"; import "./events.css"; -type UserEventSearchForm = { - client: string; - dateFrom: string; - dateTo: string; - user: string; - type: EventType[]; - ipAddress: string; -}; - -const defaultValues: UserEventSearchForm = { - client: "", - dateFrom: "", - dateTo: "", - user: "", - type: [], - ipAddress: "", -}; - -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")} - - ); -}; - export default function EventsSection() { - 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 - >({}); - - 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, - }); - } const useTab = (tab: EventsTab) => useRoutableTab(toEvents({ realm, tab })); const userEventsTab = useTab("user-events"); const adminEventsTab = useTab("admin-events"); - 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 ( - - - - -
- - - ( - setSelectOpen(isOpen)} - selections={field.value} - onSelect={(selectedValue) => { - const option = selectedValue.toString() as EventType; - const changedValue = field.value.includes(option) - ? field.value.filter((item) => item !== option) - : [...field.value, option]; - - field.onChange(changedValue); - }} - onClear={() => { - field.onChange([]); - }} - isOpen={selectOpen} - aria-labelledby={"eventType"} - chipGroupComponent={ - - {field.value.map((chip) => ( - { - event.stopPropagation(); - field.onChange( - field.value.filter((val) => val !== chip), - ); - }} - > - {t(`eventTypes.${chip}.name`)} - - ))} - - } - > - {events?.enabledEventTypes?.map((option) => ( - - {t(`eventTypes.${option}.name`)} - - ))} - - )} - /> - - - - ( - field.onChange(value)} - inputProps={{ id: "kc-dateFrom" }} - /> - )} - /> - - - ( - field.onChange(value)} - inputProps={{ id: "kc-dateTo" }} - /> - )} - /> - - - - - - - -
-
- - {Object.entries(activeFilters).length > 0 && ( -
- {Object.entries(activeFilters).map((filter) => { - const [key, value] = filter as [ - keyof UserEventSearchForm, - string | EventType[], - ]; - - return ( - removeFilter(key)} - > - {typeof value === "string" ? ( - {value} - ) : ( - value.map((entry) => ( - removeFilterValue(key, entry)} - > - {t(`eventTypes.${entry}.name`)} - - )) - )} - - ); - })} -
- )} -
-
-
- ); - }; - return ( <> {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 ( + + + + +
+ + + ( + setSelectOpen(isOpen)} + selections={field.value} + onSelect={(selectedValue) => { + const option = selectedValue.toString() as EventType; + const changedValue = field.value.includes(option) + ? field.value.filter((item) => item !== option) + : [...field.value, option]; + + field.onChange(changedValue); + }} + onClear={() => { + field.onChange([]); + }} + isOpen={selectOpen} + aria-labelledby={"eventType"} + chipGroupComponent={ + + {field.value.map((chip) => ( + { + event.stopPropagation(); + field.onChange( + field.value.filter((val) => val !== chip), + ); + }} + > + {t(`eventTypes.${chip}.name`)} + + ))} + + } + > + {events?.enabledEventTypes?.map((option) => ( + + {t(`eventTypes.${option}.name`)} + + ))} + + )} + /> + + + + ( + field.onChange(value)} + inputProps={{ id: "kc-dateFrom" }} + /> + )} + /> + + + ( + field.onChange(value)} + inputProps={{ id: "kc-dateTo" }} + /> + )} + /> + + + + + + + +
+
+ + {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;