Merge pull request #412 from edewit/initial-access-token

Initial access token list and create
This commit is contained in:
mfrances17 2021-03-08 09:05:06 -05:00 committed by GitHub
commit ad1fa1340f
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
17 changed files with 537 additions and 119 deletions

View file

@ -6,6 +6,7 @@ import CreateClientPage from "../support/pages/admin_console/manage/clients/Crea
import ModalUtils from "../support/util/ModalUtils";
import AdvancedTab from "../support/pages/admin_console/manage/clients/AdvancedTab";
import AdminClient from "../support/util/AdminClient";
import InitialAccessTokenTab from "../support/pages/admin_console/manage/clients/InitialAccessTokenTab";
let itemId = "client_crud";
const loginPage = new LoginPage();
@ -78,6 +79,27 @@ describe("Clients test", function () {
listingPage.itemExist(itemId, false);
});
it("Initial access token", () => {
const initialAccessTokenTab = new InitialAccessTokenTab();
listingPage.goToInitialAccessTokenTab();
initialAccessTokenTab.shouldBeEmpty();
initialAccessTokenTab.createNewToken(1, 1).save();
modalUtils.checkModalTitle("Initial access token details").closeModal();
initialAccessTokenTab.shouldNotBeEmpty();
initialAccessTokenTab.getFistId((id) => {
listingPage.deleteItem(id);
modalUtils
.checkModalTitle("Delete initial access token?")
.confirmModal();
masthead.checkNotificationMessage(
"initial access token created successfully"
);
});
});
});
describe("Advanced tab test", () => {

View file

@ -7,6 +7,7 @@ export default class ListingPage {
searchBtn: string;
createBtn: string;
importBtn: string;
initialAccessTokenTab = "initialAccessToken";
constructor() {
this.searchInput = '.pf-c-toolbar__item [type="search"]';
@ -34,15 +35,20 @@ export default class ListingPage {
return this;
}
goToInitialAccessTokenTab() {
cy.getId(this.initialAccessTokenTab).click();
return this;
}
searchItem(searchValue: string, wait = true) {
if (wait) {
const searchUrl = `/admin/realms/master/*${searchValue}*`;
cy.intercept(searchUrl).as("searchClients");
cy.intercept(searchUrl).as("search");
}
cy.get(this.searchInput).type(searchValue);
cy.get(this.searchBtn).click();
if (wait) {
cy.wait(["@searchClients"]);
cy.wait(["@search"]);
}
return this;
}

View file

@ -0,0 +1,38 @@
export default class InitialAccessTokenTab {
private emptyAction = "empty-primary-action";
private expirationInput = "expiration";
private countInput = "count";
private saveBtn = "save";
shouldBeEmpty() {
cy.getId(this.emptyAction).should("exist");
return this;
}
shouldNotBeEmpty() {
cy.getId(this.emptyAction).should("not.exist");
return this;
}
getFistId(callback: (id: string) => void) {
cy.get('tbody > tr > [data-label="ID"]')
.invoke("text")
.then((text) => {
callback(text);
});
return this;
}
createNewToken(expiration: number, count: number) {
cy.getId(this.emptyAction).click();
cy.getId(this.expirationInput).type(`${expiration}`);
cy.getId(this.countInput).type(`${count}`);
return this;
}
save() {
cy.getId(this.saveBtn).click();
return this;
}
}

View file

@ -24,7 +24,7 @@
"@patternfly/react-table": "4.23.0",
"file-saver": "^2.0.2",
"i18next": "^19.6.2",
"keycloak-admin": "1.14.7",
"keycloak-admin": "1.14.10",
"lodash": "^4.17.20",
"moment": "^2.29.1",
"react": "^16.8.5",

View file

@ -41,7 +41,7 @@ export const PageNav: React.FunctionComponent = () => {
type LeftNavProps = { title: string; path: string };
const LeftNav = ({ title, path }: LeftNavProps) => {
const route = routes(() => {}).find(
(route) => route.path.substr("/:realm".length) === path
(route) => route.path.replace(/\/:.+?(\?|(?:(?!\/).)*|$)/g, "") === path
);
if (!route || !hasAccess(route.access)) return <></>;
//remove "/realm-name" from the start of the path

View file

@ -1,5 +1,5 @@
import React, { useEffect, useState } from "react";
import { Link, useHistory, useRouteMatch } from "react-router-dom";
import { Link, useHistory } from "react-router-dom";
import { useTranslation } from "react-i18next";
import {
AlertVariant,
@ -8,6 +8,8 @@ import {
ButtonVariant,
PageSection,
ToolbarItem,
Tab,
TabTitleText,
} from "@patternfly/react-core";
import { ViewHeader } from "../components/view-header/ViewHeader";
@ -18,14 +20,17 @@ import { useAlerts } from "../components/alert/Alerts";
import ClientRepresentation from "keycloak-admin/lib/defs/clientRepresentation";
import { formattedLinkTableCell } from "../components/external-link/FormattedLink";
import { useConfirmDialog } from "../components/confirm-dialog/ConfirmDialog";
import { KeycloakTabs } from "../components/keycloak-tabs/KeycloakTabs";
import { InitialAccessTokenList } from "./initial-access/InitialAccessTokenList";
import { useRealm } from "../context/realm-context/RealmContext";
export const ClientsSection = () => {
const { t } = useTranslation("clients");
const { addAlert } = useAlerts();
const history = useHistory();
const { url } = useRouteMatch();
const adminClient = useAdminClient();
const { realm } = useRealm();
const baseUrl = getBaseUrl(adminClient);
const [key, setKey] = useState(0);
@ -66,7 +71,7 @@ export const ClientsSection = () => {
const ClientDetailLink = (client: ClientRepresentation) => (
<>
<Link key={client.id} to={`${url}/${client.id}/settings`}>
<Link key={client.id} to={`/${realm}/clients/${client.id}/settings`}>
{client.clientId}
{!client.enabled && (
<Badge isRead className="pf-u-ml-sm">
@ -82,81 +87,111 @@ export const ClientsSection = () => {
<ViewHeader
titleKey="clients:clientList"
subKey="clients:clientsExplain"
divider={false}
/>
<PageSection variant="light" className="pf-u-p-0">
<DeleteConfirm />
<KeycloakDataTable
key={key}
loader={loader}
isPaginated
ariaLabelKey="clients:clientList"
searchPlaceholderKey="clients:searchForClient"
toolbarItem={
<>
<ToolbarItem>
<Button onClick={() => history.push(`${url}/add-client`)}>
{t("createClient")}
</Button>
</ToolbarItem>
<ToolbarItem>
<Button
onClick={() => history.push(`${url}/import-client`)}
variant="link"
>
{t("importClient")}
</Button>
</ToolbarItem>
</>
}
actions={[
{
title: t("common:export"),
onRowClick: (client) => {
exportClient(client);
},
},
{
title: t("common:delete"),
onRowClick: (client) => {
setSelectedClient(client);
toggleDeleteDialog();
},
},
]}
columns={[
{
name: "clientId",
displayKey: "clients:clientID",
cellRenderer: ClientDetailLink,
},
{ name: "protocol", displayKey: "common:type" },
{
name: "description",
displayKey: "common:description",
cellFormatters: [emptyFormatter()],
},
{
name: "baseUrl",
displayKey: "clients:homeURL",
cellFormatters: [formattedLinkTableCell(), emptyFormatter()],
cellRenderer: (client) => {
if (client.rootUrl) {
if (
!client.rootUrl.startsWith("http") ||
client.rootUrl.indexOf("$") !== -1
) {
client.rootUrl =
client.rootUrl
.replace("${authBaseUrl}", baseUrl)
.replace("${authAdminUrl}", baseUrl) +
(client.baseUrl ? client.baseUrl.substr(1) : "");
}
}
return client.rootUrl;
},
},
]}
/>
<KeycloakTabs
isBox
inset={{
default: "insetNone",
md: "insetSm",
xl: "inset2xl",
"2xl": "insetLg",
}}
>
<Tab
data-testid="list"
eventKey="list"
title={<TabTitleText>{t("clientsList")}</TabTitleText>}
>
<DeleteConfirm />
<KeycloakDataTable
key={key}
loader={loader}
isPaginated
ariaLabelKey="clients:clientList"
searchPlaceholderKey="clients:searchForClient"
toolbarItem={
<>
<ToolbarItem>
<Button
onClick={() =>
history.push(`/${realm}/clients/add-client`)
}
>
{t("createClient")}
</Button>
</ToolbarItem>
<ToolbarItem>
<Button
onClick={() =>
history.push(`/${realm}/clients/import-client`)
}
variant="link"
>
{t("importClient")}
</Button>
</ToolbarItem>
</>
}
actions={[
{
title: t("common:export"),
onRowClick: (client) => {
exportClient(client);
},
},
{
title: t("common:delete"),
onRowClick: (client) => {
setSelectedClient(client);
toggleDeleteDialog();
},
},
]}
columns={[
{
name: "clientId",
displayKey: "clients:clientID",
cellRenderer: ClientDetailLink,
},
{ name: "protocol", displayKey: "common:type" },
{
name: "description",
displayKey: "common:description",
cellFormatters: [emptyFormatter()],
},
{
name: "baseUrl",
displayKey: "clients:homeURL",
cellFormatters: [formattedLinkTableCell(), emptyFormatter()],
cellRenderer: (client) => {
if (client.rootUrl) {
if (
!client.rootUrl.startsWith("http") ||
client.rootUrl.indexOf("$") !== -1
) {
client.rootUrl =
client.rootUrl
.replace("${authBaseUrl}", baseUrl)
.replace("${authAdminUrl}", baseUrl) +
(client.baseUrl ? client.baseUrl.substr(1) : "");
}
}
return client.rootUrl;
},
},
]}
/>
</Tab>
<Tab
data-testid="initialAccessToken"
eventKey="initialAccessToken"
title={<TabTitleText>{t("initialAccessToken")}</TabTitleText>}
>
<InitialAccessTokenList />
</Tab>
</KeycloakTabs>
</PageSection>
</>
);

View file

@ -4,6 +4,9 @@
"adminURL": "URL to the admin interface of the client. Set this if the client supports the adapter REST API. This REST API allows the auth server to push revocation policies and other administrative tasks. Usually this is set to the base URL of the client.",
"downloadType": "this is information about the download type",
"details": "this is information about the details",
"createToken": "An initial access token can only be used to create clients",
"expiration": "Specifies how long the token should be valid",
"count": "Specifies how many clients can be created using the token",
"client-authenticator-type": "Client Authenticator used for authentication of this client against Keycloak server",
"registration-access-token": "The registration access token provides access for clients to the client registration service.",
"signature-algorithm": "JWA algorithm, which the client needs to use when signing a JWT for authentication. If left blank, the client is allowed to use any algorithm.",

View file

@ -0,0 +1,44 @@
import React from "react";
import { useTranslation } from "react-i18next";
import {
Alert,
AlertVariant,
ClipboardCopy,
Form,
FormGroup,
Modal,
ModalVariant,
} from "@patternfly/react-core";
type AccessTokenDialogProps = {
token: string;
toggleDialog: () => void;
};
export const AccessTokenDialog = ({
token,
toggleDialog,
}: AccessTokenDialogProps) => {
const { t } = useTranslation("clients");
return (
<Modal
title={t("initialAccessTokenDetails")}
isOpen={true}
onClose={toggleDialog}
variant={ModalVariant.medium}
>
<Alert
title={t("copyInitialAccessToken")}
isInline
variant={AlertVariant.warning}
/>
<Form className="pf-u-mt-md">
<FormGroup label={t("initialAccessToken")} fieldId="initialAccessToken">
<ClipboardCopy id="initialAccessToken" isReadOnly>
{token}
</ClipboardCopy>
</FormGroup>
</Form>
</Modal>
);
};

View file

@ -0,0 +1,141 @@
import React, { useState } from "react";
import { useTranslation } from "react-i18next";
import { Controller, useForm } from "react-hook-form";
import {
ActionGroup,
AlertVariant,
Button,
FormGroup,
NumberInput,
PageSection,
} from "@patternfly/react-core";
import ClientInitialAccessPresentation from "keycloak-admin/lib/defs/clientInitialAccessPresentation";
import { FormAccess } from "../../components/form-access/FormAccess";
import { ViewHeader } from "../../components/view-header/ViewHeader";
import { HelpItem } from "../../components/help-enabler/HelpItem";
import { TimeSelector } from "../../components/time-selector/TimeSelector";
import { useHistory } from "react-router-dom";
import { useRealm } from "../../context/realm-context/RealmContext";
import { useAdminClient } from "../../context/auth/AdminClient";
import { useAlerts } from "../../components/alert/Alerts";
import { AccessTokenDialog } from "./AccessTokenDialog";
export const CreateInitialAccessToken = () => {
const { t } = useTranslation("clients");
const { handleSubmit, control } = useForm();
const adminClient = useAdminClient();
const { realm } = useRealm();
const { addAlert } = useAlerts();
const history = useHistory();
const [token, setToken] = useState("");
const save = async (clientToken: ClientInitialAccessPresentation) => {
try {
const access = await adminClient.realms.createClientsInitialAccess(
{ realm },
clientToken
);
setToken(access.token!);
} catch (error) {
addAlert(t("tokenSaveError", { error }), AlertVariant.danger);
}
};
return (
<>
{token && (
<AccessTokenDialog
token={token}
toggleDialog={() => {
setToken("");
history.push(`/${realm}/clients/initialAccessToken`);
}}
/>
)}
<ViewHeader
titleKey="clients:createToken"
subKey="clients-help:createToken"
/>
<PageSection variant="light">
<FormAccess
isHorizontal
role="create-client"
onSubmit={handleSubmit(save)}
>
<FormGroup
label={t("expiration")}
fieldId="expiration"
labelIcon={
<HelpItem
helpText="clients-help:expiration"
forLabel={t("expiration")}
forID="expiration"
/>
}
>
<Controller
name="expiration"
defaultValue=""
control={control}
render={({ onChange, value }) => (
<TimeSelector
data-testid="expiration"
value={value}
onChange={onChange}
/>
)}
/>
</FormGroup>
<FormGroup
label={t("count")}
fieldId="count"
labelIcon={
<HelpItem
helpText="clients-help:count"
forLabel={t("count")}
forID="count"
/>
}
>
<Controller
name="count"
defaultValue={1}
control={control}
render={({ onChange, value }) => (
<NumberInput
data-testid="count"
inputName="count"
inputAriaLabel={t("count")}
min={1}
value={value}
onPlus={() => onChange(value + 1)}
onMinus={() => onChange(value - 1)}
onChange={(event) =>
onChange(Number((event.target as HTMLInputElement).value))
}
/>
)}
/>
</FormGroup>
<ActionGroup>
<Button variant="primary" type="submit" data-testid="save">
{t("common:save")}
</Button>
<Button
data-testid="cancel"
variant="link"
onClick={() =>
history.push(`/${realm}/clients/initialAccessToken`)
}
>
{t("common:cancel")}
</Button>
</ActionGroup>
</FormAccess>
</PageSection>
</>
);
};

View file

@ -0,0 +1,109 @@
import React, { useState } from "react";
import { useHistory, useRouteMatch } from "react-router-dom";
import moment from "moment";
import { useTranslation } from "react-i18next";
import { AlertVariant, Button, ButtonVariant } from "@patternfly/react-core";
import ClientInitialAccessPresentation from "keycloak-admin/lib/defs/clientInitialAccessPresentation";
import { KeycloakDataTable } from "../../components/table-toolbar/KeycloakDataTable";
import { useAdminClient } from "../../context/auth/AdminClient";
import { useRealm } from "../../context/realm-context/RealmContext";
import { ListEmptyState } from "../../components/list-empty-state/ListEmptyState";
import { useConfirmDialog } from "../../components/confirm-dialog/ConfirmDialog";
import { useAlerts } from "../../components/alert/Alerts";
export const InitialAccessTokenList = () => {
const { t } = useTranslation("clients");
const adminClient = useAdminClient();
const { addAlert } = useAlerts();
const { realm } = useRealm();
const history = useHistory();
const { url } = useRouteMatch();
const [token, setToken] = useState<ClientInitialAccessPresentation>();
const loader = async () =>
await adminClient.realms.getClientsInitialAccess({ realm });
const [toggleDeleteDialog, DeleteConfirm] = useConfirmDialog({
titleKey: "clients:tokenDeleteConfirmTitle",
messageKey: t("tokenDeleteConfirm", token),
continueButtonLabel: "common:delete",
continueButtonVariant: ButtonVariant.danger,
onConfirm: async () => {
try {
await adminClient.realms.delClientsInitialAccess({
realm,
id: token!.id!,
});
addAlert(t("tokenDeleteSuccess"), AlertVariant.success);
setToken(undefined);
} catch (error) {
addAlert(t("tokenDeleteError", { error }), AlertVariant.danger);
}
},
});
return (
<>
<DeleteConfirm />
<KeycloakDataTable
key={token?.id}
ariaLabelKey="clients:initialAccessToken"
searchPlaceholderKey="clients:searchInitialAccessToken"
loader={loader}
toolbarItem={
<>
<Button onClick={() => history.push(`${url}/create`)}>
{t("common:create")}
</Button>
</>
}
actions={[
{
title: t("common:delete"),
onRowClick: (token) => {
setToken(token);
toggleDeleteDialog();
},
},
]}
columns={[
{
name: "id",
displayKey: "clients:id",
},
{
name: "timestamp",
displayKey: "clients:timestamp",
cellRenderer: (row) => moment(row.timestamp! * 1000).format("LLL"),
},
{
name: "expiration",
displayKey: "clients:expires",
cellRenderer: (row) =>
moment(row.timestamp! * 1000 + row.expiration! * 1000).fromNow(),
},
{
name: "count",
displayKey: "clients:count",
},
{
name: "remainingCount",
displayKey: "clients:remainingCount",
},
]}
emptyState={
<ListEmptyState
message={t("noTokens")}
instructions={t("noTokensInstructions")}
primaryActionText={t("common:create")}
onPrimaryAction={() => history.push(`${url}/create`)}
/>
}
/>
</>
);
};

View file

@ -52,6 +52,8 @@
"noGeneratedAccessToken": "No generated access token",
"generatedAccessTokenIsDisabled": "Generated access token is disabled when no user is selected",
"clientList": "Clients",
"clientsList": "Clients list",
"initialAccessToken": "Initial access token",
"clientSettings": "Client details",
"selectEncryptionType": "Select Encryption type",
"generalSettings": "General Settings",
@ -71,6 +73,23 @@
"downloadAdapterConfig": "Download adapter config",
"disableConfirm": "If you disable this client, you cannot initiate a login or obtain access tokens.",
"clientDeleteConfirm": "If you delete this client, all associated data will be removed.",
"searchInitialAccessToken": "Search token",
"createToken": "Create initial access token",
"tokenDeleteConfirm": "Are you sure you want to permanently delete the initial access token {{id}}",
"tokenDeleteConfirmTitle": "Delete initial access token?",
"tokenDeleteSuccess": "initial access token created successfully",
"tokenDeleteError": "Could not delete initial access token: '{{error}}'",
"id": "ID",
"timestamp": "Created date",
"expires": "Expires",
"count": "Count",
"remainingCount": "Remaining count",
"expiration": "Expiration",
"noTokens": "No initial access tokens",
"noTokensInstructions": "You haven't created any initial access tokens. Create an initial access token by clicking \"Create\".",
"tokenSaveError": "Could not create initial access token {{error}}",
"initialAccessTokenDetails": "Initial access token details",
"copyInitialAccessToken": "Please copy and paste the initial access token before closing as it can not be retrieved later.",
"clientAuthentication": "Client authentication",
"authentication": "Authentication",
"authenticationFlow": "Authentication flow",

View file

@ -1,7 +1,8 @@
import React from "react";
import React, { isValidElement } from "react";
import { Link } from "react-router-dom";
import useBreadcrumbs from "use-react-router-breadcrumbs";
import useBreadcrumbs, { BreadcrumbData } from "use-react-router-breadcrumbs";
import { useTranslation } from "react-i18next";
import _ from "lodash";
import { Breadcrumb, BreadcrumbItem } from "@patternfly/react-core";
import { useRealm } from "../../context/realm-context/RealmContext";
@ -11,9 +12,15 @@ import { GroupBreadCrumbs } from "./GroupBreadCrumbs";
export const PageBreadCrumbs = () => {
const { t } = useTranslation();
const { realm } = useRealm();
const crumbs = useBreadcrumbs(routes(t), {
excludePaths: ["/", `/${realm}`],
});
const elementText = (crumb: BreadcrumbData) =>
isValidElement(crumb.breadcrumb) && crumb.breadcrumb.props.children;
const crumbs = _.uniqBy(
useBreadcrumbs(routes(t), {
excludePaths: ["/", `/${realm}`],
}),
elementText
);
return (
<>
{crumbs.length > 1 && (

View file

@ -5,13 +5,14 @@ import {
Split,
SplitItem,
TextInput,
TextInputProps,
} from "@patternfly/react-core";
import React, { useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
export type Unit = "seconds" | "minutes" | "hours" | "days";
export type TimeSelectorProps = {
export type TimeSelectorProps = TextInputProps & {
value: number;
units?: Unit[];
onChange: (time: number | string) => void;
@ -21,6 +22,7 @@ export const TimeSelector = ({
value,
units = ["seconds", "minutes", "hours", "days"],
onChange,
...rest
}: TimeSelectorProps) => {
const { t } = useTranslation("common");
@ -73,6 +75,7 @@ export const TimeSelector = ({
<Split hasGutter>
<SplitItem>
<TextInput
{...rest}
type="number"
id={`kc-time-${new Date().getTime()}`}
min="0"

View file

@ -36,6 +36,7 @@ export type ViewHeaderProps = {
lowerDropdownMenuTitle?: any;
isEnabled?: boolean;
onToggle?: (value: boolean) => void;
divider?: boolean;
};
export const ViewHeader = ({
@ -51,6 +52,7 @@ export const ViewHeader = ({
lowerDropdownItems,
isEnabled = true,
onToggle,
divider = true,
}: ViewHeaderProps) => {
const { t } = useTranslation();
const { enabled } = useContext(HelpContext);
@ -161,7 +163,7 @@ export const ViewHeader = ({
/>
)}
</PageSection>
<Divider component={dividerComponent} />
{divider && <Divider component={dividerComponent} />}
</>
);
};

View file

@ -26,7 +26,7 @@ export const UsersInRoleTab = () => {
name: role.name!,
first: first!,
max: max!,
} as any);
});
return usersWithRole;
};

View file

@ -27,6 +27,7 @@ import { UserFederationLdapSettings } from "./user-federation/UserFederationLdap
import { RoleMappingForm } from "./client-scopes/add/RoleMappingForm";
import { RealmRoleTabs } from "./realm-roles/RealmRoleTabs";
import { SearchGroups } from "./groups/SearchGroups";
import { CreateInitialAccessToken } from "./clients/initial-access/CreateInitialAccessToken";
export type RouteDef = BreadcrumbsRoute & {
access: AccessType;
@ -42,12 +43,6 @@ export const routes: RoutesFn = (t: TFunction) => [
breadcrumb: t("realm:createRealm"),
access: "manage-realm",
},
{
path: "/:realm/clients",
component: ClientsSection,
breadcrumb: t("clients:clientList"),
access: "query-clients",
},
{
path: "/:realm/clients/add-client",
component: NewClientForm,
@ -60,6 +55,18 @@ export const routes: RoutesFn = (t: TFunction) => [
breadcrumb: t("clients:importClient"),
access: "manage-clients",
},
{
path: "/:realm/clients/:tab?",
component: ClientsSection,
breadcrumb: t("clients:clientList"),
access: "query-clients",
},
{
path: "/:realm/clients/initialAccessToken/create",
component: CreateInitialAccessToken,
breadcrumb: t("clients:createToken"),
access: "manage-clients",
},
{
path: "/:realm/clients/:clientId/roles/add-role",
component: RealmRoleTabs,
@ -67,17 +74,11 @@ export const routes: RoutesFn = (t: TFunction) => [
access: "manage-realm",
},
{
path: "/:realm/clients/:clientId/roles/:id",
path: "/:realm/clients/:clientId/roles/:id/:tab?",
component: RealmRoleTabs,
breadcrumb: t("roles:roleDetails"),
access: "view-realm",
},
{
path: "/:realm/clients/:clientId/roles/:id/:tab",
component: RealmRoleTabs,
breadcrumb: null,
access: "view-realm",
},
{
path: "/:realm/clients/:clientId/:tab",
component: ClientDetails,
@ -127,17 +128,11 @@ export const routes: RoutesFn = (t: TFunction) => [
access: "manage-realm",
},
{
path: "/:realm/roles/:id",
path: "/:realm/roles/:id/:tab?",
component: RealmRoleTabs,
breadcrumb: t("roles:roleDetails"),
access: "view-realm",
},
{
path: "/:realm/roles/:id/:tab",
component: RealmRoleTabs,
breadcrumb: null,
access: "view-realm",
},
{
path: "/:realm/users",
component: UsersSection,
@ -157,17 +152,11 @@ export const routes: RoutesFn = (t: TFunction) => [
access: "view-realm",
},
{
path: "/:realm/events",
path: "/:realm/events/:tab?",
component: EventsSection,
breadcrumb: t("events:title"),
access: "view-events",
},
{
path: "/:realm/events/:tab",
component: EventsSection,
breadcrumb: null,
access: "view-events",
},
{
path: "/:realm/realm-settings",
component: RealmSettingsSection,

View file

@ -13477,10 +13477,10 @@ junk@^3.1.0:
resolved "https://registry.yarnpkg.com/junk/-/junk-3.1.0.tgz#31499098d902b7e98c5d9b9c80f43457a88abfa1"
integrity sha512-pBxcB3LFc8QVgdggvZWyeys+hnrNWg4OcZIU/1X59k5jQdLBlCsYGRQaz234SqoRLTCgMH00fY0xRJH+F9METQ==
keycloak-admin@1.14.7:
version "1.14.7"
resolved "https://registry.yarnpkg.com/keycloak-admin/-/keycloak-admin-1.14.7.tgz#fe86f296cc6774ec3256b3211d5cd3c76cf79b9c"
integrity sha512-PvDcs8E9VVlhe/1Te/TA5AP8gOkQqIxOmI08Eae3gOvxtt7Ucdd4hxa/ZUX5B7/PvOQH+mFqP5XHVovAZWtmFA==
keycloak-admin@1.14.10:
version "1.14.10"
resolved "https://registry.yarnpkg.com/keycloak-admin/-/keycloak-admin-1.14.10.tgz#e44903826896262b3655303db46795b84a5f9b08"
integrity sha512-WhEA+FkcPikN/Oqh7L0puVkPU1cm3bB+15VOoPdESZknQ9poS0Ohz3Rg1flRfmMdqoMgcy+prigUPtHy6gOAUg==
dependencies:
axios "^0.21.0"
camelize "^1.0.0"