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 ModalUtils from "../support/util/ModalUtils";
import AdvancedTab from "../support/pages/admin_console/manage/clients/AdvancedTab"; import AdvancedTab from "../support/pages/admin_console/manage/clients/AdvancedTab";
import AdminClient from "../support/util/AdminClient"; import AdminClient from "../support/util/AdminClient";
import InitialAccessTokenTab from "../support/pages/admin_console/manage/clients/InitialAccessTokenTab";
let itemId = "client_crud"; let itemId = "client_crud";
const loginPage = new LoginPage(); const loginPage = new LoginPage();
@ -78,6 +79,27 @@ describe("Clients test", function () {
listingPage.itemExist(itemId, false); 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", () => { describe("Advanced tab test", () => {

View file

@ -7,6 +7,7 @@ export default class ListingPage {
searchBtn: string; searchBtn: string;
createBtn: string; createBtn: string;
importBtn: string; importBtn: string;
initialAccessTokenTab = "initialAccessToken";
constructor() { constructor() {
this.searchInput = '.pf-c-toolbar__item [type="search"]'; this.searchInput = '.pf-c-toolbar__item [type="search"]';
@ -34,15 +35,20 @@ export default class ListingPage {
return this; return this;
} }
goToInitialAccessTokenTab() {
cy.getId(this.initialAccessTokenTab).click();
return this;
}
searchItem(searchValue: string, wait = true) { searchItem(searchValue: string, wait = true) {
if (wait) { if (wait) {
const searchUrl = `/admin/realms/master/*${searchValue}*`; 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.searchInput).type(searchValue);
cy.get(this.searchBtn).click(); cy.get(this.searchBtn).click();
if (wait) { if (wait) {
cy.wait(["@searchClients"]); cy.wait(["@search"]);
} }
return this; 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", "@patternfly/react-table": "4.23.0",
"file-saver": "^2.0.2", "file-saver": "^2.0.2",
"i18next": "^19.6.2", "i18next": "^19.6.2",
"keycloak-admin": "1.14.7", "keycloak-admin": "1.14.10",
"lodash": "^4.17.20", "lodash": "^4.17.20",
"moment": "^2.29.1", "moment": "^2.29.1",
"react": "^16.8.5", "react": "^16.8.5",

View file

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

View file

@ -1,5 +1,5 @@
import React, { useEffect, useState } from "react"; 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 { useTranslation } from "react-i18next";
import { import {
AlertVariant, AlertVariant,
@ -8,6 +8,8 @@ import {
ButtonVariant, ButtonVariant,
PageSection, PageSection,
ToolbarItem, ToolbarItem,
Tab,
TabTitleText,
} from "@patternfly/react-core"; } from "@patternfly/react-core";
import { ViewHeader } from "../components/view-header/ViewHeader"; 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 ClientRepresentation from "keycloak-admin/lib/defs/clientRepresentation";
import { formattedLinkTableCell } from "../components/external-link/FormattedLink"; import { formattedLinkTableCell } from "../components/external-link/FormattedLink";
import { useConfirmDialog } from "../components/confirm-dialog/ConfirmDialog"; 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 = () => { export const ClientsSection = () => {
const { t } = useTranslation("clients"); const { t } = useTranslation("clients");
const { addAlert } = useAlerts(); const { addAlert } = useAlerts();
const history = useHistory(); const history = useHistory();
const { url } = useRouteMatch();
const adminClient = useAdminClient(); const adminClient = useAdminClient();
const { realm } = useRealm();
const baseUrl = getBaseUrl(adminClient); const baseUrl = getBaseUrl(adminClient);
const [key, setKey] = useState(0); const [key, setKey] = useState(0);
@ -66,7 +71,7 @@ export const ClientsSection = () => {
const ClientDetailLink = (client: ClientRepresentation) => ( 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.clientId}
{!client.enabled && ( {!client.enabled && (
<Badge isRead className="pf-u-ml-sm"> <Badge isRead className="pf-u-ml-sm">
@ -82,81 +87,111 @@ export const ClientsSection = () => {
<ViewHeader <ViewHeader
titleKey="clients:clientList" titleKey="clients:clientList"
subKey="clients:clientsExplain" subKey="clients:clientsExplain"
divider={false}
/> />
<PageSection variant="light" className="pf-u-p-0"> <PageSection variant="light" className="pf-u-p-0">
<DeleteConfirm /> <KeycloakTabs
<KeycloakDataTable isBox
key={key} inset={{
loader={loader} default: "insetNone",
isPaginated md: "insetSm",
ariaLabelKey="clients:clientList" xl: "inset2xl",
searchPlaceholderKey="clients:searchForClient" "2xl": "insetLg",
toolbarItem={ }}
<> >
<ToolbarItem> <Tab
<Button onClick={() => history.push(`${url}/add-client`)}> data-testid="list"
{t("createClient")} eventKey="list"
</Button> title={<TabTitleText>{t("clientsList")}</TabTitleText>}
</ToolbarItem> >
<ToolbarItem> <DeleteConfirm />
<Button <KeycloakDataTable
onClick={() => history.push(`${url}/import-client`)} key={key}
variant="link" loader={loader}
> isPaginated
{t("importClient")} ariaLabelKey="clients:clientList"
</Button> searchPlaceholderKey="clients:searchForClient"
</ToolbarItem> toolbarItem={
</> <>
} <ToolbarItem>
actions={[ <Button
{ onClick={() =>
title: t("common:export"), history.push(`/${realm}/clients/add-client`)
onRowClick: (client) => { }
exportClient(client); >
}, {t("createClient")}
}, </Button>
{ </ToolbarItem>
title: t("common:delete"), <ToolbarItem>
onRowClick: (client) => { <Button
setSelectedClient(client); onClick={() =>
toggleDeleteDialog(); history.push(`/${realm}/clients/import-client`)
}, }
}, variant="link"
]} >
columns={[ {t("importClient")}
{ </Button>
name: "clientId", </ToolbarItem>
displayKey: "clients:clientID", </>
cellRenderer: ClientDetailLink, }
}, actions={[
{ name: "protocol", displayKey: "common:type" }, {
{ title: t("common:export"),
name: "description", onRowClick: (client) => {
displayKey: "common:description", exportClient(client);
cellFormatters: [emptyFormatter()], },
}, },
{ {
name: "baseUrl", title: t("common:delete"),
displayKey: "clients:homeURL", onRowClick: (client) => {
cellFormatters: [formattedLinkTableCell(), emptyFormatter()], setSelectedClient(client);
cellRenderer: (client) => { toggleDeleteDialog();
if (client.rootUrl) { },
if ( },
!client.rootUrl.startsWith("http") || ]}
client.rootUrl.indexOf("$") !== -1 columns={[
) { {
client.rootUrl = name: "clientId",
client.rootUrl displayKey: "clients:clientID",
.replace("${authBaseUrl}", baseUrl) cellRenderer: ClientDetailLink,
.replace("${authAdminUrl}", baseUrl) + },
(client.baseUrl ? client.baseUrl.substr(1) : ""); { name: "protocol", displayKey: "common:type" },
} {
} name: "description",
return client.rootUrl; 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> </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.", "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", "downloadType": "this is information about the download type",
"details": "this is information about the details", "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", "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.", "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.", "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", "noGeneratedAccessToken": "No generated access token",
"generatedAccessTokenIsDisabled": "Generated access token is disabled when no user is selected", "generatedAccessTokenIsDisabled": "Generated access token is disabled when no user is selected",
"clientList": "Clients", "clientList": "Clients",
"clientsList": "Clients list",
"initialAccessToken": "Initial access token",
"clientSettings": "Client details", "clientSettings": "Client details",
"selectEncryptionType": "Select Encryption type", "selectEncryptionType": "Select Encryption type",
"generalSettings": "General Settings", "generalSettings": "General Settings",
@ -71,6 +73,23 @@
"downloadAdapterConfig": "Download adapter config", "downloadAdapterConfig": "Download adapter config",
"disableConfirm": "If you disable this client, you cannot initiate a login or obtain access tokens.", "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.", "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", "clientAuthentication": "Client authentication",
"authentication": "Authentication", "authentication": "Authentication",
"authenticationFlow": "Authentication flow", "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 { 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 { useTranslation } from "react-i18next";
import _ from "lodash";
import { Breadcrumb, BreadcrumbItem } from "@patternfly/react-core"; import { Breadcrumb, BreadcrumbItem } from "@patternfly/react-core";
import { useRealm } from "../../context/realm-context/RealmContext"; import { useRealm } from "../../context/realm-context/RealmContext";
@ -11,9 +12,15 @@ import { GroupBreadCrumbs } from "./GroupBreadCrumbs";
export const PageBreadCrumbs = () => { export const PageBreadCrumbs = () => {
const { t } = useTranslation(); const { t } = useTranslation();
const { realm } = useRealm(); const { realm } = useRealm();
const crumbs = useBreadcrumbs(routes(t), { const elementText = (crumb: BreadcrumbData) =>
excludePaths: ["/", `/${realm}`], isValidElement(crumb.breadcrumb) && crumb.breadcrumb.props.children;
});
const crumbs = _.uniqBy(
useBreadcrumbs(routes(t), {
excludePaths: ["/", `/${realm}`],
}),
elementText
);
return ( return (
<> <>
{crumbs.length > 1 && ( {crumbs.length > 1 && (

View file

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

View file

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

View file

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

View file

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

View file

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