Merge pull request #412 from edewit/initial-access-token
Initial access token list and create
This commit is contained in:
commit
ad1fa1340f
17 changed files with 537 additions and 119 deletions
|
@ -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", () => {
|
||||||
|
|
|
@ -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;
|
||||||
}
|
}
|
||||||
|
|
|
@ -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;
|
||||||
|
}
|
||||||
|
}
|
|
@ -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",
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|
|
@ -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.",
|
||||||
|
|
44
src/clients/initial-access/AccessTokenDialog.tsx
Normal file
44
src/clients/initial-access/AccessTokenDialog.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
};
|
141
src/clients/initial-access/CreateInitialAccessToken.tsx
Normal file
141
src/clients/initial-access/CreateInitialAccessToken.tsx
Normal 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>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
109
src/clients/initial-access/InitialAccessTokenList.tsx
Normal file
109
src/clients/initial-access/InitialAccessTokenList.tsx
Normal 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`)}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
|
@ -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",
|
||||||
|
|
|
@ -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 && (
|
||||||
|
|
|
@ -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"
|
||||||
|
|
|
@ -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} />}
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
|
@ -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;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
|
@ -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,
|
||||||
|
|
|
@ -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"
|
||||||
|
|
Loading…
Reference in a new issue