[issue-33569] Show User Events on dedicated tab on Client-/User-Details (#33574)
Fixes #33569 Signed-off-by: Oliver Cremerius <antikalk@users.noreply.github.com>
This commit is contained in:
parent
703f16ea86
commit
d547c04895
8 changed files with 508 additions and 455 deletions
|
@ -1221,7 +1221,8 @@ describe("Clients test", () => {
|
||||||
.checkTabExists(ClientsDetailsTab.Sessions, true)
|
.checkTabExists(ClientsDetailsTab.Sessions, true)
|
||||||
.checkTabExists(ClientsDetailsTab.Permissions, true)
|
.checkTabExists(ClientsDetailsTab.Permissions, true)
|
||||||
.checkTabExists(ClientsDetailsTab.Advanced, true)
|
.checkTabExists(ClientsDetailsTab.Advanced, true)
|
||||||
.checkNumberOfTabsIsEqual(5);
|
.checkTabExists(ClientsDetailsTab.UserEvents, true)
|
||||||
|
.checkNumberOfTabsIsEqual(6);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("Hides the delete action", () => {
|
it("Hides the delete action", () => {
|
||||||
|
|
|
@ -19,6 +19,7 @@ export enum ClientsDetailsTab {
|
||||||
ServiceAccountsRoles = "Service accounts roles",
|
ServiceAccountsRoles = "Service accounts roles",
|
||||||
Advanced = "Advanced",
|
Advanced = "Advanced",
|
||||||
Scope = "Scope",
|
Scope = "Scope",
|
||||||
|
UserEvents = "User events",
|
||||||
}
|
}
|
||||||
|
|
||||||
export default class ClientDetailsPage extends CommonPage {
|
export default class ClientDetailsPage extends CommonPage {
|
||||||
|
|
|
@ -72,6 +72,7 @@ import { ClientScopes } from "./scopes/ClientScopes";
|
||||||
import { EvaluateScopes } from "./scopes/EvaluateScopes";
|
import { EvaluateScopes } from "./scopes/EvaluateScopes";
|
||||||
import { ServiceAccount } from "./service-account/ServiceAccount";
|
import { ServiceAccount } from "./service-account/ServiceAccount";
|
||||||
import { getProtocolName, isRealmClient } from "./utils";
|
import { getProtocolName, isRealmClient } from "./utils";
|
||||||
|
import { UserEvents } from "../events/UserEvents";
|
||||||
|
|
||||||
type ClientDetailHeaderProps = {
|
type ClientDetailHeaderProps = {
|
||||||
onChange: (value: boolean) => void;
|
onChange: (value: boolean) => void;
|
||||||
|
@ -244,6 +245,7 @@ export default function ClientDetails() {
|
||||||
const sessionsTab = useTab("sessions");
|
const sessionsTab = useTab("sessions");
|
||||||
const permissionsTab = useTab("permissions");
|
const permissionsTab = useTab("permissions");
|
||||||
const advancedTab = useTab("advanced");
|
const advancedTab = useTab("advanced");
|
||||||
|
const userEventsTab = useTab("user-events");
|
||||||
|
|
||||||
const useClientScopesTab = (tab: ClientScopesTab) =>
|
const useClientScopesTab = (tab: ClientScopesTab) =>
|
||||||
useRoutableTab(
|
useRoutableTab(
|
||||||
|
@ -663,6 +665,15 @@ export default function ClientDetails() {
|
||||||
>
|
>
|
||||||
<AdvancedTab save={save} client={client} />
|
<AdvancedTab save={save} client={client} />
|
||||||
</Tab>
|
</Tab>
|
||||||
|
{hasAccess("view-events") && (
|
||||||
|
<Tab
|
||||||
|
data-testid="user-events-tab"
|
||||||
|
title={<TabTitleText>{t("userEvents")}</TabTitleText>}
|
||||||
|
{...userEventsTab}
|
||||||
|
>
|
||||||
|
<UserEvents client={client.clientId} />
|
||||||
|
</Tab>
|
||||||
|
)}
|
||||||
</RoutableTabs>
|
</RoutableTabs>
|
||||||
</FormProvider>
|
</FormProvider>
|
||||||
</PageSection>
|
</PageSection>
|
||||||
|
|
|
@ -14,7 +14,8 @@ export type ClientTab =
|
||||||
| "authorization"
|
| "authorization"
|
||||||
| "serviceAccount"
|
| "serviceAccount"
|
||||||
| "permissions"
|
| "permissions"
|
||||||
| "sessions";
|
| "sessions"
|
||||||
|
| "user-events";
|
||||||
|
|
||||||
export type ClientParams = {
|
export type ClientParams = {
|
||||||
realm: string;
|
realm: string;
|
||||||
|
|
|
@ -1,44 +1,6 @@
|
||||||
import type EventRepresentation from "@keycloak/keycloak-admin-client/lib/defs/eventRepresentation";
|
import { PageSection, Tab, TabTitleText } from "@patternfly/react-core";
|
||||||
import type EventType from "@keycloak/keycloak-admin-client/lib/defs/eventTypes";
|
|
||||||
import type { RealmEventsConfigRepresentation } from "@keycloak/keycloak-admin-client/lib/defs/realmEventsConfigRepresentation";
|
|
||||||
import {
|
|
||||||
KeycloakDataTable,
|
|
||||||
KeycloakSelect,
|
|
||||||
ListEmptyState,
|
|
||||||
SelectVariant,
|
|
||||||
TextControl,
|
|
||||||
useFetch,
|
|
||||||
} from "@keycloak/keycloak-ui-shared";
|
|
||||||
import {
|
|
||||||
ActionGroup,
|
|
||||||
Button,
|
|
||||||
Chip,
|
|
||||||
ChipGroup,
|
|
||||||
DatePicker,
|
|
||||||
DescriptionList,
|
|
||||||
DescriptionListDescription,
|
|
||||||
DescriptionListGroup,
|
|
||||||
DescriptionListTerm,
|
|
||||||
Flex,
|
|
||||||
FlexItem,
|
|
||||||
Form,
|
|
||||||
FormGroup,
|
|
||||||
Icon,
|
|
||||||
PageSection,
|
|
||||||
SelectOption,
|
|
||||||
Tab,
|
|
||||||
TabTitleText,
|
|
||||||
Tooltip,
|
|
||||||
} from "@patternfly/react-core";
|
|
||||||
import { CheckCircleIcon, WarningTriangleIcon } from "@patternfly/react-icons";
|
|
||||||
import { cellWidth } from "@patternfly/react-table";
|
|
||||||
import { pickBy } from "lodash-es";
|
|
||||||
import { useState } from "react";
|
|
||||||
import { Controller, FormProvider, useForm } from "react-hook-form";
|
|
||||||
import { Trans, useTranslation } from "react-i18next";
|
import { Trans, useTranslation } from "react-i18next";
|
||||||
import { Link } from "react-router-dom";
|
import { Link } from "react-router-dom";
|
||||||
import { useAdminClient } from "../admin-client";
|
|
||||||
import DropdownPanel from "../components/dropdown-panel/DropdownPanel";
|
|
||||||
import {
|
import {
|
||||||
RoutableTabs,
|
RoutableTabs,
|
||||||
useRoutableTab,
|
useRoutableTab,
|
||||||
|
@ -47,383 +9,21 @@ import { ViewHeader } from "../components/view-header/ViewHeader";
|
||||||
import { useRealm } from "../context/realm-context/RealmContext";
|
import { useRealm } from "../context/realm-context/RealmContext";
|
||||||
import helpUrls from "../help-urls";
|
import helpUrls from "../help-urls";
|
||||||
import { toRealmSettings } from "../realm-settings/routes/RealmSettings";
|
import { toRealmSettings } from "../realm-settings/routes/RealmSettings";
|
||||||
import { toUser } from "../user/routes/User";
|
|
||||||
import useFormatDate, { FORMAT_DATE_AND_TIME } from "../utils/useFormatDate";
|
|
||||||
import { AdminEvents } from "./AdminEvents";
|
import { AdminEvents } from "./AdminEvents";
|
||||||
|
import { UserEvents } from "./UserEvents";
|
||||||
import { EventsTab, toEvents } from "./routes/Events";
|
import { EventsTab, toEvents } from "./routes/Events";
|
||||||
|
|
||||||
import "./events.css";
|
import "./events.css";
|
||||||
|
|
||||||
type UserEventSearchForm = {
|
|
||||||
client: string;
|
|
||||||
dateFrom: string;
|
|
||||||
dateTo: string;
|
|
||||||
user: string;
|
|
||||||
type: EventType[];
|
|
||||||
ipAddress: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
const defaultValues: UserEventSearchForm = {
|
|
||||||
client: "",
|
|
||||||
dateFrom: "",
|
|
||||||
dateTo: "",
|
|
||||||
user: "",
|
|
||||||
type: [],
|
|
||||||
ipAddress: "",
|
|
||||||
};
|
|
||||||
|
|
||||||
const StatusRow = (event: EventRepresentation) =>
|
|
||||||
!event.error ? (
|
|
||||||
<span>
|
|
||||||
<Icon status="success">
|
|
||||||
<CheckCircleIcon />
|
|
||||||
</Icon>
|
|
||||||
{event.type}
|
|
||||||
</span>
|
|
||||||
) : (
|
|
||||||
<Tooltip content={event.error}>
|
|
||||||
<span>
|
|
||||||
<Icon status="warning">
|
|
||||||
<WarningTriangleIcon />
|
|
||||||
</Icon>
|
|
||||||
{event.type}
|
|
||||||
</span>
|
|
||||||
</Tooltip>
|
|
||||||
);
|
|
||||||
|
|
||||||
const DetailCell = (event: EventRepresentation) => (
|
|
||||||
<DescriptionList isHorizontal className="keycloak_eventsection_details">
|
|
||||||
{event.details &&
|
|
||||||
Object.entries(event.details).map(([key, value]) => (
|
|
||||||
<DescriptionListGroup key={key}>
|
|
||||||
<DescriptionListTerm>{key}</DescriptionListTerm>
|
|
||||||
<DescriptionListDescription>{value}</DescriptionListDescription>
|
|
||||||
</DescriptionListGroup>
|
|
||||||
))}
|
|
||||||
{event.error && (
|
|
||||||
<DescriptionListGroup key="error">
|
|
||||||
<DescriptionListTerm>error</DescriptionListTerm>
|
|
||||||
<DescriptionListDescription>{event.error}</DescriptionListDescription>
|
|
||||||
</DescriptionListGroup>
|
|
||||||
)}
|
|
||||||
</DescriptionList>
|
|
||||||
);
|
|
||||||
|
|
||||||
const UserDetailLink = (event: EventRepresentation) => {
|
|
||||||
const { t } = useTranslation();
|
|
||||||
const { realm } = useRealm();
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
{event.userId && (
|
|
||||||
<Link
|
|
||||||
key={`link-${event.time}-${event.type}`}
|
|
||||||
to={toUser({
|
|
||||||
realm,
|
|
||||||
id: event.userId,
|
|
||||||
tab: "settings",
|
|
||||||
})}
|
|
||||||
>
|
|
||||||
{event.userId}
|
|
||||||
</Link>
|
|
||||||
)}
|
|
||||||
{!event.userId && t("noUserDetails")}
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default function EventsSection() {
|
export default function EventsSection() {
|
||||||
const { adminClient } = useAdminClient();
|
|
||||||
|
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const { realm } = useRealm();
|
const { realm } = useRealm();
|
||||||
const formatDate = useFormatDate();
|
|
||||||
const [key, setKey] = useState(0);
|
|
||||||
const [searchDropdownOpen, setSearchDropdownOpen] = useState(false);
|
|
||||||
const [selectOpen, setSelectOpen] = useState(false);
|
|
||||||
const [events, setEvents] = useState<RealmEventsConfigRepresentation>();
|
|
||||||
const [activeFilters, setActiveFilters] = useState<
|
|
||||||
Partial<UserEventSearchForm>
|
|
||||||
>({});
|
|
||||||
|
|
||||||
const filterLabels: Record<keyof UserEventSearchForm, string> = {
|
|
||||||
client: t("client"),
|
|
||||||
dateFrom: t("dateFrom"),
|
|
||||||
dateTo: t("dateTo"),
|
|
||||||
user: t("userId"),
|
|
||||||
type: t("eventType"),
|
|
||||||
ipAddress: t("ipAddress"),
|
|
||||||
};
|
|
||||||
|
|
||||||
const form = useForm<UserEventSearchForm>({
|
|
||||||
mode: "onChange",
|
|
||||||
defaultValues,
|
|
||||||
});
|
|
||||||
|
|
||||||
const {
|
|
||||||
getValues,
|
|
||||||
reset,
|
|
||||||
formState: { isDirty },
|
|
||||||
control,
|
|
||||||
handleSubmit,
|
|
||||||
} = form;
|
|
||||||
|
|
||||||
useFetch(
|
|
||||||
() => adminClient.realms.getConfigEvents({ realm }),
|
|
||||||
(events) => setEvents(events),
|
|
||||||
[],
|
|
||||||
);
|
|
||||||
|
|
||||||
function loader(first?: number, max?: number) {
|
|
||||||
return adminClient.realms.findEvents({
|
|
||||||
// The admin client wants 'dateFrom' and 'dateTo' to be Date objects, however it cannot actually handle them so we need to cast to any.
|
|
||||||
...(activeFilters as any),
|
|
||||||
realm,
|
|
||||||
first,
|
|
||||||
max,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
const useTab = (tab: EventsTab) => useRoutableTab(toEvents({ realm, tab }));
|
const useTab = (tab: EventsTab) => useRoutableTab(toEvents({ realm, tab }));
|
||||||
|
|
||||||
const userEventsTab = useTab("user-events");
|
const userEventsTab = useTab("user-events");
|
||||||
const adminEventsTab = useTab("admin-events");
|
const adminEventsTab = useTab("admin-events");
|
||||||
|
|
||||||
function onSubmit() {
|
|
||||||
setSearchDropdownOpen(false);
|
|
||||||
commitFilters();
|
|
||||||
}
|
|
||||||
|
|
||||||
function resetSearch() {
|
|
||||||
reset();
|
|
||||||
commitFilters();
|
|
||||||
}
|
|
||||||
|
|
||||||
function removeFilter(key: keyof UserEventSearchForm) {
|
|
||||||
const formValues: UserEventSearchForm = { ...getValues() };
|
|
||||||
delete formValues[key];
|
|
||||||
|
|
||||||
reset({ ...defaultValues, ...formValues });
|
|
||||||
commitFilters();
|
|
||||||
}
|
|
||||||
|
|
||||||
function removeFilterValue(
|
|
||||||
key: keyof UserEventSearchForm,
|
|
||||||
valueToRemove: EventType,
|
|
||||||
) {
|
|
||||||
const formValues = getValues();
|
|
||||||
const fieldValue = formValues[key];
|
|
||||||
const newFieldValue = Array.isArray(fieldValue)
|
|
||||||
? fieldValue.filter((val) => val !== valueToRemove)
|
|
||||||
: fieldValue;
|
|
||||||
|
|
||||||
reset({ ...formValues, [key]: newFieldValue });
|
|
||||||
commitFilters();
|
|
||||||
}
|
|
||||||
|
|
||||||
function commitFilters() {
|
|
||||||
const newFilters: Partial<UserEventSearchForm> = pickBy(
|
|
||||||
getValues(),
|
|
||||||
(value) => value !== "" || (Array.isArray(value) && value.length > 0),
|
|
||||||
);
|
|
||||||
|
|
||||||
setActiveFilters(newFilters);
|
|
||||||
setKey(key + 1);
|
|
||||||
}
|
|
||||||
|
|
||||||
const userEventSearchFormDisplay = () => {
|
|
||||||
return (
|
|
||||||
<FormProvider {...form}>
|
|
||||||
<Flex
|
|
||||||
direction={{ default: "column" }}
|
|
||||||
spaceItems={{ default: "spaceItemsNone" }}
|
|
||||||
>
|
|
||||||
<FlexItem>
|
|
||||||
<DropdownPanel
|
|
||||||
buttonText={t("searchForUserEvent")}
|
|
||||||
setSearchDropdownOpen={setSearchDropdownOpen}
|
|
||||||
searchDropdownOpen={searchDropdownOpen}
|
|
||||||
marginRight="2.5rem"
|
|
||||||
width="15vw"
|
|
||||||
>
|
|
||||||
<Form
|
|
||||||
data-testid="searchForm"
|
|
||||||
className="keycloak__events_search__form"
|
|
||||||
onSubmit={handleSubmit(onSubmit)}
|
|
||||||
isHorizontal
|
|
||||||
>
|
|
||||||
<TextControl
|
|
||||||
name="user"
|
|
||||||
label={t("userId")}
|
|
||||||
data-testid="userId-searchField"
|
|
||||||
/>
|
|
||||||
<FormGroup
|
|
||||||
label={t("eventType")}
|
|
||||||
fieldId="kc-eventType"
|
|
||||||
className="keycloak__events_search__form_label"
|
|
||||||
>
|
|
||||||
<Controller
|
|
||||||
name="type"
|
|
||||||
control={control}
|
|
||||||
render={({ field }) => (
|
|
||||||
<KeycloakSelect
|
|
||||||
className="keycloak__events_search__type_select"
|
|
||||||
data-testid="event-type-searchField"
|
|
||||||
chipGroupProps={{
|
|
||||||
numChips: 1,
|
|
||||||
expandedText: t("hide"),
|
|
||||||
collapsedText: t("showRemaining"),
|
|
||||||
}}
|
|
||||||
variant={SelectVariant.typeaheadMulti}
|
|
||||||
typeAheadAriaLabel="Select"
|
|
||||||
onToggle={(isOpen) => setSelectOpen(isOpen)}
|
|
||||||
selections={field.value}
|
|
||||||
onSelect={(selectedValue) => {
|
|
||||||
const option = selectedValue.toString() as EventType;
|
|
||||||
const changedValue = field.value.includes(option)
|
|
||||||
? field.value.filter((item) => item !== option)
|
|
||||||
: [...field.value, option];
|
|
||||||
|
|
||||||
field.onChange(changedValue);
|
|
||||||
}}
|
|
||||||
onClear={() => {
|
|
||||||
field.onChange([]);
|
|
||||||
}}
|
|
||||||
isOpen={selectOpen}
|
|
||||||
aria-labelledby={"eventType"}
|
|
||||||
chipGroupComponent={
|
|
||||||
<ChipGroup>
|
|
||||||
{field.value.map((chip) => (
|
|
||||||
<Chip
|
|
||||||
key={chip}
|
|
||||||
onClick={(event) => {
|
|
||||||
event.stopPropagation();
|
|
||||||
field.onChange(
|
|
||||||
field.value.filter((val) => val !== chip),
|
|
||||||
);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{t(`eventTypes.${chip}.name`)}
|
|
||||||
</Chip>
|
|
||||||
))}
|
|
||||||
</ChipGroup>
|
|
||||||
}
|
|
||||||
>
|
|
||||||
{events?.enabledEventTypes?.map((option) => (
|
|
||||||
<SelectOption key={option} value={option}>
|
|
||||||
{t(`eventTypes.${option}.name`)}
|
|
||||||
</SelectOption>
|
|
||||||
))}
|
|
||||||
</KeycloakSelect>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
</FormGroup>
|
|
||||||
<TextControl
|
|
||||||
name="client"
|
|
||||||
label={t("client")}
|
|
||||||
data-testid="client-searchField"
|
|
||||||
/>
|
|
||||||
<FormGroup
|
|
||||||
label={t("dateFrom")}
|
|
||||||
fieldId="kc-dateFrom"
|
|
||||||
className="keycloak__events_search__form_label"
|
|
||||||
>
|
|
||||||
<Controller
|
|
||||||
name="dateFrom"
|
|
||||||
control={control}
|
|
||||||
render={({ field }) => (
|
|
||||||
<DatePicker
|
|
||||||
className="pf-v5-u-w-100"
|
|
||||||
value={field.value}
|
|
||||||
onChange={(_, value) => field.onChange(value)}
|
|
||||||
inputProps={{ id: "kc-dateFrom" }}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
</FormGroup>
|
|
||||||
<FormGroup
|
|
||||||
label={t("dateTo")}
|
|
||||||
fieldId="kc-dateTo"
|
|
||||||
className="keycloak__events_search__form_label"
|
|
||||||
>
|
|
||||||
<Controller
|
|
||||||
name="dateTo"
|
|
||||||
control={control}
|
|
||||||
render={({ field }) => (
|
|
||||||
<DatePicker
|
|
||||||
className="pf-v5-u-w-100"
|
|
||||||
value={field.value}
|
|
||||||
onChange={(_, value) => field.onChange(value)}
|
|
||||||
inputProps={{ id: "kc-dateTo" }}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
</FormGroup>
|
|
||||||
<TextControl
|
|
||||||
name="ipAddress"
|
|
||||||
label={t("ipAddress")}
|
|
||||||
data-testid="ipAddress-searchField"
|
|
||||||
/>
|
|
||||||
<ActionGroup>
|
|
||||||
<Button
|
|
||||||
data-testid="search-events-btn"
|
|
||||||
variant="primary"
|
|
||||||
type="submit"
|
|
||||||
isDisabled={!isDirty}
|
|
||||||
>
|
|
||||||
{t("searchUserEventsBtn")}
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
variant="secondary"
|
|
||||||
onClick={resetSearch}
|
|
||||||
isDisabled={!isDirty}
|
|
||||||
>
|
|
||||||
{t("resetBtn")}
|
|
||||||
</Button>
|
|
||||||
</ActionGroup>
|
|
||||||
</Form>
|
|
||||||
</DropdownPanel>
|
|
||||||
</FlexItem>
|
|
||||||
<FlexItem>
|
|
||||||
{Object.entries(activeFilters).length > 0 && (
|
|
||||||
<div className="keycloak__searchChips pf-v5-u-ml-md">
|
|
||||||
{Object.entries(activeFilters).map((filter) => {
|
|
||||||
const [key, value] = filter as [
|
|
||||||
keyof UserEventSearchForm,
|
|
||||||
string | EventType[],
|
|
||||||
];
|
|
||||||
|
|
||||||
return (
|
|
||||||
<ChipGroup
|
|
||||||
className="pf-v5-u-mt-md pf-v5-u-mr-md"
|
|
||||||
key={key}
|
|
||||||
categoryName={filterLabels[key]}
|
|
||||||
isClosable
|
|
||||||
onClick={() => removeFilter(key)}
|
|
||||||
>
|
|
||||||
{typeof value === "string" ? (
|
|
||||||
<Chip isReadOnly>{value}</Chip>
|
|
||||||
) : (
|
|
||||||
value.map((entry) => (
|
|
||||||
<Chip
|
|
||||||
key={entry}
|
|
||||||
onClick={() => removeFilterValue(key, entry)}
|
|
||||||
>
|
|
||||||
{t(`eventTypes.${entry}.name`)}
|
|
||||||
</Chip>
|
|
||||||
))
|
|
||||||
)}
|
|
||||||
</ChipGroup>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</FlexItem>
|
|
||||||
</Flex>
|
|
||||||
</FormProvider>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<ViewHeader
|
<ViewHeader
|
||||||
|
@ -450,56 +50,7 @@ export default function EventsSection() {
|
||||||
title={<TabTitleText>{t("userEvents")}</TabTitleText>}
|
title={<TabTitleText>{t("userEvents")}</TabTitleText>}
|
||||||
{...userEventsTab}
|
{...userEventsTab}
|
||||||
>
|
>
|
||||||
<div className="keycloak__events_table">
|
<UserEvents />
|
||||||
<KeycloakDataTable
|
|
||||||
key={key}
|
|
||||||
loader={loader}
|
|
||||||
detailColumns={[
|
|
||||||
{
|
|
||||||
name: "details",
|
|
||||||
enabled: (event) => event.details !== undefined,
|
|
||||||
cellRenderer: DetailCell,
|
|
||||||
},
|
|
||||||
]}
|
|
||||||
isPaginated
|
|
||||||
ariaLabelKey="titleEvents"
|
|
||||||
toolbarItem={userEventSearchFormDisplay()}
|
|
||||||
columns={[
|
|
||||||
{
|
|
||||||
name: "time",
|
|
||||||
displayKey: "time",
|
|
||||||
cellRenderer: (row) =>
|
|
||||||
formatDate(new Date(row.time!), FORMAT_DATE_AND_TIME),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "userId",
|
|
||||||
displayKey: "user",
|
|
||||||
cellRenderer: UserDetailLink,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "type",
|
|
||||||
displayKey: "eventType",
|
|
||||||
cellRenderer: StatusRow,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "ipAddress",
|
|
||||||
displayKey: "ipAddress",
|
|
||||||
transforms: [cellWidth(10)],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "clientId",
|
|
||||||
displayKey: "client",
|
|
||||||
},
|
|
||||||
]}
|
|
||||||
emptyState={
|
|
||||||
<ListEmptyState
|
|
||||||
message={t("emptyUserEvents")}
|
|
||||||
instructions={t("emptyUserEventsInstructions")}
|
|
||||||
/>
|
|
||||||
}
|
|
||||||
isSearching={Object.keys(activeFilters).length > 0}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</Tab>
|
</Tab>
|
||||||
<Tab
|
<Tab
|
||||||
title={<TabTitleText>{t("adminEvents")}</TabTitleText>}
|
title={<TabTitleText>{t("adminEvents")}</TabTitleText>}
|
||||||
|
|
476
js/apps/admin-ui/src/events/UserEvents.tsx
Normal file
476
js/apps/admin-ui/src/events/UserEvents.tsx
Normal file
|
@ -0,0 +1,476 @@
|
||||||
|
import type EventRepresentation from "@keycloak/keycloak-admin-client/lib/defs/eventRepresentation";
|
||||||
|
import type EventType from "@keycloak/keycloak-admin-client/lib/defs/eventTypes";
|
||||||
|
import type { RealmEventsConfigRepresentation } from "@keycloak/keycloak-admin-client/lib/defs/realmEventsConfigRepresentation";
|
||||||
|
import {
|
||||||
|
KeycloakDataTable,
|
||||||
|
KeycloakSelect,
|
||||||
|
ListEmptyState,
|
||||||
|
SelectVariant,
|
||||||
|
TextControl,
|
||||||
|
useFetch,
|
||||||
|
} from "@keycloak/keycloak-ui-shared";
|
||||||
|
import {
|
||||||
|
ActionGroup,
|
||||||
|
Button,
|
||||||
|
Chip,
|
||||||
|
ChipGroup,
|
||||||
|
DatePicker,
|
||||||
|
DescriptionList,
|
||||||
|
DescriptionListDescription,
|
||||||
|
DescriptionListGroup,
|
||||||
|
DescriptionListTerm,
|
||||||
|
Flex,
|
||||||
|
FlexItem,
|
||||||
|
Form,
|
||||||
|
FormGroup,
|
||||||
|
Icon,
|
||||||
|
SelectOption,
|
||||||
|
Tooltip,
|
||||||
|
} from "@patternfly/react-core";
|
||||||
|
import { CheckCircleIcon, WarningTriangleIcon } from "@patternfly/react-icons";
|
||||||
|
import { cellWidth } from "@patternfly/react-table";
|
||||||
|
import { pickBy } from "lodash-es";
|
||||||
|
import { useState } from "react";
|
||||||
|
import { Controller, FormProvider, useForm } from "react-hook-form";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { Link } from "react-router-dom";
|
||||||
|
import { useAdminClient } from "../admin-client";
|
||||||
|
import DropdownPanel from "../components/dropdown-panel/DropdownPanel";
|
||||||
|
import { useRealm } from "../context/realm-context/RealmContext";
|
||||||
|
import { toUser } from "../user/routes/User";
|
||||||
|
import useFormatDate, { FORMAT_DATE_AND_TIME } from "../utils/useFormatDate";
|
||||||
|
|
||||||
|
import "./events.css";
|
||||||
|
|
||||||
|
type UserEventSearchForm = {
|
||||||
|
client: string;
|
||||||
|
dateFrom: string;
|
||||||
|
dateTo: string;
|
||||||
|
user: string;
|
||||||
|
type: EventType[];
|
||||||
|
ipAddress: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
const StatusRow = (event: EventRepresentation) =>
|
||||||
|
!event.error ? (
|
||||||
|
<span>
|
||||||
|
<Icon status="success">
|
||||||
|
<CheckCircleIcon />
|
||||||
|
</Icon>
|
||||||
|
{event.type}
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
<Tooltip content={event.error}>
|
||||||
|
<span>
|
||||||
|
<Icon status="warning">
|
||||||
|
<WarningTriangleIcon />
|
||||||
|
</Icon>
|
||||||
|
{event.type}
|
||||||
|
</span>
|
||||||
|
</Tooltip>
|
||||||
|
);
|
||||||
|
|
||||||
|
const DetailCell = (event: EventRepresentation) => (
|
||||||
|
<DescriptionList isHorizontal className="keycloak_eventsection_details">
|
||||||
|
{event.details &&
|
||||||
|
Object.entries(event.details).map(([key, value]) => (
|
||||||
|
<DescriptionListGroup key={key}>
|
||||||
|
<DescriptionListTerm>{key}</DescriptionListTerm>
|
||||||
|
<DescriptionListDescription>{value}</DescriptionListDescription>
|
||||||
|
</DescriptionListGroup>
|
||||||
|
))}
|
||||||
|
{event.error && (
|
||||||
|
<DescriptionListGroup key="error">
|
||||||
|
<DescriptionListTerm>error</DescriptionListTerm>
|
||||||
|
<DescriptionListDescription>{event.error}</DescriptionListDescription>
|
||||||
|
</DescriptionListGroup>
|
||||||
|
)}
|
||||||
|
</DescriptionList>
|
||||||
|
);
|
||||||
|
|
||||||
|
const UserDetailLink = (event: EventRepresentation) => {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const { realm } = useRealm();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{event.userId && (
|
||||||
|
<Link
|
||||||
|
key={`link-${event.time}-${event.type}`}
|
||||||
|
to={toUser({
|
||||||
|
realm,
|
||||||
|
id: event.userId,
|
||||||
|
tab: "settings",
|
||||||
|
})}
|
||||||
|
>
|
||||||
|
{event.userId}
|
||||||
|
</Link>
|
||||||
|
)}
|
||||||
|
{!event.userId && t("noUserDetails")}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
type UserEventsProps = {
|
||||||
|
user?: string;
|
||||||
|
client?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const UserEvents = ({ user, client }: UserEventsProps) => {
|
||||||
|
const { adminClient } = useAdminClient();
|
||||||
|
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const { realm } = useRealm();
|
||||||
|
const formatDate = useFormatDate();
|
||||||
|
const [key, setKey] = useState(0);
|
||||||
|
const [searchDropdownOpen, setSearchDropdownOpen] = useState(false);
|
||||||
|
const [selectOpen, setSelectOpen] = useState(false);
|
||||||
|
const [events, setEvents] = useState<RealmEventsConfigRepresentation>();
|
||||||
|
const [activeFilters, setActiveFilters] = useState<
|
||||||
|
Partial<UserEventSearchForm>
|
||||||
|
>({
|
||||||
|
...(user && { user }),
|
||||||
|
...(client && { client }),
|
||||||
|
});
|
||||||
|
|
||||||
|
const defaultValues: UserEventSearchForm = {
|
||||||
|
client: client ? client : "",
|
||||||
|
dateFrom: "",
|
||||||
|
dateTo: "",
|
||||||
|
user: user ? user : "",
|
||||||
|
type: [],
|
||||||
|
ipAddress: "",
|
||||||
|
};
|
||||||
|
|
||||||
|
const filterLabels: Record<keyof UserEventSearchForm, string> = {
|
||||||
|
client: t("client"),
|
||||||
|
dateFrom: t("dateFrom"),
|
||||||
|
dateTo: t("dateTo"),
|
||||||
|
user: t("userId"),
|
||||||
|
type: t("eventType"),
|
||||||
|
ipAddress: t("ipAddress"),
|
||||||
|
};
|
||||||
|
|
||||||
|
const form = useForm<UserEventSearchForm>({
|
||||||
|
mode: "onChange",
|
||||||
|
defaultValues,
|
||||||
|
});
|
||||||
|
|
||||||
|
const {
|
||||||
|
getValues,
|
||||||
|
reset,
|
||||||
|
formState: { isDirty },
|
||||||
|
control,
|
||||||
|
handleSubmit,
|
||||||
|
} = form;
|
||||||
|
|
||||||
|
useFetch(
|
||||||
|
() => adminClient.realms.getConfigEvents({ realm }),
|
||||||
|
(events) => setEvents(events),
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
|
||||||
|
function loader(first?: number, max?: number) {
|
||||||
|
return adminClient.realms.findEvents({
|
||||||
|
// The admin client wants 'dateFrom' and 'dateTo' to be Date objects, however it cannot actually handle them so we need to cast to any.
|
||||||
|
...(activeFilters as any),
|
||||||
|
realm,
|
||||||
|
first,
|
||||||
|
max,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function onSubmit() {
|
||||||
|
setSearchDropdownOpen(false);
|
||||||
|
commitFilters();
|
||||||
|
}
|
||||||
|
|
||||||
|
function resetSearch() {
|
||||||
|
reset();
|
||||||
|
commitFilters();
|
||||||
|
}
|
||||||
|
|
||||||
|
function removeFilter(key: keyof UserEventSearchForm) {
|
||||||
|
const formValues: UserEventSearchForm = { ...getValues() };
|
||||||
|
delete formValues[key];
|
||||||
|
|
||||||
|
reset({ ...defaultValues, ...formValues });
|
||||||
|
commitFilters();
|
||||||
|
}
|
||||||
|
|
||||||
|
function removeFilterValue(
|
||||||
|
key: keyof UserEventSearchForm,
|
||||||
|
valueToRemove: EventType,
|
||||||
|
) {
|
||||||
|
const formValues = getValues();
|
||||||
|
const fieldValue = formValues[key];
|
||||||
|
const newFieldValue = Array.isArray(fieldValue)
|
||||||
|
? fieldValue.filter((val) => val !== valueToRemove)
|
||||||
|
: fieldValue;
|
||||||
|
|
||||||
|
reset({ ...formValues, [key]: newFieldValue });
|
||||||
|
commitFilters();
|
||||||
|
}
|
||||||
|
|
||||||
|
function commitFilters() {
|
||||||
|
const newFilters: Partial<UserEventSearchForm> = pickBy(
|
||||||
|
getValues(),
|
||||||
|
(value) => value !== "" || (Array.isArray(value) && value.length > 0),
|
||||||
|
);
|
||||||
|
|
||||||
|
setActiveFilters(newFilters);
|
||||||
|
setKey(key + 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
const userEventSearchFormDisplay = () => {
|
||||||
|
return (
|
||||||
|
<FormProvider {...form}>
|
||||||
|
<Flex
|
||||||
|
direction={{ default: "column" }}
|
||||||
|
spaceItems={{ default: "spaceItemsNone" }}
|
||||||
|
>
|
||||||
|
<FlexItem>
|
||||||
|
<DropdownPanel
|
||||||
|
buttonText={t("searchForUserEvent")}
|
||||||
|
setSearchDropdownOpen={setSearchDropdownOpen}
|
||||||
|
searchDropdownOpen={searchDropdownOpen}
|
||||||
|
marginRight="2.5rem"
|
||||||
|
width="15vw"
|
||||||
|
>
|
||||||
|
<Form
|
||||||
|
data-testid="searchForm"
|
||||||
|
className="keycloak__events_search__form"
|
||||||
|
onSubmit={handleSubmit(onSubmit)}
|
||||||
|
isHorizontal
|
||||||
|
>
|
||||||
|
<TextControl
|
||||||
|
name="user"
|
||||||
|
label={t("userId")}
|
||||||
|
data-testid="userId-searchField"
|
||||||
|
isDisabled={!!user}
|
||||||
|
/>
|
||||||
|
<FormGroup
|
||||||
|
label={t("eventType")}
|
||||||
|
fieldId="kc-eventType"
|
||||||
|
className="keycloak__events_search__form_label"
|
||||||
|
>
|
||||||
|
<Controller
|
||||||
|
name="type"
|
||||||
|
control={control}
|
||||||
|
render={({ field }) => (
|
||||||
|
<KeycloakSelect
|
||||||
|
className="keycloak__events_search__type_select"
|
||||||
|
data-testid="event-type-searchField"
|
||||||
|
chipGroupProps={{
|
||||||
|
numChips: 1,
|
||||||
|
expandedText: t("hide"),
|
||||||
|
collapsedText: t("showRemaining"),
|
||||||
|
}}
|
||||||
|
variant={SelectVariant.typeaheadMulti}
|
||||||
|
typeAheadAriaLabel="Select"
|
||||||
|
onToggle={(isOpen) => setSelectOpen(isOpen)}
|
||||||
|
selections={field.value}
|
||||||
|
onSelect={(selectedValue) => {
|
||||||
|
const option = selectedValue.toString() as EventType;
|
||||||
|
const changedValue = field.value.includes(option)
|
||||||
|
? field.value.filter((item) => item !== option)
|
||||||
|
: [...field.value, option];
|
||||||
|
|
||||||
|
field.onChange(changedValue);
|
||||||
|
}}
|
||||||
|
onClear={() => {
|
||||||
|
field.onChange([]);
|
||||||
|
}}
|
||||||
|
isOpen={selectOpen}
|
||||||
|
aria-labelledby={"eventType"}
|
||||||
|
chipGroupComponent={
|
||||||
|
<ChipGroup>
|
||||||
|
{field.value.map((chip) => (
|
||||||
|
<Chip
|
||||||
|
key={chip}
|
||||||
|
onClick={(event) => {
|
||||||
|
event.stopPropagation();
|
||||||
|
field.onChange(
|
||||||
|
field.value.filter((val) => val !== chip),
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{t(`eventTypes.${chip}.name`)}
|
||||||
|
</Chip>
|
||||||
|
))}
|
||||||
|
</ChipGroup>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{events?.enabledEventTypes?.map((option) => (
|
||||||
|
<SelectOption key={option} value={option}>
|
||||||
|
{t(`eventTypes.${option}.name`)}
|
||||||
|
</SelectOption>
|
||||||
|
))}
|
||||||
|
</KeycloakSelect>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</FormGroup>
|
||||||
|
<TextControl
|
||||||
|
name="client"
|
||||||
|
label={t("client")}
|
||||||
|
data-testid="client-searchField"
|
||||||
|
isDisabled={!!client}
|
||||||
|
/>
|
||||||
|
<FormGroup
|
||||||
|
label={t("dateFrom")}
|
||||||
|
fieldId="kc-dateFrom"
|
||||||
|
className="keycloak__events_search__form_label"
|
||||||
|
>
|
||||||
|
<Controller
|
||||||
|
name="dateFrom"
|
||||||
|
control={control}
|
||||||
|
render={({ field }) => (
|
||||||
|
<DatePicker
|
||||||
|
className="pf-v5-u-w-100"
|
||||||
|
value={field.value}
|
||||||
|
onChange={(_, value) => field.onChange(value)}
|
||||||
|
inputProps={{ id: "kc-dateFrom" }}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</FormGroup>
|
||||||
|
<FormGroup
|
||||||
|
label={t("dateTo")}
|
||||||
|
fieldId="kc-dateTo"
|
||||||
|
className="keycloak__events_search__form_label"
|
||||||
|
>
|
||||||
|
<Controller
|
||||||
|
name="dateTo"
|
||||||
|
control={control}
|
||||||
|
render={({ field }) => (
|
||||||
|
<DatePicker
|
||||||
|
className="pf-v5-u-w-100"
|
||||||
|
value={field.value}
|
||||||
|
onChange={(_, value) => field.onChange(value)}
|
||||||
|
inputProps={{ id: "kc-dateTo" }}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</FormGroup>
|
||||||
|
<TextControl
|
||||||
|
name="ipAddress"
|
||||||
|
label={t("ipAddress")}
|
||||||
|
data-testid="ipAddress-searchField"
|
||||||
|
/>
|
||||||
|
<ActionGroup>
|
||||||
|
<Button
|
||||||
|
data-testid="search-events-btn"
|
||||||
|
variant="primary"
|
||||||
|
type="submit"
|
||||||
|
isDisabled={!isDirty}
|
||||||
|
>
|
||||||
|
{t("searchUserEventsBtn")}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="secondary"
|
||||||
|
onClick={resetSearch}
|
||||||
|
isDisabled={!isDirty}
|
||||||
|
>
|
||||||
|
{t("resetBtn")}
|
||||||
|
</Button>
|
||||||
|
</ActionGroup>
|
||||||
|
</Form>
|
||||||
|
</DropdownPanel>
|
||||||
|
</FlexItem>
|
||||||
|
<FlexItem>
|
||||||
|
{Object.entries(activeFilters).length > 0 && (
|
||||||
|
<div className="keycloak__searchChips pf-v5-u-ml-md">
|
||||||
|
{Object.entries(activeFilters).map((filter) => {
|
||||||
|
const [key, value] = filter as [
|
||||||
|
keyof UserEventSearchForm,
|
||||||
|
string | EventType[],
|
||||||
|
];
|
||||||
|
|
||||||
|
const disableClose =
|
||||||
|
(key === "user" && !!user) ||
|
||||||
|
(key === "client" && !!client);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ChipGroup
|
||||||
|
className="pf-v5-u-mt-md pf-v5-u-mr-md"
|
||||||
|
key={key}
|
||||||
|
categoryName={filterLabels[key]}
|
||||||
|
isClosable={!disableClose}
|
||||||
|
onClick={() => removeFilter(key)}
|
||||||
|
>
|
||||||
|
{typeof value === "string" ? (
|
||||||
|
<Chip isReadOnly>{value}</Chip>
|
||||||
|
) : (
|
||||||
|
value.map((entry) => (
|
||||||
|
<Chip
|
||||||
|
key={entry}
|
||||||
|
onClick={() => removeFilterValue(key, entry)}
|
||||||
|
>
|
||||||
|
{t(`eventTypes.${entry}.name`)}
|
||||||
|
</Chip>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</ChipGroup>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</FlexItem>
|
||||||
|
</Flex>
|
||||||
|
</FormProvider>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="keycloak__events_table">
|
||||||
|
<KeycloakDataTable
|
||||||
|
key={key}
|
||||||
|
loader={loader}
|
||||||
|
detailColumns={[
|
||||||
|
{
|
||||||
|
name: "details",
|
||||||
|
enabled: (event) => event.details !== undefined,
|
||||||
|
cellRenderer: DetailCell,
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
isPaginated
|
||||||
|
ariaLabelKey="titleEvents"
|
||||||
|
toolbarItem={userEventSearchFormDisplay()}
|
||||||
|
columns={[
|
||||||
|
{
|
||||||
|
name: "time",
|
||||||
|
displayKey: "time",
|
||||||
|
cellRenderer: (row) =>
|
||||||
|
formatDate(new Date(row.time!), FORMAT_DATE_AND_TIME),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "userId",
|
||||||
|
displayKey: "user",
|
||||||
|
cellRenderer: UserDetailLink,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "type",
|
||||||
|
displayKey: "eventType",
|
||||||
|
cellRenderer: StatusRow,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "ipAddress",
|
||||||
|
displayKey: "ipAddress",
|
||||||
|
transforms: [cellWidth(10)],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "clientId",
|
||||||
|
displayKey: "client",
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
emptyState={
|
||||||
|
<ListEmptyState
|
||||||
|
message={t("emptyUserEvents")}
|
||||||
|
instructions={t("emptyUserEventsInstructions")}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
isSearching={Object.keys(activeFilters).length > 0}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
|
@ -47,6 +47,7 @@ import { UserGroups } from "./UserGroups";
|
||||||
import { UserIdentityProviderLinks } from "./UserIdentityProviderLinks";
|
import { UserIdentityProviderLinks } from "./UserIdentityProviderLinks";
|
||||||
import { UserRoleMapping } from "./UserRoleMapping";
|
import { UserRoleMapping } from "./UserRoleMapping";
|
||||||
import { UserSessions } from "./UserSessions";
|
import { UserSessions } from "./UserSessions";
|
||||||
|
import { UserEvents } from "../events/UserEvents";
|
||||||
import {
|
import {
|
||||||
UIUserRepresentation,
|
UIUserRepresentation,
|
||||||
UserFormFields,
|
UserFormFields,
|
||||||
|
@ -112,6 +113,7 @@ export default function EditUser() {
|
||||||
const consentsTab = useTab("consents");
|
const consentsTab = useTab("consents");
|
||||||
const identityProviderLinksTab = useTab("identity-provider-links");
|
const identityProviderLinksTab = useTab("identity-provider-links");
|
||||||
const sessionsTab = useTab("sessions");
|
const sessionsTab = useTab("sessions");
|
||||||
|
const userEventsTab = useTab("user-events");
|
||||||
|
|
||||||
useFetch(
|
useFetch(
|
||||||
async () =>
|
async () =>
|
||||||
|
@ -424,6 +426,15 @@ export default function EditUser() {
|
||||||
>
|
>
|
||||||
<UserSessions />
|
<UserSessions />
|
||||||
</Tab>
|
</Tab>
|
||||||
|
{hasAccess("view-events") && (
|
||||||
|
<Tab
|
||||||
|
data-testid="user-events-tab"
|
||||||
|
title={<TabTitleText>{t("userEvents")}</TabTitleText>}
|
||||||
|
{...userEventsTab}
|
||||||
|
>
|
||||||
|
<UserEvents user={user.id} />
|
||||||
|
</Tab>
|
||||||
|
)}
|
||||||
</RoutableTabs>
|
</RoutableTabs>
|
||||||
</FormProvider>
|
</FormProvider>
|
||||||
</UserProfileProvider>
|
</UserProfileProvider>
|
||||||
|
|
|
@ -12,7 +12,8 @@ export type UserTab =
|
||||||
| "sessions"
|
| "sessions"
|
||||||
| "credentials"
|
| "credentials"
|
||||||
| "role-mapping"
|
| "role-mapping"
|
||||||
| "identity-provider-links";
|
| "identity-provider-links"
|
||||||
|
| "user-events";
|
||||||
|
|
||||||
export type UserParams = {
|
export type UserParams = {
|
||||||
realm: string;
|
realm: string;
|
||||||
|
|
Loading…
Reference in a new issue