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:
parent
4b5193dcef
commit
544dcd31db
15 changed files with 1097 additions and 94 deletions
|
@ -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();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
// });
|
|
||||||
|
|
|
@ -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);
|
||||||
});
|
});
|
||||||
|
|
|
@ -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"]);
|
||||||
|
|
|
@ -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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -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}</>;
|
||||||
};
|
};
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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);
|
||||||
|
|
|
@ -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>
|
||||||
}
|
}
|
||||||
|
|
|
@ -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>
|
||||||
|
|
62
src/realm-settings/event-config/AddEventTypesDialog.tsx
Normal file
62
src/realm-settings/event-config/AddEventTypesDialog.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
};
|
183
src/realm-settings/event-config/EventConfigForm.tsx
Normal file
183
src/realm-settings/event-config/EventConfigForm.tsx
Normal 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>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
210
src/realm-settings/event-config/EventsTab.tsx
Normal file
210
src/realm-settings/event-config/EventsTab.tsx
Normal 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>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
84
src/realm-settings/event-config/EventsTypeTable.tsx
Normal file
84
src/realm-settings/event-config/EventsTypeTable.tsx
Normal 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")}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
|
@ -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",
|
||||||
|
|
|
@ -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",
|
||||||
|
@ -39,7 +48,7 @@
|
||||||
"password": "Password",
|
"password": "Password",
|
||||||
"keys": "Keys",
|
"keys": "Keys",
|
||||||
"keysList": "Keys list",
|
"keysList": "Keys list",
|
||||||
"searchKey":"Search key",
|
"searchKey": "Search key",
|
||||||
"keystore": "Keystore",
|
"keystore": "Keystore",
|
||||||
"keystorePassword": "Keystore password",
|
"keystorePassword": "Keystore password",
|
||||||
"keyAlias": "Key alias",
|
"keyAlias": "Key alias",
|
||||||
|
@ -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.",
|
||||||
|
|
Loading…
Reference in a new issue