Introduces new Realm Events tab (#618)

* initial version events tab

* added save

* fix for admin events

* added clear and reset

* add type add dialog

* add remove and add actions

* fix ids

* add cofirm disable button

* added key

* added tests

* fixed messages

* add disabled on save on not dirty

* fixed review comment

* fixed test

* merged tests
This commit is contained in:
Erik Jan de Wit 2021-06-08 07:29:56 +02:00 committed by GitHub
parent 4b5193dcef
commit 544dcd31db
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
15 changed files with 1097 additions and 94 deletions

View file

@ -1,12 +1,16 @@
import SidebarPage from "../support/pages/admin_console/SidebarPage"; import SidebarPage from "../support/pages/admin_console/SidebarPage";
import LoginPage from "../support/pages/LoginPage"; import LoginPage from "../support/pages/LoginPage";
import RealmSettingsPage from "../support/pages/admin_console/manage/realm_settings/RealmSettingsPage"; import RealmSettingsPage from "../support/pages/admin_console/manage/realm_settings/RealmSettingsPage";
import Masthead from "../support/pages/admin_console/Masthead";
import ModalUtils from "../support/util/ModalUtils";
import { keycloakBefore } from "../support/util/keycloak_before"; import { keycloakBefore } from "../support/util/keycloak_before";
import AdminClient from "../support/util/AdminClient"; import AdminClient from "../support/util/AdminClient";
import ListingPage from "../support/pages/admin_console/ListingPage";
// describe("Realm settings test", () => {
const loginPage = new LoginPage(); const loginPage = new LoginPage();
const sidebarPage = new SidebarPage(); const sidebarPage = new SidebarPage();
const masthead = new Masthead();
const modalUtils = new ModalUtils();
const realmSettingsPage = new RealmSettingsPage(); const realmSettingsPage = new RealmSettingsPage();
describe("Realm settings", () => { describe("Realm settings", () => {
@ -22,11 +26,11 @@ describe("Realm settings", () => {
await new AdminClient().createRealm(realmName); await new AdminClient().createRealm(realmName);
}); });
// after(async () => { after(async () => {
// await new AdminClient().deleteRealm(realmName); await new AdminClient().deleteRealm(realmName);
// }); });
it("Go to general tab", function () { it("Go to general tab", () => {
sidebarPage.goToRealmSettings(); sidebarPage.goToRealmSettings();
realmSettingsPage.toggleSwitch(realmSettingsPage.managedAccessSwitch); realmSettingsPage.toggleSwitch(realmSettingsPage.managedAccessSwitch);
realmSettingsPage.save(realmSettingsPage.generalSaveBtn); realmSettingsPage.save(realmSettingsPage.generalSaveBtn);
@ -34,7 +38,7 @@ describe("Realm settings", () => {
realmSettingsPage.save(realmSettingsPage.generalSaveBtn); realmSettingsPage.save(realmSettingsPage.generalSaveBtn);
}); });
it("Go to login tab", function () { it("Go to login tab", () => {
sidebarPage.goToRealmSettings(); sidebarPage.goToRealmSettings();
cy.getId("rs-login-tab").click(); cy.getId("rs-login-tab").click();
realmSettingsPage.toggleSwitch(realmSettingsPage.userRegSwitch); realmSettingsPage.toggleSwitch(realmSettingsPage.userRegSwitch);
@ -43,7 +47,7 @@ describe("Realm settings", () => {
realmSettingsPage.toggleSwitch(realmSettingsPage.verifyEmailSwitch); realmSettingsPage.toggleSwitch(realmSettingsPage.verifyEmailSwitch);
}); });
it("Go to email tab", function () { it("Go to email tab", () => {
sidebarPage.goToRealmSettings(); sidebarPage.goToRealmSettings();
cy.getId("rs-email-tab").click(); cy.getId("rs-email-tab").click();
@ -57,7 +61,7 @@ describe("Realm settings", () => {
realmSettingsPage.save(realmSettingsPage.emailSaveBtn); realmSettingsPage.save(realmSettingsPage.emailSaveBtn);
}); });
it("Go to themes tab", function () { it("Go to themes tab", () => {
cy.wait(5000); cy.wait(5000);
sidebarPage.goToRealmSettings(); sidebarPage.goToRealmSettings();
cy.getId("rs-themes-tab").click(); cy.getId("rs-themes-tab").click();
@ -69,7 +73,46 @@ describe("Realm settings", () => {
realmSettingsPage.saveThemes(); realmSettingsPage.saveThemes();
}); });
it("Go to keys tab", function () { describe("Events tab", () => {
const listingPage = new ListingPage();
it("Enable user events", () => {
sidebarPage.goToRealmSettings();
cy.getId("rs-realm-events-tab").click();
cy.wait(5000);
realmSettingsPage
.toggleSwitch(realmSettingsPage.enableEvents)
.save(realmSettingsPage.eventsUserSave);
masthead.checkNotificationMessage("Successfully saved configuration");
realmSettingsPage.clearEvents("user");
modalUtils
.checkModalMessage(
"If you clear all events of this realm, all records will be permanently cleared in the database"
)
.confirmModal();
masthead.checkNotificationMessage("The user events have been cleared");
const events = ["Client info", "Client info error"];
cy.intercept("GET", `/auth/admin/realms/${realmName}/events/config`).as(
"fetchConfig"
);
realmSettingsPage.addUserEvents(events).clickAdd();
masthead.checkNotificationMessage("Successfully saved configuration");
cy.wait(["@fetchConfig"]);
cy.get(".pf-c-spinner__tail-ball").should("not.exist");
for (const event of events) {
listingPage.searchItem(event, false).itemExist(event);
}
});
});
it("Go to keys tab", () => {
cy.wait(5000); cy.wait(5000);
sidebarPage.goToRealmSettings(); sidebarPage.goToRealmSettings();
@ -77,7 +120,7 @@ describe("Realm settings", () => {
cy.getId("rs-keys-tab").click(); cy.getId("rs-keys-tab").click();
}); });
it("add Providers", function () { it("add Providers", () => {
cy.wait(5000); cy.wait(5000);
sidebarPage.goToRealmSettings(); sidebarPage.goToRealmSettings();
@ -115,4 +158,3 @@ describe("Realm settings", () => {
realmSettingsPage.addProvider(); realmSettingsPage.addProvider();
}); });
}); });
// });

View file

@ -25,5 +25,5 @@
// Cypress.Commands.overwrite("visit", (originalFn, url, options) => { ... }) // Cypress.Commands.overwrite("visit", (originalFn, url, options) => { ... })
Cypress.Commands.add("getId", (selector, ...args) => { Cypress.Commands.add("getId", (selector, ...args) => {
return cy.get(`[data-testid=${selector}]`, ...args); return cy.get(`[data-testid="${selector}"]`, ...args);
}); });

View file

@ -28,7 +28,7 @@ export default class ListingPage {
const searchUrl = `/auth/admin/realms/master/*${searchValue}*`; const searchUrl = `/auth/admin/realms/master/*${searchValue}*`;
cy.intercept(searchUrl).as("search"); cy.intercept(searchUrl).as("search");
} }
cy.get(this.searchInput).type(searchValue); cy.get(this.searchInput).clear().type(searchValue);
cy.get(this.searchBtn).click(); cy.get(this.searchBtn).click();
if (wait) { if (wait) {
cy.wait(["@search"]); cy.wait(["@search"]);

View file

@ -1,3 +1,4 @@
const expect = chai.expect;
export default class RealmSettingsPage { export default class RealmSettingsPage {
generalSaveBtn = "general-tab-save"; generalSaveBtn = "general-tab-save";
themesSaveBtn = "themes-tab-save"; themesSaveBtn = "themes-tab-save";
@ -28,6 +29,9 @@ export default class RealmSettingsPage {
addProviderDropdown = "addProviderDropdown"; addProviderDropdown = "addProviderDropdown";
addProviderButton = "add-provider-button"; addProviderButton = "add-provider-button";
displayName = "display-name-input"; displayName = "display-name-input";
enableEvents = "eventsEnabled";
eventsUserSave = "save-user";
eventTypeColumn = 'tbody > tr > [data-label="Event saved type"]';
selectLoginThemeType(themeType: string) { selectLoginThemeType(themeType: string) {
const themesUrl = "/auth/admin/realms/master/themes"; const themesUrl = "/auth/admin/realms/master/themes";
@ -86,7 +90,7 @@ export default class RealmSettingsPage {
} }
toggleSwitch(switchName: string) { toggleSwitch(switchName: string) {
cy.getId(switchName).next().click(); cy.getId(switchName).click({ force: true });
return this; return this;
} }
@ -120,4 +124,36 @@ export default class RealmSettingsPage {
return this; return this;
} }
clearEvents(type: "admin" | "user") {
cy.getId(`clear-${type}-events`).click();
return this;
}
addUserEvents(events: string[]) {
cy.getId("addTypes").click();
for (const event of events) {
cy.get(this.eventTypeColumn)
.contains(event)
.parent()
.find("input")
.click();
}
return this;
}
checkUserEvents(events: string[]) {
cy.get(this.eventTypeColumn).should((event) => {
for (const user of events) {
expect(event).to.contain(user);
}
});
return this;
}
clickAdd() {
cy.getId("addEventTypeConfirm").click();
return this;
}
} }

View file

@ -41,7 +41,9 @@ const AppContexts = ({ children }: { children: ReactNode }) => (
const RealmPathSelector = ({ children }: { children: ReactNode }) => { const RealmPathSelector = ({ children }: { children: ReactNode }) => {
const { setRealm } = useRealm(); const { setRealm } = useRealm();
const { realm } = useParams<{ realm: string }>(); const { realm } = useParams<{ realm: string }>();
useEffect(() => setRealm(realm), []); useEffect(() => {
if (realm) setRealm(realm);
}, []);
return <>{children}</>; return <>{children}</>;
}; };

View file

@ -1,4 +1,10 @@
import React, { isValidElement, ReactNode, useEffect, useState } from "react"; import React, {
isValidElement,
ReactNode,
useEffect,
useMemo,
useState,
} from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { import {
IAction, IAction,
@ -175,7 +181,6 @@ export function KeycloakDataTable<T>({
const [selected, setSelected] = useState<T[]>([]); const [selected, setSelected] = useState<T[]>([]);
const [rows, setRows] = useState<(Row<T> | SubRow<T>)[]>(); const [rows, setRows] = useState<(Row<T> | SubRow<T>)[]>();
const [unPaginatedData, setUnPaginatedData] = useState<T[]>(); const [unPaginatedData, setUnPaginatedData] = useState<T[]>();
const [filteredData, setFilteredData] = useState<(Row<T> | SubRow<T>)[]>();
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [max, setMax] = useState(10); const [max, setMax] = useState(10);
@ -185,60 +190,8 @@ export function KeycloakDataTable<T>({
const [key, setKey] = useState(0); const [key, setKey] = useState(0);
const refresh = () => setKey(new Date().getTime()); const refresh = () => setKey(new Date().getTime());
useEffect(() => {
if (canSelectAll) {
const checkboxes = document
.getElementsByClassName("pf-c-table__check")
.item(0);
if (checkboxes) {
const checkAllCheckbox = checkboxes.children!.item(
0
)! as HTMLInputElement;
checkAllCheckbox.indeterminate =
selected.length > 0 &&
selected.length < (filteredData || rows)!.length;
}
}
}, [selected]);
useFetch(
async () => {
setLoading(true);
return unPaginatedData || (await loader(first, max, search));
},
(data) => {
if (!isPaginated) {
setUnPaginatedData(data);
data = data.slice(first, first + max);
}
const result = convertToColumns(data);
setRows(result);
setFilteredData(result);
setLoading(false);
},
[key, first, max, search]
);
const getNodeText = (node: Cell<T>): string => {
if (["string", "number"].includes(typeof node)) {
return node!.toString();
}
if (node instanceof Array) {
return node.map(getNodeText).join("");
}
if (typeof node === "object" && node) {
return getNodeText(
isValidElement((node as TitleCell).title)
? (node as TitleCell).title.props.children
: (node as JSX.Element).props.children
);
}
return "";
};
const convertToColumns = (data: T[]): (Row<T> | SubRow<T>)[] => { const convertToColumns = (data: T[]): (Row<T> | SubRow<T>)[] => {
return data! return data
.map((value, index) => { .map((value, index) => {
const disabledRow = isRowDisabled ? isRowDisabled(value) : false; const disabledRow = isRowDisabled ? isRowDisabled(value) : false;
const row: (Row<T> | SubRow<T>)[] = [ const row: (Row<T> | SubRow<T>)[] = [
@ -279,17 +232,72 @@ export function KeycloakDataTable<T>({
.flat(); .flat();
}; };
const filter = (search: string) => { const getNodeText = (node: Cell<T>): string => {
setFilteredData( if (["string", "number"].includes(typeof node)) {
convertToColumns(unPaginatedData!).filter((row) => return node!.toString();
}
if (node instanceof Array) {
return node.map(getNodeText).join("");
}
if (typeof node === "object" && node) {
return getNodeText(
isValidElement((node as TitleCell).title)
? (node as TitleCell).title.props.children
: (node as TitleCell).title
? (node as TitleCell).title
: (node as JSX.Element).props.children
);
}
return "";
};
const filteredData = useMemo<(Row<T> | SubRow<T>)[] | undefined>(
() =>
search === "" || isPaginated
? undefined
: convertToColumns(unPaginatedData || []).filter((row) =>
row.cells.some( row.cells.some(
(cell) => (cell) =>
cell && cell &&
getNodeText(cell).toLowerCase().includes(search.toLowerCase()) getNodeText(cell).toLowerCase().includes(search.toLowerCase())
) )
) ),
[search]
);
useEffect(() => {
if (canSelectAll) {
const checkboxes = document
.getElementsByClassName("pf-c-table__check")
.item(0);
if (checkboxes) {
const checkAllCheckbox = checkboxes.children!.item(
0
)! as HTMLInputElement;
checkAllCheckbox.indeterminate =
selected.length > 0 &&
selected.length < (filteredData || rows)!.length;
}
}
}, [selected]);
useFetch(
async () => {
setLoading(true);
return unPaginatedData || (await loader(first, max, search));
},
(data) => {
if (!isPaginated) {
setUnPaginatedData(data);
data = data.slice(first, first + max);
}
const result = convertToColumns(data);
setRows(result);
setLoading(false);
},
[key, first, max, search]
); );
};
const convertAction = () => const convertAction = () =>
actions && actions &&
@ -301,7 +309,7 @@ export function KeycloakDataTable<T>({
); );
if (result) { if (result) {
if (!isPaginated) { if (!isPaginated) {
setFilteredData(undefined); setSearch("");
} }
refresh(); refresh();
} }
@ -370,9 +378,7 @@ export function KeycloakDataTable<T>({
inputGroupName={ inputGroupName={
searchPlaceholderKey ? `${ariaLabelKey}input` : undefined searchPlaceholderKey ? `${ariaLabelKey}input` : undefined
} }
inputGroupOnEnter={ inputGroupOnEnter={setSearch}
isPaginated ? setSearch : (search) => filter(search)
}
inputGroupPlaceholder={t(searchPlaceholderKey || "")} inputGroupPlaceholder={t(searchPlaceholderKey || "")}
searchTypeComponent={searchTypeComponent} searchTypeComponent={searchTypeComponent}
toolbarItem={toolbarItem} toolbarItem={toolbarItem}
@ -392,7 +398,7 @@ export function KeycloakDataTable<T>({
/> />
)} )}
{!loading && {!loading &&
rows.length === 0 && (filteredData || rows).length === 0 &&
search !== "" && search !== "" &&
searchPlaceholderKey && ( searchPlaceholderKey && (
<ListEmptyState <ListEmptyState

View file

@ -64,7 +64,7 @@ export const WhoAmIContextProvider = ({ children }: WhoAmIProviderProps) => {
const [key, setKey] = useState(0); const [key, setKey] = useState(0);
useFetch( useFetch(
() => adminClient.whoAmI.find({ realm: "master" }), () => adminClient.whoAmI.find(),
(me) => { (me) => {
const whoAmI = new WhoAmI(adminClient.keycloak?.realm, me); const whoAmI = new WhoAmI(adminClient.keycloak?.realm, me);
setWhoAmI(whoAmI); setWhoAmI(whoAmI);

View file

@ -110,7 +110,9 @@ export const EventsSection = () => {
<Trans i18nKey="events:eventExplain"> <Trans i18nKey="events:eventExplain">
If you want to configure user events, Admin events or Event If you want to configure user events, Admin events or Event
listeners, please enter listeners, please enter
<Link to={`/${realm}/`}>{t("eventConfig")}</Link> <Link to={`/${realm}/realm-settings/events`}>
{t("eventConfig")}
</Link>
page realm settings to configure. page realm settings to configure.
</Trans> </Trans>
} }

View file

@ -27,6 +27,7 @@ import { PartialImportDialog } from "./PartialImport";
import { RealmSettingsThemesTab } from "./ThemesTab"; import { RealmSettingsThemesTab } from "./ThemesTab";
import { RealmSettingsEmailTab } from "./EmailTab"; import { RealmSettingsEmailTab } from "./EmailTab";
import { KeysListTab } from "./KeysListTab"; import { KeysListTab } from "./KeysListTab";
import { EventsTab } from "./event-config/EventsTab";
import type ComponentRepresentation from "keycloak-admin/lib/defs/componentRepresentation"; import type ComponentRepresentation from "keycloak-admin/lib/defs/componentRepresentation";
import { KeysProviderTab } from "./KeysProvidersTab"; import { KeysProviderTab } from "./KeysProvidersTab";
import { useServerInfo } from "../context/server-info/ServerInfoProvider"; import { useServerInfo } from "../context/server-info/ServerInfoProvider";
@ -233,7 +234,7 @@ export const RealmSettingsSection = () => {
<KeycloakTabs isBox> <KeycloakTabs isBox>
<Tab <Tab
eventKey="general" eventKey="general"
title={<TabTitleText>{t("realm-settings:general")}</TabTitleText>} title={<TabTitleText>{t("general")}</TabTitleText>}
data-testid="rs-general-tab" data-testid="rs-general-tab"
> >
<RealmSettingsGeneralTab <RealmSettingsGeneralTab
@ -243,21 +244,21 @@ export const RealmSettingsSection = () => {
</Tab> </Tab>
<Tab <Tab
eventKey="login" eventKey="login"
title={<TabTitleText>{t("realm-settings:login")}</TabTitleText>} title={<TabTitleText>{t("login")}</TabTitleText>}
data-testid="rs-login-tab" data-testid="rs-login-tab"
> >
<RealmSettingsLoginTab save={save} realm={realm!} /> <RealmSettingsLoginTab save={save} realm={realm!} />
</Tab> </Tab>
<Tab <Tab
eventKey="email" eventKey="email"
title={<TabTitleText>{t("realm-settings:email")}</TabTitleText>} title={<TabTitleText>{t("email")}</TabTitleText>}
data-testid="rs-email-tab" data-testid="rs-email-tab"
> >
{realm && <RealmSettingsEmailTab realm={realm} />} {realm && <RealmSettingsEmailTab realm={realm} />}
</Tab> </Tab>
<Tab <Tab
eventKey="themes" eventKey="themes"
title={<TabTitleText>{t("realm-settings:themes")}</TabTitleText>} title={<TabTitleText>{t("themes")}</TabTitleText>}
data-testid="rs-themes-tab" data-testid="rs-themes-tab"
> >
<RealmSettingsThemesTab <RealmSettingsThemesTab
@ -297,6 +298,13 @@ export const RealmSettingsSection = () => {
</Tabs> </Tabs>
)} )}
</Tab> </Tab>
<Tab
eventKey="events"
title={<TabTitleText>{t("events")}</TabTitleText>}
data-testid="rs-realm-events-tab"
>
<EventsTab />
</Tab>
</KeycloakTabs> </KeycloakTabs>
</FormProvider> </FormProvider>
</PageSection> </PageSection>

View file

@ -0,0 +1,62 @@
import React, { useState } from "react";
import { useTranslation } from "react-i18next";
import { Button, Modal, ModalVariant } from "@patternfly/react-core";
import { EventsTypeTable, EventType } from "./EventsTypeTable";
import { useServerInfo } from "../../context/server-info/ServerInfoProvider";
type AddEventTypesDialogProps = {
onConfirm: (selected: EventType[]) => void;
onClose: () => void;
configured: string[];
};
export const AddEventTypesDialog = ({
onConfirm,
onClose,
configured,
}: AddEventTypesDialogProps) => {
const { t } = useTranslation("realm-settings");
const { enums } = useServerInfo();
const [selectedTypes, setSelectedTypes] = useState<EventType[]>([]);
return (
<Modal
variant={ModalVariant.medium}
title={t("addTypes")}
isOpen={true}
onClose={onClose}
actions={[
<Button
data-testid="addEventTypeConfirm"
key="confirm"
variant="primary"
onClick={() => onConfirm(selectedTypes)}
>
{t("common:add")}
</Button>,
<Button
data-testid="moveCancel"
key="cancel"
variant="link"
onClick={onClose}
>
{t("common:cancel")}
</Button>,
]}
>
<EventsTypeTable
onSelect={(selected) => setSelectedTypes(selected)}
loader={() =>
Promise.resolve(
enums!["eventType"]
.filter((type) => !configured.includes(type))
.map((id) => {
return { id };
})
)
}
/>
</Modal>
);
};

View file

@ -0,0 +1,183 @@
import React from "react";
import { useTranslation } from "react-i18next";
import { Controller, UseFormMethods } from "react-hook-form";
import {
ActionGroup,
Button,
Divider,
FormGroup,
Switch,
} from "@patternfly/react-core";
import { HelpItem } from "../../components/help-enabler/HelpItem";
import { TimeSelector } from "../../components/time-selector/TimeSelector";
import { useConfirmDialog } from "../../components/confirm-dialog/ConfirmDialog";
export type EventsType = "admin" | "user";
type EventConfigFormProps = {
type: EventsType;
form: UseFormMethods;
reset: () => void;
clear: () => void;
};
export const EventConfigForm = ({
type,
form,
reset,
clear,
}: EventConfigFormProps) => {
const { t } = useTranslation("realm-settings");
const {
control,
watch,
setValue,
formState: { isDirty },
} = form;
const eventKey = type === "admin" ? "adminEventsEnabled" : "eventsEnabled";
const eventsEnabled: boolean = watch(eventKey);
const [toggleDisableDialog, DisableConfirm] = useConfirmDialog({
titleKey: "realm-settings:events-disable-title",
messageKey: "realm-settings:events-disable-confirm",
continueButtonLabel: "realm-settings:confirm",
onConfirm: () => setValue(eventKey, false),
});
return (
<>
<DisableConfirm />
<FormGroup
hasNoPaddingTop
label={t("saveEvents")}
fieldId={eventKey}
labelIcon={
<HelpItem
helpText={`realm-settings-help:save-${type}-events`}
forLabel={t("saveEvents")}
forID={eventKey}
/>
}
>
<Controller
name={eventKey}
defaultValue={false}
control={control}
render={({ onChange, value }) => (
<Switch
data-testid={eventKey}
id={eventKey}
label={t("common:on")}
labelOff={t("common:off")}
isChecked={value}
onChange={(value) => {
if (!value) {
toggleDisableDialog();
} else {
onChange(value);
}
}}
/>
)}
/>
</FormGroup>
{eventsEnabled && (
<>
{type === "admin" && (
<FormGroup
hasNoPaddingTop
label={t("includeRepresentation")}
fieldId="includeRepresentation"
labelIcon={
<HelpItem
helpText="realm-settings-help:includeRepresentation"
forLabel={t("includeRepresentation")}
forID="includeRepresentation"
/>
}
>
<Controller
name="adminEventsDetailsEnabled"
defaultValue={false}
control={control}
render={({ onChange, value }) => (
<Switch
data-testid="includeRepresentation"
id="includeRepresentation"
label={t("common:on")}
labelOff={t("common:off")}
isChecked={value}
onChange={onChange}
/>
)}
/>
</FormGroup>
)}
{type === "user" && (
<FormGroup
label={t("expiration")}
fieldId="expiration"
labelIcon={
<HelpItem
helpText="realm-settings-help:expiration"
forLabel={t("expiration")}
forID="expiration"
/>
}
>
<Controller
name="eventsExpiration"
defaultValue=""
control={control}
render={({ onChange, value }) => (
<TimeSelector
value={value}
onChange={onChange}
units={["minutes", "hours", "days"]}
/>
)}
/>
</FormGroup>
)}
</>
)}
<ActionGroup>
<Button
variant="primary"
type="submit"
id={`save-${type}`}
data-testid={`save-${type}`}
isDisabled={!isDirty}
>
{t("common:save")}
</Button>
<Button variant="link" onClick={reset}>
{t("common:revert")}
</Button>
</ActionGroup>
<Divider />
<FormGroup
label={t("clearEvents")}
fieldId={`clear-${type}-events`}
labelIcon={
<HelpItem
helpText={`realm-settings-help:${type}-clearEvents`}
forLabel={t("clearEvents")}
forID={`clear-${type}-events`}
/>
}
>
<Button
variant="danger"
id={`clear-${type}-events`}
data-testid={`clear-${type}-events`}
onClick={() => clear()}
>
{t("clearEvents")}
</Button>
</FormGroup>
</>
);
};

View file

@ -0,0 +1,210 @@
import React, { useState } from "react";
import { useTranslation } from "react-i18next";
import { useForm } from "react-hook-form";
import {
AlertVariant,
ButtonVariant,
PageSection,
Tab,
Tabs,
TabTitleText,
Title,
} from "@patternfly/react-core";
import type { RealmEventsConfigRepresentation } from "keycloak-admin/lib/defs/realmEventsConfigRepresentation";
import { FormAccess } from "../../components/form-access/FormAccess";
import { useRealm } from "../../context/realm-context/RealmContext";
import { useAlerts } from "../../components/alert/Alerts";
import { useFetch, useAdminClient } from "../../context/auth/AdminClient";
import { EventConfigForm, EventsType } from "./EventConfigForm";
import { useConfirmDialog } from "../../components/confirm-dialog/ConfirmDialog";
import { EventsTypeTable, EventType } from "./EventsTypeTable";
import { AddEventTypesDialog } from "./AddEventTypesDialog";
export const EventsTab = () => {
const { t } = useTranslation("realm-settings");
const form = useForm<RealmEventsConfigRepresentation>();
const { setValue, handleSubmit, watch, reset } = form;
const [key, setKey] = useState(0);
const refresh = () => setKey(new Date().getTime());
const [tableKey, setTableKey] = useState(0);
const reload = () => setTableKey(new Date().getTime());
const [activeTab, setActiveTab] = useState("user");
const [events, setEvents] = useState<RealmEventsConfigRepresentation>();
const [type, setType] = useState<EventsType>();
const [addEventType, setAddEventType] = useState(false);
const adminClient = useAdminClient();
const { addAlert } = useAlerts();
const { realm } = useRealm();
const setupForm = (eventConfig?: RealmEventsConfigRepresentation) => {
reset(eventConfig);
setEvents(eventConfig);
Object.entries(eventConfig || {}).forEach((entry) =>
setValue(entry[0], entry[1])
);
};
const clear = async (type: EventsType) => {
setType(type);
toggleDeleteDialog();
};
const [toggleDeleteDialog, DeleteConfirm] = useConfirmDialog({
titleKey: "realm-settings:deleteEvents",
messageKey: "realm-settings:deleteEventsConfirm",
continueButtonLabel: "common:clear",
continueButtonVariant: ButtonVariant.danger,
onConfirm: async () => {
try {
switch (type) {
case "admin":
await adminClient.realms.clearAdminEvents({ realm });
break;
case "user":
await adminClient.realms.clearEvents({ realm });
break;
}
addAlert(t(`${type}-events-cleared`), AlertVariant.success);
} catch (error) {
addAlert(
t(`${type}-events-cleared-error`, {
error: error.response?.data?.errorMessage || error,
}),
AlertVariant.danger
);
}
},
});
useFetch(
() => adminClient.realms.getConfigEvents({ realm }),
(eventConfig) => {
setupForm(eventConfig);
reload();
},
[key]
);
const save = async (eventConfig: RealmEventsConfigRepresentation) => {
try {
await adminClient.realms.updateConfigEvents({ realm }, eventConfig);
setupForm({ ...events, ...eventConfig });
addAlert(t("eventConfigSuccessfully"), AlertVariant.success);
} catch (error) {
addAlert(
t("eventConfigError", {
error: error.response?.data?.errorMessage || error,
}),
AlertVariant.danger
);
}
};
const addEventTypes = async (eventTypes: EventType[]) => {
const eventsTypes = eventTypes.map((type) => type.id);
const enabledEvents = events!.enabledEventTypes?.concat(eventsTypes);
await addEvents(enabledEvents);
};
const addEvents = async (events: string[] = []) => {
const eventConfig = { ...form.getValues(), enabledEventTypes: events };
await save(eventConfig);
setAddEventType(false);
refresh();
};
const eventsEnabled: boolean = watch("eventsEnabled") || false;
return (
<>
<DeleteConfirm />
{addEventType && (
<AddEventTypesDialog
onConfirm={(eventTypes) => addEventTypes(eventTypes)}
configured={events?.enabledEventTypes || []}
onClose={() => setAddEventType(false)}
/>
)}
<Tabs
activeKey={activeTab}
onSelect={(_, key) => setActiveTab(key as string)}
>
<Tab
eventKey="user"
title={<TabTitleText>{t("userEventsSettings")}</TabTitleText>}
data-testid="rs-events-tab"
>
<PageSection>
<Title headingLevel="h4" size="xl">
{t("userEventsConfig")}
</Title>
</PageSection>
<PageSection>
<FormAccess
role="manage-events"
isHorizontal
onSubmit={handleSubmit(save)}
>
<EventConfigForm
type="user"
form={form}
reset={() => setupForm(events)}
clear={() => clear("user")}
/>
</FormAccess>
</PageSection>
{eventsEnabled && (
<PageSection>
<EventsTypeTable
key={tableKey}
addTypes={() => setAddEventType(true)}
loader={() =>
Promise.resolve(
events?.enabledEventTypes?.map((id) => {
return { id };
}) || []
)
}
onDelete={(value) => {
const enabledEventTypes = events?.enabledEventTypes?.filter(
(e) => e !== value.id
);
addEvents(enabledEventTypes);
setEvents({ ...events, enabledEventTypes });
}}
/>
</PageSection>
)}
</Tab>
<Tab
eventKey="admin"
title={<TabTitleText>{t("adminEventsSettings")}</TabTitleText>}
data-testid="rs-events-tab"
>
<PageSection>
<Title headingLevel="h4" size="xl">
{t("adminEventsConfig")}
</Title>
</PageSection>
<PageSection>
<FormAccess
role="manage-events"
isHorizontal
onSubmit={handleSubmit(save)}
>
<EventConfigForm
type="admin"
form={form}
reset={() => setupForm(events)}
clear={() => clear("admin")}
/>
</FormAccess>
</PageSection>
</Tab>
</Tabs>
</>
);
};

View file

@ -0,0 +1,84 @@
import React, { Fragment } from "react";
import { useTranslation } from "react-i18next";
import { Button, ToolbarItem } from "@patternfly/react-core";
import type { IFormatterValueType } from "@patternfly/react-table";
import { KeycloakDataTable } from "../../components/table-toolbar/KeycloakDataTable";
import { ListEmptyState } from "../../components/list-empty-state/ListEmptyState";
export type EventType = {
id: string;
};
type EventsTypeTableProps = {
loader: () => Promise<EventType[]>;
addTypes?: () => void;
onSelect?: (value: EventType[]) => void;
onDelete?: (value: EventType) => void;
};
export function EventsTypeTable({
loader,
addTypes,
onSelect,
onDelete,
}: EventsTypeTableProps) {
const { t } = useTranslation("realm-settings");
const DescriptionCell = (event: EventType) => (
<Fragment key={event.id}>
{t(`eventTypes.${event.id}.description`)}
</Fragment>
);
return (
<KeycloakDataTable
ariaLabelKey="userEventsRegistered"
searchPlaceholderKey="realm-settings:searchEventType"
loader={loader}
onSelect={onSelect ? onSelect : undefined}
canSelectAll={!!onSelect}
toolbarItem={
<>
{addTypes && (
<ToolbarItem>
<Button id="addTypes" onClick={addTypes} data-testid="addTypes">
{t("addSavedTypes")}
</Button>
</ToolbarItem>
)}
</>
}
actions={
!onDelete
? []
: [
{
title: t("common:delete"),
onRowClick: onDelete,
},
]
}
columns={[
{
name: "id",
displayKey: "realm-settings:eventType",
cellFormatters: [
(data?: IFormatterValueType) => t(`eventTypes.${data}.name`),
],
},
{
name: "description",
displayKey: "description",
cellRenderer: DescriptionCell,
},
]}
emptyState={
<ListEmptyState
message={t("emptyEvents")}
instructions={t("emptyEventsInstructions")}
/>
}
/>
);
}

View file

@ -17,6 +17,12 @@
"enabled": "Set if the keys are enabled", "enabled": "Set if the keys are enabled",
"active": "Set if the keys can be used for signing", "active": "Set if the keys can be used for signing",
"AESKeySize": "Size in bytes for the generated AES key. Size 16 is for AES-128, Size 24 for AES-192, and Size 32 for AES-256. WARN: Bigger keys than 128 are not allowed on some JDK implementations.", "AESKeySize": "Size in bytes for the generated AES key. Size 16 is for AES-128, Size 24 for AES-192, and Size 32 for AES-256. WARN: Bigger keys than 128 are not allowed on some JDK implementations.",
"save-user-events": "If enabled, login events are saved to the database, which makes events available to the admin and account management consoles.",
"save-admin-events": "If enabled, admin events are saved to the database, which makes events available to the admin console.",
"expiration": "Sets the expiration for events. Expired events are periodically deleted from the database.",
"admin-clearEvents": "Deletes all admin events in the database.",
"includeRepresentation": "Include JSON representation for create and update requests.",
"user-clearEvents": "Deletes all user events in the database.",
"ellipticCurve": "Elliptic curve used in ECDSA", "ellipticCurve": "Elliptic curve used in ECDSA",
"secretSize": "Size in bytes for the generated secret", "secretSize": "Size in bytes for the generated secret",
"algorithm": "Intended algorithm for the key", "algorithm": "Intended algorithm for the key",

View file

@ -21,6 +21,15 @@
"general": "General", "general": "General",
"login": "Login", "login": "Login",
"themes": "Themes", "themes": "Themes",
"events": "Events",
"userEventsConfig": "User events configuration",
"userEventsSettings": "User events settings",
"adminEventsConfig": "Admin events config",
"adminEventsSettings": "Admin events settings",
"saveEvents": "Save events",
"expiration": "Expiration",
"clearEvents": "Clear user events",
"includeRepresentation": "Include representation",
"email": "Email", "email": "Email",
"template": "Template", "template": "Template",
"connectionAndAuthentication": "Connection & Authentication", "connectionAndAuthentication": "Connection & Authentication",
@ -124,7 +133,360 @@
"emailTheme": "Email theme", "emailTheme": "Email theme",
"internationalization": "Internationalization", "internationalization": "Internationalization",
"supportedLocales": "Supported locales", "supportedLocales": "Supported locales",
"defaultLocale": "Default locale" "defaultLocale": "Default locale",
"eventType": "Event saved type",
"searchEventType": "Search saved event type",
"addSavedTypes": "Add saved types",
"addTypes": "Add types",
"eventTypes": {
"SEND_RESET_PASSWORD": {
"name": "Send reset password",
"description": "Send reset password"
},
"UPDATE_CONSENT_ERROR": {
"name": "Update consent error",
"description": "Update consent error"
},
"GRANT_CONSENT": {
"name": "Grant consent",
"description": "Grant consent"
},
"REMOVE_TOTP": { "name": "Remove totp", "description": "Remove totp" },
"REVOKE_GRANT": { "name": "Revoke grant", "description": "Revoke grant" },
"UPDATE_TOTP": { "name": "Update totp", "description": "Update totp" },
"LOGIN_ERROR": { "name": "Login error", "description": "Login error" },
"CLIENT_LOGIN": { "name": "Client login", "description": "Client login" },
"RESET_PASSWORD_ERROR": {
"name": "Reset password error",
"description": "Reset password error"
},
"IMPERSONATE_ERROR": {
"name": "Impersonate error",
"description": "Impersonate error"
},
"CODE_TO_TOKEN_ERROR": {
"name": "Code to token error",
"description": "Code to token error"
},
"CUSTOM_REQUIRED_ACTION": {
"name": "Custom required action",
"description": "Custom required action"
},
"RESTART_AUTHENTICATION": {
"name": "Restart authentication",
"description": "Restart authentication"
},
"IMPERSONATE": { "name": "Impersonate", "description": "Impersonate" },
"UPDATE_PROFILE_ERROR": {
"name": "Update profile error",
"description": "Update profile error"
},
"LOGIN": { "name": "Login", "description": "Login" },
"UPDATE_PASSWORD_ERROR": {
"name": "Update password error",
"description": "Update password error"
},
"CLIENT_INITIATED_ACCOUNT_LINKING": {
"name": "Client initiated account linking",
"description": "Client initiated account linking"
},
"TOKEN_EXCHANGE": {
"name": "Token exchange",
"description": "Token exchange"
},
"LOGOUT": { "name": "Logout", "description": "Logout" },
"REGISTER": { "name": "Register", "description": "Register" },
"DELETE_ACCOUNT_ERROR": {
"name": "Delete account error",
"description": "Delete account error"
},
"CLIENT_REGISTER": {
"name": "Client register",
"description": "Client register"
},
"IDENTITY_PROVIDER_LINK_ACCOUNT": {
"name": "Identity provider link account",
"description": "Identity provider link account"
},
"DELETE_ACCOUNT": {
"name": "Delete account",
"description": "Delete account"
},
"UPDATE_PASSWORD": {
"name": "Update password",
"description": "Update password"
},
"CLIENT_DELETE": {
"name": "Client delete",
"description": "Client delete"
},
"FEDERATED_IDENTITY_LINK_ERROR": {
"name": "Federated identity link error",
"description": "Federated identity link error"
},
"IDENTITY_PROVIDER_FIRST_LOGIN": {
"name": "Identity provider first login",
"description": "Identity provider first login"
},
"CLIENT_DELETE_ERROR": {
"name": "Client delete error",
"description": "Client delete error"
},
"VERIFY_EMAIL": { "name": "Verify email", "description": "Verify email" },
"CLIENT_LOGIN_ERROR": {
"name": "Client login error",
"description": "Client login error"
},
"RESTART_AUTHENTICATION_ERROR": {
"name": "Restart authentication error",
"description": "Restart authentication error"
},
"EXECUTE_ACTIONS": {
"name": "Execute actions",
"description": "Execute actions"
},
"REMOVE_FEDERATED_IDENTITY_ERROR": {
"name": "Remove federated identity error",
"description": "Remove federated identity error"
},
"TOKEN_EXCHANGE_ERROR": {
"name": "Token exchange error",
"description": "Token exchange error"
},
"PERMISSION_TOKEN": {
"name": "Permission token",
"description": "Permission token"
},
"SEND_IDENTITY_PROVIDER_LINK_ERROR": {
"name": "Send identity provider link error",
"description": "Send identity provider link error"
},
"EXECUTE_ACTION_TOKEN_ERROR": {
"name": "Execute action token error",
"description": "Execute action token error"
},
"SEND_VERIFY_EMAIL": {
"name": "Send verify email",
"description": "Send verify email"
},
"EXECUTE_ACTIONS_ERROR": {
"name": "Execute actions error",
"description": "Execute actions error"
},
"REMOVE_FEDERATED_IDENTITY": {
"name": "Remove federated identity",
"description": "Remove federated identity"
},
"IDENTITY_PROVIDER_POST_LOGIN": {
"name": "Identity provider post login",
"description": "Identity provider post login"
},
"IDENTITY_PROVIDER_LINK_ACCOUNT_ERROR": {
"name": "Identity provider link account error",
"description": "Identity provider link account error"
},
"UPDATE_EMAIL": { "name": "Update email", "description": "Update email" },
"REGISTER_ERROR": {
"name": "Register error",
"description": "Register error"
},
"REVOKE_GRANT_ERROR": {
"name": "Revoke grant error",
"description": "Revoke grant error"
},
"EXECUTE_ACTION_TOKEN": {
"name": "Execute action token",
"description": "Execute action token"
},
"LOGOUT_ERROR": { "name": "Logout error", "description": "Logout error" },
"UPDATE_EMAIL_ERROR": {
"name": "Update email error",
"description": "Update email error"
},
"CLIENT_UPDATE_ERROR": {
"name": "Client update error",
"description": "Client update error"
},
"UPDATE_PROFILE": {
"name": "Update profile",
"description": "Update profile"
},
"CLIENT_REGISTER_ERROR": {
"name": "Client register error",
"description": "Client register error"
},
"FEDERATED_IDENTITY_LINK": {
"name": "Federated identity link",
"description": "Federated identity link"
},
"SEND_IDENTITY_PROVIDER_LINK": {
"name": "Send identity provider link",
"description": "Send identity provider link"
},
"SEND_VERIFY_EMAIL_ERROR": {
"name": "Send verify email error",
"description": "Send verify email error"
},
"RESET_PASSWORD": {
"name": "Reset password",
"description": "Reset password"
},
"CLIENT_INITIATED_ACCOUNT_LINKING_ERROR": {
"name": "Client initiated account linking error",
"description": "Client initiated account linking error"
},
"UPDATE_CONSENT": {
"name": "Update consent",
"description": "Update consent"
},
"REMOVE_TOTP_ERROR": {
"name": "Remove totp error",
"description": "Remove totp error"
},
"VERIFY_EMAIL_ERROR": {
"name": "Verify email error",
"description": "Verify email error"
},
"SEND_RESET_PASSWORD_ERROR": {
"name": "Send reset password error",
"description": "Send reset password error"
},
"CLIENT_UPDATE": {
"name": "Client update",
"description": "Client update"
},
"CUSTOM_REQUIRED_ACTION_ERROR": {
"name": "Custom required action error",
"description": "Custom required action error"
},
"IDENTITY_PROVIDER_POST_LOGIN_ERROR": {
"name": "Identity provider post login error",
"description": "Identity provider post login error"
},
"UPDATE_TOTP_ERROR": {
"name": "Update totp error",
"description": "Update totp error"
},
"CODE_TO_TOKEN": {
"name": "Code to token",
"description": "Code to token"
},
"GRANT_CONSENT_ERROR": {
"name": "Grant consent error",
"description": "Grant consent error"
},
"IDENTITY_PROVIDER_FIRST_LOGIN_ERROR": {
"name": "Identity provider first login error",
"description": "Identity provider first login error"
},
"REGISTER_NODE_ERROR": {
"name": "Register node error",
"description": "Register node error"
},
"PERMISSION_TOKEN_ERROR": {
"name": "Permission token error",
"description": "Permission token error"
},
"IDENTITY_PROVIDER_RETRIEVE_TOKEN_ERROR": {
"name": "Identity provider retrieve token error",
"description": "Identity provider retrieve token error"
},
"CLIENT_INFO": {
"name": "Client info",
"description": "Client info"
},
"VALIDATE_ACCESS_TOKEN": {
"name": "Validate access token",
"description": "Validate access token"
},
"IDENTITY_PROVIDER_LOGIN": {
"name": "Identity provider login",
"description": "Identity provider login"
},
"CLIENT_INFO_ERROR": {
"name": "Client info error",
"description": "Client info error"
},
"INTROSPECT_TOKEN_ERROR": {
"name": "Introspect token error",
"description": "Introspect token error"
},
"INTROSPECT_TOKEN": {
"name": "Introspect token",
"description": "Introspect token"
},
"UNREGISTER_NODE": {
"name": "Unregister node",
"description": "Unregister node"
},
"REGISTER_NODE": {
"name": "Register node",
"description": "Register node"
},
"INVALID_SIGNATURE": {
"name": "Invalid signature",
"description": "Invalid signature"
},
"USER_INFO_REQUEST_ERROR": {
"name": "User info request error",
"description": "User info request error"
},
"REFRESH_TOKEN": {
"name": "Refresh token",
"description": "Refresh token"
},
"IDENTITY_PROVIDER_RESPONSE": {
"name": "Identity provider response",
"description": "Identity provider response"
},
"IDENTITY_PROVIDER_RETRIEVE_TOKEN": {
"name": "Identity provider retrieve token",
"description": "Identity provider retrieve token"
},
"UNREGISTER_NODE_ERROR": {
"name": "Unregister node error",
"description": "Unregister node error"
},
"VALIDATE_ACCESS_TOKEN_ERROR": {
"name": "Validate access token error",
"description": "Validate access token error"
},
"INVALID_SIGNATURE_ERROR": {
"name": "Invalid signature error",
"description": "Invalid signature error"
},
"USER_INFO_REQUEST": {
"name": "User info request",
"description": "User info request"
},
"IDENTITY_PROVIDER_RESPONSE_ERROR": {
"name": "Identity provider response error",
"description": "Identity provider response error"
},
"IDENTITY_PROVIDER_LOGIN_ERROR": {
"name": "Identity provider login error",
"description": "Identity provider login error"
},
"REFRESH_TOKEN_ERROR": {
"name": "Refresh token error",
"description": "Refresh token error"
}
},
"emptyEvents": "Nothing to add",
"emptyEventsInstructions": "There are no more events types left to add",
"eventConfigSuccessfully": "Successfully saved configuration",
"eventConfigError": "Could not save event configuration {{error}}",
"deleteEvents": "Clear events",
"deleteEventsConfirm": "If you clear all events of this realm, all records will be permanently cleared in the database",
"admin-events-cleared": "The admin events have been cleared",
"admin-events-cleared-error": "Could not clear the admin events {{error}}",
"user-events-cleared": "The user events have been cleared",
"user-events-cleared-error": "Could not clear the user events {{error}}",
"events-disable-title": "Unsave events?",
"events-disable-confirm": "If \"Save events\" is disabled, subsequent events will not be displayed in the \"Events\" menu",
"confirm": "Confirm"
}, },
"partial-import": { "partial-import": {
"partialImportHeaderText": "Partial import allows you to import users, clients, and resources from a previously exported json file.", "partialImportHeaderText": "Partial import allows you to import users, clients, and resources from a previously exported json file.",