events: search user events form, chips for searched fields and tests (#930)

This commit is contained in:
agagancarczyk 2021-08-20 10:13:47 +01:00 committed by GitHub
parent 7efa03dc3f
commit 3469ccb509
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
5 changed files with 577 additions and 118 deletions

View file

@ -0,0 +1,66 @@
import LoginPage from "../support/pages/LoginPage";
import SidebarPage from "../support/pages/admin_console/SidebarPage";
import EventsPage from "../support/pages/admin_console/manage/events/EventsPage";
import RealmSettingsPage from "../support/pages/admin_console/manage/realm_settings/RealmSettingsPage";
import Masthead from "../support/pages/admin_console/Masthead";
import { keycloakBefore } from "../support/util/keycloak_before";
const loginPage = new LoginPage();
const sidebarPage = new SidebarPage();
const eventsPage = new EventsPage();
const realmSettingsPage = new RealmSettingsPage();
const masthead = new Masthead();
describe("Search events test", function () {
describe("Search events dropdown", function () {
beforeEach(function () {
keycloakBefore();
loginPage.logIn();
sidebarPage.goToEvents();
});
it("Check search dropdown display", () => {
eventsPage.shouldDisplay();
});
it("Check search form fields display", () => {
eventsPage.shouldHaveFormFields();
});
it("Check event type dropdown options exist", () => {
eventsPage.shouldHaveEventTypeOptions();
});
it("Check `search events` button disabled by default", () => {
eventsPage.shouldHaveSearchBtnDisabled();
});
it.skip("Check search and removal works", () => {
sidebarPage.goToRealmSettings();
cy.getId("rs-realm-events-tab").click();
cy.get("#eventsEnabled-switch-on")
.should("exist")
.then((exist) => {
if (exist) {
sidebarPage.goToEvents();
eventsPage.shouldDoSearchAndRemoveChips();
} else {
realmSettingsPage
.toggleSwitch(realmSettingsPage.enableEvents)
.save(realmSettingsPage.eventsUserSave);
masthead.checkNotificationMessage(
"Successfully saved configuration"
);
sidebarPage.goToEvents();
eventsPage.shouldDoSearchAndRemoveChips();
}
});
});
it("Check `search events` button enabled", () => {
eventsPage.shouldHaveSearchBtnEnabled();
});
});
});

View file

@ -0,0 +1,80 @@
export default class EventsPage {
searchEventDrpDwn = ".pf-c-dropdown__toggle";
searchEventDrpDwnBtn =
".keycloak__user_events_search_selector_dropdown__toggle";
searchForm = ".pf-c-dropdown__menu";
userIdInputFld = "userId-searchField";
eventTypeDrpDwnFld = "event-type-searchField";
clientInputFld = "client-searchField";
dateFromInputFld = "dateFrom-searchField";
dateToInputFld = "dateTo-searchField";
searchEventsBtn = "search-events-btn";
eventTypeList = ".pf-c-form-control";
eventTypeOption = ".pf-c-select__menu-item";
eventTypeInputFld = ".pf-c-form-control.pf-c-select__toggle-typeahead";
eventTypeBtn = ".pf-c-button.pf-c-select__toggle-button.pf-m-plain";
eventsPageTitle = ".pf-c-title";
shouldDisplay() {
cy.get(this.searchEventDrpDwn).should("exist");
}
shouldHaveFormFields() {
cy.get(this.searchEventDrpDwnBtn).click();
cy.get(this.searchForm).contains("User ID");
cy.get(this.searchForm).contains("Event type");
cy.get(this.searchForm).contains("Client");
cy.get(this.searchForm).contains("Date(from)");
cy.get(this.searchForm).contains("Date(to)");
cy.get(this.searchForm).contains("Search events");
}
shouldHaveEventTypeOptions() {
cy.get(this.searchEventDrpDwnBtn).click();
cy.get(this.eventTypeList).should("exist");
}
shouldHaveSearchBtnDisabled() {
cy.get(this.searchEventDrpDwnBtn).click();
cy.getId(this.searchEventsBtn).should("have.attr", "disabled");
}
shouldHaveSearchBtnEnabled() {
cy.get(this.searchEventDrpDwnBtn).click();
cy.getId(this.userIdInputFld).type("11111");
cy.getId(this.searchEventsBtn).should("not.have.attr", "disabled");
}
shouldDoSearchAndRemoveChips() {
cy.get(this.searchEventDrpDwnBtn).click();
cy.get(this.eventTypeInputFld).type("LOGIN");
cy.get(this.eventTypeOption).contains("LOGIN").click();
cy.intercept("/auth/admin/realms/master/events*").as("eventsFetch");
cy.getId(this.searchEventsBtn).click();
cy.wait("@eventsFetch");
cy.get("table").contains("td", "LOGIN");
cy.get("table").should("not.have.text", "CODE_TO_TOKEN");
cy.get("table").should("not.have.text", "CODE_TO_TOKEN_ERROR");
cy.get("table").should("not.have.text", "LOGIN_ERROR");
cy.get("table").should("not.have.text", "LOGOUT");
// cy.get('[id^=remove_pf]').click();
// cy.get("table").contains("LOGIN");
// cy.get(this.searchEventDrpDwnBtn).click();
// cy.getId(this.userIdInputFld).type("11111");
// cy.get(this.eventsPageTitle).contains("No events logged");
// cy.getId(this.searchEventsBtn).click();
// cy.get('[id^=remove_group]').click();
// cy.get("table").contains("LOGIN");
}
shouldDoNoResultsSearch() {
cy.get(this.searchEventDrpDwnBtn).click();
cy.getId(this.userIdInputFld).type("test");
cy.getId(this.searchEventsBtn).click();
cy.get(this.eventsPageTitle).contains("No events logged");
}
}

View file

@ -1,105 +1,182 @@
import {
ActionGroup,
Button,
Chip,
ChipGroup,
DescriptionList,
DescriptionListDescription,
DescriptionListGroup,
DescriptionListTerm,
Divider,
Dropdown,
DropdownToggle,
Flex,
FlexItem,
Form,
FormGroup,
PageSection,
Select,
SelectOption,
SelectVariant,
Tab,
TabTitleText,
ToolbarItem,
TextInput,
Tooltip,
} from "@patternfly/react-core";
import { CheckCircleIcon, WarningTriangleIcon } from "@patternfly/react-icons";
import { cellWidth, expandable } from "@patternfly/react-table";
import type EventRepresentation from "keycloak-admin/lib/defs/eventRepresentation";
import type EventType from "keycloak-admin/lib/defs/eventTypes";
import type { RealmEventsConfigRepresentation } from "keycloak-admin/lib/defs/realmEventsConfigRepresentation";
import { pickBy } from "lodash";
import moment from "moment";
import React, { useState } from "react";
import { Controller, useForm } from "react-hook-form";
import { Trans, useTranslation } from "react-i18next";
import { Link } from "react-router-dom";
import { KeycloakTabs } from "../components/keycloak-tabs/KeycloakTabs";
import { ListEmptyState } from "../components/list-empty-state/ListEmptyState";
import { KeycloakDataTable } from "../components/table-toolbar/KeycloakDataTable";
import { ViewHeader } from "../components/view-header/ViewHeader";
import { useAdminClient } from "../context/auth/AdminClient";
import { useAdminClient, useFetch } from "../context/auth/AdminClient";
import { useRealm } from "../context/realm-context/RealmContext";
import { toRealmSettings } from "../realm-settings/routes/RealmSettings";
import { toUser } from "../user/routes/User";
import { AdminEvents } from "./AdminEvents";
import "./events-section.css";
type UserEventSearchForm = {
client: string;
dateFrom: string;
dateTo: string;
user: string;
type: EventType[];
};
const defaultValues: UserEventSearchForm = {
client: "",
dateFrom: "",
dateTo: "",
user: "",
type: [],
};
const StatusRow = (event: EventRepresentation) =>
!event.error ? (
<span>
<CheckCircleIcon color="green" /> {event.type}
</span>
) : (
<Tooltip content={event.error}>
<span>
<WarningTriangleIcon color="orange" /> {event.type}
</span>
</Tooltip>
);
const DetailCell = (event: EventRepresentation) => (
<DescriptionList isHorizontal className="keycloak_eventsection_details">
{Object.entries(event.details!).map(([key, value]) => (
<DescriptionListGroup key={key}>
<DescriptionListTerm>{key}</DescriptionListTerm>
<DescriptionListDescription>{value}</DescriptionListDescription>
</DescriptionListGroup>
))}
</DescriptionList>
);
export const EventsSection = () => {
const { t } = useTranslation("events");
const adminClient = useAdminClient();
const { realm } = useRealm();
const [key, setKey] = useState(0);
const refresh = () => setKey(new Date().getTime());
const [searchDropdownOpen, setSearchDropdownOpen] = useState(false);
const [selectOpen, setSelectOpen] = useState(false);
const [events, setEvents] = useState<RealmEventsConfigRepresentation>();
const [activeFilters, setActiveFilters] = useState<
Partial<UserEventSearchForm>
>({});
const loader = async (first?: number, max?: number, search?: string) => {
const params = {
first: first!,
max: max!,
realm,
};
if (search) {
console.log("how to search?", search);
}
return await adminClient.realms.findEvents({ ...params });
const filterLabels: Record<keyof UserEventSearchForm, string> = {
client: t("client"),
dateFrom: t("dateFrom"),
dateTo: t("dateTo"),
user: t("userId"),
type: t("eventType"),
};
const StatusRow = (event: EventRepresentation) => (
<>
{!event.error && (
<span key={`status-${event.time}-${event.type}`}>
<CheckCircleIcon
color="green"
key={`circle-${event.time}-${event.type}`}
/>{" "}
{event.type}
</span>
)}
{event.error && (
<Tooltip
content={event.error}
key={`tooltip-${event.time}-${event.type}`}
>
<span key={`label-${event.time}-${event.type}`}>
<WarningTriangleIcon
color="orange"
key={`triangle-${event.time}-${event.type}`}
/>{" "}
{event.type}
</span>
</Tooltip>
)}
</>
const {
getValues,
register,
reset,
formState: { isDirty },
control,
} = useForm<UserEventSearchForm>({
shouldUnregister: false,
mode: "onChange",
defaultValues,
});
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 submitSearch() {
setSearchDropdownOpen(false);
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<UserEventSearchForm> = pickBy(
getValues(),
(value) => value !== "" || (Array.isArray(value) && value.length > 0)
);
setActiveFilters(newFilters);
setKey(key + 1);
}
const UserDetailLink = (event: EventRepresentation) => (
<>
<Link
key={`link-${event.time}-${event.type}`}
to={toUser({ realm, id: event.userId!, tab: "settings" })}
>
{event.userId}
</Link>
</>
);
const DetailCell = (event: EventRepresentation) => (
<>
<DescriptionList isHorizontal className="keycloak_eventsection_details">
{Object.keys(event.details!).map((k) => (
<DescriptionListGroup key={`detail-${event.time}-${event.type}`}>
<DescriptionListTerm>{k}</DescriptionListTerm>
<DescriptionListDescription>
{event.details![k]}
</DescriptionListDescription>
</DescriptionListGroup>
))}
</DescriptionList>
</>
<Link
key={`link-${event.time}-${event.type}`}
to={toUser({ realm, id: event.userId!, tab: "settings" })}
>
{event.userId}
</Link>
);
return (
@ -124,60 +201,252 @@ export const EventsSection = () => {
eventKey="userEvents"
title={<TabTitleText>{t("userEvents")}</TabTitleText>}
>
<KeycloakDataTable
key={key}
loader={loader}
detailColumns={[
{
name: "details",
enabled: (event) => event.details !== undefined,
cellRenderer: DetailCell,
},
]}
isPaginated
ariaLabelKey="events:title"
searchPlaceholderKey="events:searchForEvent"
toolbarItem={
<>
<ToolbarItem>
<Button onClick={refresh}>{t("refresh")}</Button>
</ToolbarItem>
</>
}
columns={[
{
name: "time",
displayKey: "events:time",
cellRenderer: (row) => moment(row.time).format("LLL"),
cellFormatters: [expandable],
},
{
name: "userId",
displayKey: "events:user",
cellRenderer: UserDetailLink,
},
{
name: "type",
displayKey: "events:eventType",
cellRenderer: StatusRow,
},
{
name: "ipAddress",
displayKey: "events:ipAddress",
transforms: [cellWidth(10)],
},
{
name: "clientId",
displayKey: "events:client",
},
]}
emptyState={
<ListEmptyState
message={t("emptyEvents")}
instructions={t("emptyEventsInstructions")}
/>
}
/>
<Flex>
<FlexItem>
<Dropdown
id="user-events-search-select"
data-testid="UserEventsSearchSelector"
className="pf-u-mt-md pf-u-ml-md pf-u-mb-md"
toggle={
<DropdownToggle
data-testid="userEventsSearchSelectorToggle"
onToggle={(isOpen) => setSearchDropdownOpen(isOpen)}
className="keycloak__user_events_search_selector_dropdown__toggle"
>
{t("searchForEvent")}
</DropdownToggle>
}
isOpen={searchDropdownOpen}
>
<Form
isHorizontal
className="keycloak__user_events_search__form"
data-testid="searchForm"
>
<FormGroup
label={t("userId")}
fieldId="kc-userId"
className="keycloak__user_events_search__form_label"
>
<TextInput
ref={register()}
type="text"
id="kc-userId"
name="user"
data-testid="userId-searchField"
/>
</FormGroup>
<FormGroup
label={t("eventType")}
fieldId="kc-eventType"
className="keycloak__user_events_search__form_label"
>
<Controller
name="type"
control={control}
render={({
onChange,
value,
}: {
onChange: (newValue: EventType[]) => void;
value: EventType[];
}) => (
<Select
className="keycloak__user_events_search__event_type_select"
name="eventType"
data-testid="event-type-searchField"
chipGroupProps={{
numChips: 1,
expandedText: "Hide",
collapsedText: "Show ${remaining}",
}}
variant={SelectVariant.typeaheadMulti}
typeAheadAriaLabel="Select"
onToggle={(isOpen) => setSelectOpen(isOpen)}
selections={value}
onSelect={(_, selectedValue) => {
const option =
selectedValue.toString() as EventType;
const changedValue = value.includes(option)
? value.filter((item) => item !== option)
: [...value, option];
onChange(changedValue);
}}
onClear={(event) => {
event.stopPropagation();
onChange([]);
}}
isOpen={selectOpen}
aria-labelledby={"eventType"}
chipGroupComponent={
<ChipGroup>
{value.map((chip) => (
<Chip
key={chip}
onClick={(event) => {
event.stopPropagation();
onChange(
value.filter((val) => val !== chip)
);
}}
>
{chip}
</Chip>
))}
</ChipGroup>
}
>
{events?.enabledEventTypes?.map((option) => (
<SelectOption key={option} value={option} />
))}
</Select>
)}
/>
</FormGroup>
<FormGroup
label={t("client")}
fieldId="kc-client"
className="keycloak__user_events_search__form_label"
>
<TextInput
ref={register()}
type="text"
id="kc-client"
name="client"
data-testid="client-searchField"
/>
</FormGroup>
<FormGroup
label={t("dateFrom")}
fieldId="kc-dateFrom"
className="keycloak__user_events_search__form_label"
>
<TextInput
ref={register()}
type="text"
id="kc-dateFrom"
name="dateFrom"
className="pf-c-form-control pf-m-icon pf-m-calendar"
placeholder="yyyy-MM-dd"
data-testid="dateFrom-searchField"
/>
</FormGroup>
<FormGroup
label={t("dateTo")}
fieldId="kc-dateTo"
className="keycloak__user_events_search__form_label"
>
<TextInput
ref={register()}
type="text"
id="kc-dateTo"
name="dateTo"
className="pf-c-form-control pf-m-icon pf-m-calendar"
placeholder="yyyy-MM-dd"
data-testid="dateTo-searchField"
/>
</FormGroup>
<ActionGroup>
<Button
className="keycloak__user_events_search__form_btn"
variant={"primary"}
onClick={submitSearch}
data-testid="search-events-btn"
isDisabled={!isDirty}
>
{t("searchBtn")}
</Button>
</ActionGroup>
</Form>
</Dropdown>
</FlexItem>
</Flex>
{Object.entries(activeFilters).length > 0 && (
<div className="keycloak__searchChips pf-u-ml-md">
{Object.entries(activeFilters).map((filter) => {
const [key, value] = filter as [
keyof UserEventSearchForm,
string | EventType[]
];
return (
<ChipGroup
className="pf-u-mr-md pf-u-mb-md"
key={key}
categoryName={filterLabels[key]}
isClosable
onClick={() => removeFilter(key)}
>
{typeof value === "string" ? (
<Chip isReadOnly>{value}</Chip>
) : (
value.map((entry) => (
<Chip
key={entry}
onClick={() => removeFilterValue(key, entry)}
>
{entry}
</Chip>
))
)}
</ChipGroup>
);
})}
</div>
)}
<div className="keycloak__events_table">
<KeycloakDataTable
key={key}
loader={loader}
detailColumns={[
{
name: "details",
enabled: (event) => event.details !== undefined,
cellRenderer: DetailCell,
},
]}
isPaginated
ariaLabelKey="events:title"
columns={[
{
name: "time",
displayKey: "events:time",
cellRenderer: (row) => moment(row.time).format("LLL"),
cellFormatters: [expandable],
},
{
name: "userId",
displayKey: "events:user",
cellRenderer: UserDetailLink,
},
{
name: "type",
displayKey: "events:eventType",
cellRenderer: StatusRow,
},
{
name: "ipAddress",
displayKey: "events:ipAddress",
transforms: [cellWidth(10)],
},
{
name: "clientId",
displayKey: "events:client",
},
]}
emptyState={
<div className="pf-u-mt-md">
<Divider className="keycloak__events_empty_state_divider" />
<ListEmptyState
message={t("emptyEvents")}
instructions={t("emptyEventsInstructions")}
/>
</div>
}
/>
</div>
</Tab>
<Tab
eventKey="adminEvents"

View file

@ -1,4 +1,42 @@
.keycloak_eventsection_details {
--pf-c-description-list--m-horizontal__term--width: 15ch;
}
.keycloak__user_events_search_selector_dropdown__toggle {
--pf-c-dropdown__toggle--MinWidth: 21rem;
}
.keycloak__refresh_btn,
.keycloak__searchChips,
.keycloak__user_events_search_selector_dropdown__toggle {
position: relative;
z-index: 1;
}
.keycloak__user_events_search__form {
margin: 0 0 var(--pf-global--spacer--lg) var(--pf-global--spacer--lg);
--pf-c-form__group--m-action--MarginTop: 0rem;
--pf-c-form--m-horizontal__group-control--md--GridColumnWidth: 12rem;
--pf-c-form--m-horizontal__group-label--md--GridColumnWidth: 5rem;
}
.keycloak__user_events_search__form_label {
--pf-c-form--m-horizontal__group-label--md--GridColumnWidth: 5rem;
}
.keycloak__user_events_search__event_type_select .pf-c-select__menu {
max-height: 200px;
overflow: auto;
}
.pf-c-toolbar {
background-color: transparent;
}
.keycloak__events_table {
margin-top: -4.25rem;
}
.keycloak__events_empty_state_divider {
margin-top: 4.25rem;
}

View file

@ -12,9 +12,15 @@ export default {
emptyEventsInstructions: "Configure event logging in the realm settings",
time: "Time",
user: "User",
userId: "User ID",
username: "User name",
email: "Email",
eventType: "Event type",
ipAddress: "IP address",
client: "Client",
dateFrom: "Date(from)",
dateTo: "Date(to)",
searchBtn: "Search events",
resourcePath: "Resource path",
resourceType: "Resource type",
operationType: "Operation type",