added keys tab to client details (#557)
This commit is contained in:
parent
15677b6bfb
commit
c2b78c471b
16 changed files with 787 additions and 37 deletions
1
.gitignore
vendored
1
.gitignore
vendored
|
@ -141,3 +141,4 @@ server/
|
||||||
###########
|
###########
|
||||||
**/assets
|
**/assets
|
||||||
**/cypress.env.json
|
**/cypress.env.json
|
||||||
|
cypress/downloads/
|
||||||
|
|
|
@ -9,6 +9,7 @@ import AdminClient from "../support/util/AdminClient";
|
||||||
import InitialAccessTokenTab from "../support/pages/admin_console/manage/clients/InitialAccessTokenTab";
|
import InitialAccessTokenTab from "../support/pages/admin_console/manage/clients/InitialAccessTokenTab";
|
||||||
import { keycloakBefore } from "../support/util/keycloak_before";
|
import { keycloakBefore } from "../support/util/keycloak_before";
|
||||||
import RoleMappingTab from "../support/pages/admin_console/manage/RoleMappingTab";
|
import RoleMappingTab from "../support/pages/admin_console/manage/RoleMappingTab";
|
||||||
|
import KeysTab from "../support/pages/admin_console/manage/clients/KeysTab";
|
||||||
|
|
||||||
let itemId = "client_crud";
|
let itemId = "client_crud";
|
||||||
const loginPage = new LoginPage();
|
const loginPage = new LoginPage();
|
||||||
|
@ -198,4 +199,43 @@ describe("Clients test", function () {
|
||||||
.checkRoles(["manage-account", "offline_access", "uma_authorization"]);
|
.checkRoles(["manage-account", "offline_access", "uma_authorization"]);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("Keys tab test", () => {
|
||||||
|
const keysName = "keys-client";
|
||||||
|
beforeEach(() => {
|
||||||
|
keycloakBefore();
|
||||||
|
loginPage.logIn();
|
||||||
|
sidebarPage.goToClients();
|
||||||
|
listingPage.searchItem(keysName).goToItemDetails(keysName);
|
||||||
|
});
|
||||||
|
|
||||||
|
before(() => {
|
||||||
|
new AdminClient().createClient({
|
||||||
|
protocol: "openid-connect",
|
||||||
|
clientId: keysName,
|
||||||
|
publicClient: false,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
after(() => {
|
||||||
|
new AdminClient().deleteClient(keysName);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("change use JWKS Url", () => {
|
||||||
|
const keysTab = new KeysTab();
|
||||||
|
keysTab.goToTab().checkSaveDisabled();
|
||||||
|
keysTab.toggleUseJwksUrl().checkSaveDisabled(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("generate new keys", () => {
|
||||||
|
const keysTab = new KeysTab();
|
||||||
|
keysTab.goToTab().clickGenerate();
|
||||||
|
|
||||||
|
keysTab.fillGenerateModal("keyname", "123", "1234").clickConfirm();
|
||||||
|
|
||||||
|
masthead.checkNotificationMessage(
|
||||||
|
"New key pair and certificate generated successfully"
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
|
@ -9,13 +9,13 @@ export default class ListingPage {
|
||||||
importBtn: string;
|
importBtn: string;
|
||||||
|
|
||||||
constructor() {
|
constructor() {
|
||||||
this.searchInput = '.pf-c-toolbar__item [type="search"]';
|
this.searchInput = '.pf-c-toolbar__item [type="search"]:visible';
|
||||||
this.itemsRows = "table";
|
this.itemsRows = "table";
|
||||||
this.itemRowDrpDwn = ".pf-c-dropdown > button";
|
this.itemRowDrpDwn = ".pf-c-dropdown > button";
|
||||||
this.exportBtn = '[role="menuitem"]:nth-child(1)';
|
this.exportBtn = '[role="menuitem"]:nth-child(1)';
|
||||||
this.deleteBtn = '[role="menuitem"]:nth-child(2)';
|
this.deleteBtn = '[role="menuitem"]:nth-child(2)';
|
||||||
this.searchBtn =
|
this.searchBtn =
|
||||||
".pf-c-page__main .pf-c-toolbar__content-section button.pf-m-control";
|
".pf-c-page__main .pf-c-toolbar__content-section button.pf-m-control:visible";
|
||||||
this.createBtn =
|
this.createBtn =
|
||||||
".pf-c-page__main .pf-c-toolbar__content-section button.pf-m-primary";
|
".pf-c-page__main .pf-c-toolbar__content-section button.pf-m-primary";
|
||||||
this.importBtn =
|
this.importBtn =
|
||||||
|
|
|
@ -0,0 +1,49 @@
|
||||||
|
export default class KeysTab {
|
||||||
|
private tabName = "#pf-tab-keys-keys";
|
||||||
|
private useJwksUrl = "useJwksUrl";
|
||||||
|
private saveKeys = "saveKeys";
|
||||||
|
private generate = "generate";
|
||||||
|
private keyAlias = "keyAlias";
|
||||||
|
private keyPassword = "keyPassword";
|
||||||
|
private storePassword = "storePassword";
|
||||||
|
private confirm = "confirm";
|
||||||
|
|
||||||
|
goToTab() {
|
||||||
|
cy.get(this.tabName).click();
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
checkSaveDisabled(disabled = true) {
|
||||||
|
cy.getId(this.saveKeys).should((!disabled ? "not." : "") + "be.disabled");
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
toggleUseJwksUrl() {
|
||||||
|
cy.getId(this.useJwksUrl).click({ force: true });
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
clickGenerate() {
|
||||||
|
cy.getId(this.generate).click();
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
clickConfirm() {
|
||||||
|
cy.getId(this.confirm).click();
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
fillGenerateModal(
|
||||||
|
keyAlias: string,
|
||||||
|
keyPassword: string,
|
||||||
|
storePassword: string
|
||||||
|
) {
|
||||||
|
cy.getId(this.keyAlias)
|
||||||
|
.type(keyAlias)
|
||||||
|
.getId(this.keyPassword)
|
||||||
|
.type(keyPassword)
|
||||||
|
.getId(this.storePassword)
|
||||||
|
.type(storePassword);
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
}
|
|
@ -27,7 +27,7 @@
|
||||||
"@patternfly/react-table": "4.26.7",
|
"@patternfly/react-table": "4.26.7",
|
||||||
"file-saver": "^2.0.5",
|
"file-saver": "^2.0.5",
|
||||||
"i18next": "^19.6.2",
|
"i18next": "^19.6.2",
|
||||||
"keycloak-admin": "1.14.11",
|
"keycloak-admin": "1.14.15",
|
||||||
"lodash": "^4.17.20",
|
"lodash": "^4.17.20",
|
||||||
"moment": "^2.29.1",
|
"moment": "^2.29.1",
|
||||||
"react": "^16.8.5",
|
"react": "^16.8.5",
|
||||||
|
|
|
@ -45,6 +45,7 @@ import { ServiceAccount } from "./service-account/ServiceAccount";
|
||||||
import { KeycloakTabs } from "../components/keycloak-tabs/KeycloakTabs";
|
import { KeycloakTabs } from "../components/keycloak-tabs/KeycloakTabs";
|
||||||
import { AdvancedTab } from "./AdvancedTab";
|
import { AdvancedTab } from "./AdvancedTab";
|
||||||
import { useRealm } from "../context/realm-context/RealmContext";
|
import { useRealm } from "../context/realm-context/RealmContext";
|
||||||
|
import { Keys } from "./keys/Keys";
|
||||||
|
|
||||||
type ClientDetailHeaderProps = {
|
type ClientDetailHeaderProps = {
|
||||||
onChange: (value: boolean) => void;
|
onChange: (value: boolean) => void;
|
||||||
|
@ -290,6 +291,15 @@ export const ClientDetails = () => {
|
||||||
reset={() => setupForm(client)}
|
reset={() => setupForm(client)}
|
||||||
/>
|
/>
|
||||||
</Tab>
|
</Tab>
|
||||||
|
{!client.publicClient && (
|
||||||
|
<Tab
|
||||||
|
id="keys"
|
||||||
|
eventKey="keys"
|
||||||
|
title={<TabTitleText>{t("keys")}</TabTitleText>}
|
||||||
|
>
|
||||||
|
<Keys clientId={clientId} save={() => save()} />
|
||||||
|
</Tab>
|
||||||
|
)}
|
||||||
{!client.publicClient && (
|
{!client.publicClient && (
|
||||||
<Tab
|
<Tab
|
||||||
id="credentials"
|
id="credentials"
|
||||||
|
|
|
@ -59,6 +59,14 @@
|
||||||
"logoutServiceRedirectBindingURL": "SAML Redirect Binding URL for the client's single logout service. You can leave this blank if you are using a different binding.",
|
"logoutServiceRedirectBindingURL": "SAML Redirect Binding URL for the client's single logout service. You can leave this blank if you are using a different binding.",
|
||||||
"authenticationOverrides": "Override realm authentication flow bindings.",
|
"authenticationOverrides": "Override realm authentication flow bindings.",
|
||||||
"browserFlow": "Select the flow you want to use for browser authentication.",
|
"browserFlow": "Select the flow you want to use for browser authentication.",
|
||||||
"directGrant": "Select the flow you want to use for direct grant authentication."
|
"directGrant": "Select the flow you want to use for direct grant authentication.",
|
||||||
|
"useJwksUrl": "If the switch is on, client public keys will be downloaded from given JWKS URL. This allows great flexibility because new keys will be always re-downloaded again when client generates new keypair. If the switch is off, public key (or certificate) from the Keycloak DB is used, so when client keypair changes, you always need to import new key (or certificate) to the Keycloak DB as well.",
|
||||||
|
"certificate": "Client Certificate for validate JWT issued by client and signed by Client private key from your keystore.",
|
||||||
|
"jwksUrl": "URL where client keys in JWK format are stored. See JWK specification for more details. If you use Keycloak client adapter with \"jwt\" credential, you can use URL of your app with '/k_jwks' suffix. For example 'http://www.myhost.com/myapp/k_jwks' .",
|
||||||
|
"generateKeysDescription": "If you generate new keys, you can download the keystore with the private key automatically and save it on your client's side. Keycloak server will save just the certificate and public key, but not the private key.",
|
||||||
|
"archiveFormat": "Java keystore or PKCS12 archive format.",
|
||||||
|
"keyAlias": "Archive alias for your private key and certificate.",
|
||||||
|
"keyPassword": "Password to access the private key in the archive",
|
||||||
|
"storePassword": "Password to access the archive itself"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
118
src/clients/keys/GenerateKeyDialog.tsx
Normal file
118
src/clients/keys/GenerateKeyDialog.tsx
Normal file
|
@ -0,0 +1,118 @@
|
||||||
|
import React, { useState } from "react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { Controller, useForm } from "react-hook-form";
|
||||||
|
import {
|
||||||
|
Button,
|
||||||
|
ButtonVariant,
|
||||||
|
Form,
|
||||||
|
FormGroup,
|
||||||
|
Modal,
|
||||||
|
ModalVariant,
|
||||||
|
Select,
|
||||||
|
SelectOption,
|
||||||
|
SelectVariant,
|
||||||
|
Text,
|
||||||
|
TextContent,
|
||||||
|
TextInput,
|
||||||
|
} from "@patternfly/react-core";
|
||||||
|
|
||||||
|
import KeyStoreConfig from "keycloak-admin/lib/defs/keystoreConfig";
|
||||||
|
import { HelpItem } from "../../components/help-enabler/HelpItem";
|
||||||
|
import { PasswordInput } from "../../components/password-input/PasswordInput";
|
||||||
|
import { StoreSettings } from "./StoreSettings";
|
||||||
|
|
||||||
|
type GenerateKeyDialogProps = {
|
||||||
|
toggleDialog: () => void;
|
||||||
|
save: (keyStoreConfig: KeyStoreConfig) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const GenerateKeyDialog = ({
|
||||||
|
save,
|
||||||
|
toggleDialog,
|
||||||
|
}: GenerateKeyDialogProps) => {
|
||||||
|
const { t } = useTranslation("clients");
|
||||||
|
const { register, control, handleSubmit } = useForm<KeyStoreConfig>();
|
||||||
|
|
||||||
|
const [openArchiveFormat, setOpenArchiveFormat] = useState(false);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Modal
|
||||||
|
variant={ModalVariant.medium}
|
||||||
|
title={t("generateKeys")}
|
||||||
|
isOpen
|
||||||
|
onClose={toggleDialog}
|
||||||
|
actions={[
|
||||||
|
<Button
|
||||||
|
id="modal-confirm"
|
||||||
|
key="confirm"
|
||||||
|
data-testid="confirm"
|
||||||
|
onClick={() => {
|
||||||
|
handleSubmit((config) => {
|
||||||
|
save(config);
|
||||||
|
toggleDialog();
|
||||||
|
})();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{t("generate")}
|
||||||
|
</Button>,
|
||||||
|
<Button
|
||||||
|
id="modal-cancel"
|
||||||
|
key="cancel"
|
||||||
|
data-testid="cancel"
|
||||||
|
variant={ButtonVariant.link}
|
||||||
|
onClick={() => {
|
||||||
|
toggleDialog();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{t("common:cancel")}
|
||||||
|
</Button>,
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
<TextContent>
|
||||||
|
<Text>{t("clients-help:generateKeysDescription")}</Text>
|
||||||
|
</TextContent>
|
||||||
|
<Form className="pf-u-pt-lg">
|
||||||
|
<FormGroup
|
||||||
|
label={t("archiveFormat")}
|
||||||
|
labelIcon={
|
||||||
|
<HelpItem
|
||||||
|
helpText="clients-help:archiveFormat"
|
||||||
|
forLabel={t("archiveFormat")}
|
||||||
|
forID="archiveFormat"
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
fieldId="archiveFormat"
|
||||||
|
>
|
||||||
|
<Controller
|
||||||
|
name="format"
|
||||||
|
defaultValue="JKS"
|
||||||
|
control={control}
|
||||||
|
render={({ onChange, value }) => (
|
||||||
|
<Select
|
||||||
|
toggleId="archiveFormat"
|
||||||
|
onToggle={() => setOpenArchiveFormat(!openArchiveFormat)}
|
||||||
|
onSelect={(_, value) => {
|
||||||
|
onChange(value as string);
|
||||||
|
setOpenArchiveFormat(false);
|
||||||
|
}}
|
||||||
|
selections={value}
|
||||||
|
variant={SelectVariant.single}
|
||||||
|
aria-label={t("archiveFormat")}
|
||||||
|
isOpen={openArchiveFormat}
|
||||||
|
>
|
||||||
|
{["JKS", "PKCS12"].map((option) => (
|
||||||
|
<SelectOption
|
||||||
|
selected={option === value}
|
||||||
|
key={option}
|
||||||
|
value={option}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</Select>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</FormGroup>
|
||||||
|
<StoreSettings register={register} />
|
||||||
|
</Form>
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
};
|
150
src/clients/keys/ImportKeyDialog.tsx
Normal file
150
src/clients/keys/ImportKeyDialog.tsx
Normal file
|
@ -0,0 +1,150 @@
|
||||||
|
import React, { useState } from "react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { Controller, useForm, useWatch } from "react-hook-form";
|
||||||
|
import {
|
||||||
|
Button,
|
||||||
|
ButtonVariant,
|
||||||
|
FileUpload,
|
||||||
|
Form,
|
||||||
|
FormGroup,
|
||||||
|
Modal,
|
||||||
|
ModalVariant,
|
||||||
|
Select,
|
||||||
|
SelectOption,
|
||||||
|
SelectVariant,
|
||||||
|
Text,
|
||||||
|
TextContent,
|
||||||
|
} from "@patternfly/react-core";
|
||||||
|
import { HelpItem } from "../../components/help-enabler/HelpItem";
|
||||||
|
import { StoreSettings } from "./StoreSettings";
|
||||||
|
|
||||||
|
type ImportKeyDialogProps = {
|
||||||
|
toggleDialog: () => void;
|
||||||
|
save: (importFile: ImportFile) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
const baseFormats = ["JKS", "PKCS12"];
|
||||||
|
|
||||||
|
const formats = baseFormats.concat([
|
||||||
|
"Certificate PEM",
|
||||||
|
"Public Key PEM",
|
||||||
|
"JSON Web Key Set",
|
||||||
|
]);
|
||||||
|
|
||||||
|
export type ImportFile = {
|
||||||
|
keystoreFormat: string;
|
||||||
|
keyAlias: string;
|
||||||
|
storePassword: string;
|
||||||
|
file: { value: File; filename: string };
|
||||||
|
};
|
||||||
|
|
||||||
|
export const ImportKeyDialog = ({
|
||||||
|
save,
|
||||||
|
toggleDialog,
|
||||||
|
}: ImportKeyDialogProps) => {
|
||||||
|
const { t } = useTranslation("clients");
|
||||||
|
const { register, control, handleSubmit } = useForm<ImportFile>();
|
||||||
|
|
||||||
|
const [openArchiveFormat, setOpenArchiveFormat] = useState(false);
|
||||||
|
|
||||||
|
const format = useWatch<string>({
|
||||||
|
control,
|
||||||
|
name: "keystoreFormat",
|
||||||
|
defaultValue: "JKS",
|
||||||
|
});
|
||||||
|
return (
|
||||||
|
<Modal
|
||||||
|
variant={ModalVariant.medium}
|
||||||
|
title={t("generateKeys")}
|
||||||
|
isOpen
|
||||||
|
onClose={toggleDialog}
|
||||||
|
actions={[
|
||||||
|
<Button
|
||||||
|
id="modal-confirm"
|
||||||
|
key="confirm"
|
||||||
|
onClick={() => {
|
||||||
|
handleSubmit((importFile) => {
|
||||||
|
save(importFile);
|
||||||
|
toggleDialog();
|
||||||
|
})();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{t("import")}
|
||||||
|
</Button>,
|
||||||
|
<Button
|
||||||
|
id="modal-cancel"
|
||||||
|
key="cancel"
|
||||||
|
variant={ButtonVariant.link}
|
||||||
|
onClick={() => {
|
||||||
|
toggleDialog();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{t("common:cancel")}
|
||||||
|
</Button>,
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
<TextContent>
|
||||||
|
<Text>{t("clients-help:generateKeysDescription")}</Text>
|
||||||
|
</TextContent>
|
||||||
|
<Form className="pf-u-pt-lg">
|
||||||
|
<FormGroup
|
||||||
|
label={t("archiveFormat")}
|
||||||
|
labelIcon={
|
||||||
|
<HelpItem
|
||||||
|
helpText="clients-help:archiveFormat"
|
||||||
|
forLabel={t("archiveFormat")}
|
||||||
|
forID="archiveFormat"
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
fieldId="archiveFormat"
|
||||||
|
>
|
||||||
|
<Controller
|
||||||
|
name="keystoreFormat"
|
||||||
|
control={control}
|
||||||
|
defaultValue="JKS"
|
||||||
|
render={({ onChange, value }) => (
|
||||||
|
<Select
|
||||||
|
toggleId="archiveFormat"
|
||||||
|
onToggle={() => setOpenArchiveFormat(!openArchiveFormat)}
|
||||||
|
onSelect={(_, value) => {
|
||||||
|
onChange(value as string);
|
||||||
|
setOpenArchiveFormat(false);
|
||||||
|
}}
|
||||||
|
selections={value}
|
||||||
|
variant={SelectVariant.single}
|
||||||
|
aria-label={t("archiveFormat")}
|
||||||
|
isOpen={openArchiveFormat}
|
||||||
|
>
|
||||||
|
{formats.map((option) => (
|
||||||
|
<SelectOption
|
||||||
|
selected={option === value}
|
||||||
|
key={option}
|
||||||
|
value={option}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</Select>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</FormGroup>
|
||||||
|
{baseFormats.includes(format) && (
|
||||||
|
<StoreSettings register={register} hidePassword />
|
||||||
|
)}
|
||||||
|
<FormGroup label={t("importFile")} fieldId="importFile">
|
||||||
|
<Controller
|
||||||
|
name="file"
|
||||||
|
control={control}
|
||||||
|
defaultValue=""
|
||||||
|
render={({ onChange, value }) => (
|
||||||
|
<FileUpload
|
||||||
|
id="importFile"
|
||||||
|
value={value.value}
|
||||||
|
filename={value.filename}
|
||||||
|
onChange={(value, filename) => onChange({ value, filename })}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</FormGroup>
|
||||||
|
</Form>
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
};
|
249
src/clients/keys/Keys.tsx
Normal file
249
src/clients/keys/Keys.tsx
Normal file
|
@ -0,0 +1,249 @@
|
||||||
|
import React, { useEffect, useState } from "react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import FileSaver from "file-saver";
|
||||||
|
import {
|
||||||
|
ActionGroup,
|
||||||
|
AlertVariant,
|
||||||
|
Button,
|
||||||
|
Card,
|
||||||
|
CardBody,
|
||||||
|
CardHeader,
|
||||||
|
CardTitle,
|
||||||
|
FormGroup,
|
||||||
|
PageSection,
|
||||||
|
Switch,
|
||||||
|
Text,
|
||||||
|
TextArea,
|
||||||
|
TextContent,
|
||||||
|
TextInput,
|
||||||
|
} from "@patternfly/react-core";
|
||||||
|
|
||||||
|
import CertificateRepresentation from "keycloak-admin/lib/defs/certificateRepresentation";
|
||||||
|
import KeyStoreConfig from "keycloak-admin/lib/defs/keystoreConfig";
|
||||||
|
import { HelpItem } from "../../components/help-enabler/HelpItem";
|
||||||
|
import { FormAccess } from "../../components/form-access/FormAccess";
|
||||||
|
import { Controller, useFormContext, useWatch } from "react-hook-form";
|
||||||
|
import { ClientForm } from "../ClientDetails";
|
||||||
|
import { GenerateKeyDialog } from "./GenerateKeyDialog";
|
||||||
|
import {
|
||||||
|
asyncStateFetch,
|
||||||
|
useAdminClient,
|
||||||
|
} from "../../context/auth/AdminClient";
|
||||||
|
import { useAlerts } from "../../components/alert/Alerts";
|
||||||
|
import { ImportKeyDialog, ImportFile } from "./ImportKeyDialog";
|
||||||
|
import { useErrorHandler } from "react-error-boundary";
|
||||||
|
|
||||||
|
type KeysProps = {
|
||||||
|
save: () => void;
|
||||||
|
clientId: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
const attr = "jwt.credential";
|
||||||
|
|
||||||
|
export const Keys = ({ clientId, save }: KeysProps) => {
|
||||||
|
const { t } = useTranslation("clients");
|
||||||
|
const {
|
||||||
|
control,
|
||||||
|
register,
|
||||||
|
formState: { isDirty },
|
||||||
|
} = useFormContext<ClientForm>();
|
||||||
|
const adminClient = useAdminClient();
|
||||||
|
const errorHandler = useErrorHandler();
|
||||||
|
const { addAlert } = useAlerts();
|
||||||
|
|
||||||
|
const [keyInfo, setKeyInfo] = useState<CertificateRepresentation>();
|
||||||
|
const [openGenerateKeys, setOpenGenerateKeys] = useState(false);
|
||||||
|
const [openImportKeys, setOpenImportKeys] = useState(false);
|
||||||
|
|
||||||
|
const useJwksUrl = useWatch({
|
||||||
|
control,
|
||||||
|
name: "attributes.use-jwks-url",
|
||||||
|
defaultValue: "false",
|
||||||
|
});
|
||||||
|
useEffect(
|
||||||
|
() =>
|
||||||
|
asyncStateFetch(
|
||||||
|
() => adminClient.clients.getKeyInfo({ id: clientId, attr }),
|
||||||
|
(info) => setKeyInfo(info),
|
||||||
|
errorHandler
|
||||||
|
),
|
||||||
|
[]
|
||||||
|
);
|
||||||
|
|
||||||
|
const generate = async (config: KeyStoreConfig) => {
|
||||||
|
try {
|
||||||
|
const keyStore = await adminClient.clients.generateAndDownloadKey(
|
||||||
|
{
|
||||||
|
id: clientId,
|
||||||
|
attr,
|
||||||
|
},
|
||||||
|
config
|
||||||
|
);
|
||||||
|
FileSaver.saveAs(
|
||||||
|
new Blob([keyStore], { type: "application/octet-stream" }),
|
||||||
|
`keystore.${config.format == "PKCS12" ? "p12" : "jks"}`
|
||||||
|
);
|
||||||
|
addAlert(t("generateSuccess"), AlertVariant.success);
|
||||||
|
} catch (error) {
|
||||||
|
addAlert(
|
||||||
|
t("generateError", {
|
||||||
|
error: error.response?.data?.errorMessage || error,
|
||||||
|
}),
|
||||||
|
AlertVariant.danger
|
||||||
|
);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const importKey = async (importFile: ImportFile) => {
|
||||||
|
try {
|
||||||
|
const formData = new FormData();
|
||||||
|
const { file, ...rest } = importFile;
|
||||||
|
Object.entries(rest).map((entry) =>
|
||||||
|
formData.append(entry[0], entry[1] as string)
|
||||||
|
);
|
||||||
|
formData.append("file", file.value);
|
||||||
|
|
||||||
|
await adminClient.clients.uploadCertificate(
|
||||||
|
{ id: clientId, attr },
|
||||||
|
formData
|
||||||
|
);
|
||||||
|
addAlert(t("importSuccess"), AlertVariant.success);
|
||||||
|
} catch (error) {
|
||||||
|
addAlert(
|
||||||
|
t("importError", {
|
||||||
|
error: error.response?.data?.errorMessage || error,
|
||||||
|
}),
|
||||||
|
AlertVariant.danger
|
||||||
|
);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<PageSection variant="light" className="keycloak__form">
|
||||||
|
{openGenerateKeys && (
|
||||||
|
<GenerateKeyDialog
|
||||||
|
toggleDialog={() => setOpenGenerateKeys(!openGenerateKeys)}
|
||||||
|
save={generate}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{openImportKeys && (
|
||||||
|
<ImportKeyDialog
|
||||||
|
toggleDialog={() => setOpenImportKeys(!openImportKeys)}
|
||||||
|
save={importKey}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<Card isFlat>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>{t("jwksUrlConfig")}</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardBody>
|
||||||
|
<TextContent>
|
||||||
|
<Text>{t("keysIntro")}</Text>
|
||||||
|
</TextContent>
|
||||||
|
</CardBody>
|
||||||
|
<CardBody>
|
||||||
|
<FormAccess role="manage-clients" isHorizontal>
|
||||||
|
<FormGroup
|
||||||
|
hasNoPaddingTop
|
||||||
|
label={t("useJwksUrl")}
|
||||||
|
fieldId="useJwksUrl"
|
||||||
|
labelIcon={
|
||||||
|
<HelpItem
|
||||||
|
helpText="clients-help:useJwksUrl"
|
||||||
|
forLabel={t("useJwksUrl")}
|
||||||
|
forID="useJwksUrl"
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<Controller
|
||||||
|
name="attributes.use-jwks-url"
|
||||||
|
defaultValue="false"
|
||||||
|
control={control}
|
||||||
|
render={({ onChange, value }) => (
|
||||||
|
<Switch
|
||||||
|
data-testid="useJwksUrl"
|
||||||
|
id="useJwksUrl"
|
||||||
|
label={t("common:on")}
|
||||||
|
labelOff={t("common:off")}
|
||||||
|
isChecked={value === "true"}
|
||||||
|
onChange={(value) => onChange(`${value}`)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</FormGroup>
|
||||||
|
{useJwksUrl !== "true" && (
|
||||||
|
<>
|
||||||
|
{keyInfo ? (
|
||||||
|
<FormGroup
|
||||||
|
label={t("certificate")}
|
||||||
|
fieldId="certificate"
|
||||||
|
labelIcon={
|
||||||
|
<HelpItem
|
||||||
|
helpText="clients-help:certificate"
|
||||||
|
forLabel={t("certificate")}
|
||||||
|
forID="certificate"
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<TextArea
|
||||||
|
readOnly
|
||||||
|
rows={5}
|
||||||
|
id="certificate"
|
||||||
|
value={keyInfo.certificate}
|
||||||
|
/>
|
||||||
|
</FormGroup>
|
||||||
|
) : (
|
||||||
|
"No client certificate configured"
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{useJwksUrl === "true" && (
|
||||||
|
<FormGroup
|
||||||
|
label={t("jwksUrl")}
|
||||||
|
fieldId="jwksUrl"
|
||||||
|
labelIcon={
|
||||||
|
<HelpItem
|
||||||
|
helpText="clients-help:jwksUrl"
|
||||||
|
forLabel={t("jwksUrl")}
|
||||||
|
forID="jwksUrl"
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<TextInput
|
||||||
|
type="text"
|
||||||
|
id="jwksUrl"
|
||||||
|
name="attributes.jwks-url"
|
||||||
|
ref={register}
|
||||||
|
/>
|
||||||
|
</FormGroup>
|
||||||
|
)}
|
||||||
|
<ActionGroup>
|
||||||
|
<Button
|
||||||
|
data-testid="saveKeys"
|
||||||
|
onClick={save}
|
||||||
|
isDisabled={!isDirty}
|
||||||
|
>
|
||||||
|
{t("common:save")}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
data-testid="generate"
|
||||||
|
variant="secondary"
|
||||||
|
onClick={() => setOpenGenerateKeys(true)}
|
||||||
|
>
|
||||||
|
{t("generateNewKeys")}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
data-testid="import"
|
||||||
|
variant="secondary"
|
||||||
|
onClick={() => setOpenImportKeys(true)}
|
||||||
|
isDisabled={useJwksUrl === "true"}
|
||||||
|
>
|
||||||
|
{t("import")}
|
||||||
|
</Button>
|
||||||
|
</ActionGroup>
|
||||||
|
</FormAccess>
|
||||||
|
</CardBody>
|
||||||
|
</Card>
|
||||||
|
</PageSection>
|
||||||
|
);
|
||||||
|
};
|
78
src/clients/keys/StoreSettings.tsx
Normal file
78
src/clients/keys/StoreSettings.tsx
Normal file
|
@ -0,0 +1,78 @@
|
||||||
|
import React from "react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { FormGroup, TextInput } from "@patternfly/react-core";
|
||||||
|
|
||||||
|
import { HelpItem } from "../../components/help-enabler/HelpItem";
|
||||||
|
import { PasswordInput } from "../../components/password-input/PasswordInput";
|
||||||
|
|
||||||
|
export const StoreSettings = ({
|
||||||
|
register,
|
||||||
|
hidePassword = false,
|
||||||
|
}: {
|
||||||
|
register: () => void;
|
||||||
|
hidePassword?: boolean;
|
||||||
|
}) => {
|
||||||
|
const { t } = useTranslation("clients");
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<FormGroup
|
||||||
|
label={t("keyAlias")}
|
||||||
|
fieldId="keyAlias"
|
||||||
|
labelIcon={
|
||||||
|
<HelpItem
|
||||||
|
helpText="clients-help:keyAlias"
|
||||||
|
forLabel={t("keyAlias")}
|
||||||
|
forID="keyAlias"
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<TextInput
|
||||||
|
data-testid="keyAlias"
|
||||||
|
type="text"
|
||||||
|
id="keyAlias"
|
||||||
|
name="keyAlias"
|
||||||
|
ref={register}
|
||||||
|
/>
|
||||||
|
</FormGroup>
|
||||||
|
{!hidePassword && (
|
||||||
|
<FormGroup
|
||||||
|
label={t("keyPassword")}
|
||||||
|
fieldId="keyPassword"
|
||||||
|
labelIcon={
|
||||||
|
<HelpItem
|
||||||
|
helpText="clients-help:keyPassword"
|
||||||
|
forLabel={t("keyPassword")}
|
||||||
|
forID="keyPassword"
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<PasswordInput
|
||||||
|
data-testid="keyPassword"
|
||||||
|
id="keyPassword"
|
||||||
|
name="keyPassword"
|
||||||
|
ref={register}
|
||||||
|
/>
|
||||||
|
</FormGroup>
|
||||||
|
)}
|
||||||
|
<FormGroup
|
||||||
|
label={t("storePassword")}
|
||||||
|
fieldId="storePassword"
|
||||||
|
labelIcon={
|
||||||
|
<HelpItem
|
||||||
|
helpText="clients-help:storePassword"
|
||||||
|
forLabel={t("storePassword")}
|
||||||
|
forID="storePassword"
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<PasswordInput
|
||||||
|
data-testid="storePassword"
|
||||||
|
id="storePassword"
|
||||||
|
name="storePassword"
|
||||||
|
ref={register}
|
||||||
|
/>
|
||||||
|
</FormGroup>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
|
@ -13,6 +13,7 @@
|
||||||
"encryptAssertions": "Encrypt assertions",
|
"encryptAssertions": "Encrypt assertions",
|
||||||
"clientSignature": "Client signature required",
|
"clientSignature": "Client signature required",
|
||||||
"downloadAdaptorTitle": "Download adaptor configs",
|
"downloadAdaptorTitle": "Download adaptor configs",
|
||||||
|
"keys": "Keys",
|
||||||
"credentials": "Credentials",
|
"credentials": "Credentials",
|
||||||
"roles": "Roles",
|
"roles": "Roles",
|
||||||
"createRole": "Create role",
|
"createRole": "Create role",
|
||||||
|
@ -198,7 +199,24 @@
|
||||||
"keyForCodeExchange": "Proof Key for Code Exchange Code Challenge Method",
|
"keyForCodeExchange": "Proof Key for Code Exchange Code Challenge Method",
|
||||||
"authenticationOverrides": "Authentication flow overrides",
|
"authenticationOverrides": "Authentication flow overrides",
|
||||||
"browserFlow": "Browser Flow",
|
"browserFlow": "Browser Flow",
|
||||||
"directGrant": "Direct Grant Flow"
|
"directGrant": "Direct Grant Flow",
|
||||||
|
"jwksUrlConfig": "JWKS URL configs",
|
||||||
|
"keysIntro": "If \"Use JWKS URL switch\" is on, you need to fill a valid JWKS URL. After saving, admin can download keys from the JWKS URL or keys will be downloaded automatically by Keycloak server when see the stuff signed by the unknown KID",
|
||||||
|
"useJwksUrl": "Use JWKS URL",
|
||||||
|
"certificate": "Certificate",
|
||||||
|
"jwksUrl": "JWKS URL",
|
||||||
|
"generateNewKeys": "Generate new keys",
|
||||||
|
"generateKeys": "Generate keys?",
|
||||||
|
"generate": "Generate",
|
||||||
|
"archiveFormat": "Archive format",
|
||||||
|
"keyAlias": "Key alias",
|
||||||
|
"keyPassword": "Key password",
|
||||||
|
"storePassword": "Store password",
|
||||||
|
"generateSuccess": "New key pair and certificate generated successfully",
|
||||||
|
"generateError": "Could not generate new key pair and certificate {{error}}",
|
||||||
|
"import": "Import",
|
||||||
|
"importFile": "Import file",
|
||||||
|
"importSuccess": "New certificate imported",
|
||||||
|
"importError": "Could not import certificate {{error}}"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,5 +1,6 @@
|
||||||
{
|
{
|
||||||
"common-help": {
|
"common-help": {
|
||||||
"helpToggleInfo": "This toggle will enable / disable part of the help info in the console. Includes any help text, links and popovers."
|
"helpToggleInfo": "This toggle will enable / disable part of the help info in the console. Includes any help text, links and popovers.",
|
||||||
|
"showPassword": "Show password field in clear text"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
40
src/components/password-input/PasswordInput.tsx
Normal file
40
src/components/password-input/PasswordInput.tsx
Normal file
|
@ -0,0 +1,40 @@
|
||||||
|
import React, { useState } from "react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import {
|
||||||
|
Button,
|
||||||
|
InputGroup,
|
||||||
|
TextInput,
|
||||||
|
TextInputProps,
|
||||||
|
} from "@patternfly/react-core";
|
||||||
|
import { EyeIcon, EyeSlashIcon } from "@patternfly/react-icons";
|
||||||
|
|
||||||
|
const PasswordInputBase = ({ innerRef, ...rest }: TextInputProps) => {
|
||||||
|
const { t } = useTranslation("common-help");
|
||||||
|
const [hidePassword, setHidePassword] = useState(true);
|
||||||
|
return (
|
||||||
|
<InputGroup>
|
||||||
|
<TextInput
|
||||||
|
{...rest}
|
||||||
|
type={hidePassword ? "password" : "text"}
|
||||||
|
ref={innerRef}
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
variant="control"
|
||||||
|
aria-label={t("showPassword")}
|
||||||
|
onClick={() => setHidePassword(!hidePassword)}
|
||||||
|
>
|
||||||
|
{hidePassword ? <EyeIcon /> : <EyeSlashIcon />}
|
||||||
|
</Button>
|
||||||
|
</InputGroup>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const PasswordInput = React.forwardRef(
|
||||||
|
(props: TextInputProps, ref: React.Ref<HTMLInputElement>) => (
|
||||||
|
<PasswordInputBase
|
||||||
|
{...props}
|
||||||
|
innerRef={ref as React.MutableRefObject<any>}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
);
|
||||||
|
PasswordInput.displayName = "PasswordInput";
|
|
@ -1,7 +1,6 @@
|
||||||
import {
|
import {
|
||||||
Button,
|
Button,
|
||||||
FormGroup,
|
FormGroup,
|
||||||
InputGroup,
|
|
||||||
Select,
|
Select,
|
||||||
SelectOption,
|
SelectOption,
|
||||||
SelectVariant,
|
SelectVariant,
|
||||||
|
@ -12,9 +11,9 @@ import { useTranslation } from "react-i18next";
|
||||||
import React, { useState } from "react";
|
import React, { useState } from "react";
|
||||||
import { HelpItem } from "../../components/help-enabler/HelpItem";
|
import { HelpItem } from "../../components/help-enabler/HelpItem";
|
||||||
import { Controller, UseFormMethods } from "react-hook-form";
|
import { Controller, UseFormMethods } from "react-hook-form";
|
||||||
import { EyeIcon, EyeSlashIcon } from "@patternfly/react-icons";
|
|
||||||
import { FormAccess } from "../../components/form-access/FormAccess";
|
import { FormAccess } from "../../components/form-access/FormAccess";
|
||||||
import { WizardSectionHeader } from "../../components/wizard-section-header/WizardSectionHeader";
|
import { WizardSectionHeader } from "../../components/wizard-section-header/WizardSectionHeader";
|
||||||
|
import { PasswordInput } from "../../components/password-input/PasswordInput";
|
||||||
|
|
||||||
export type LdapSettingsConnectionProps = {
|
export type LdapSettingsConnectionProps = {
|
||||||
form: UseFormMethods;
|
form: UseFormMethods;
|
||||||
|
@ -36,7 +35,6 @@ export const LdapSettingsConnection = ({
|
||||||
] = useState(false);
|
] = useState(false);
|
||||||
|
|
||||||
const [isBindTypeDropdownOpen, setIsBindTypeDropdownOpen] = useState(false);
|
const [isBindTypeDropdownOpen, setIsBindTypeDropdownOpen] = useState(false);
|
||||||
const [isPasswordVisible, setIsPasswordVisible] = useState(false);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
|
@ -283,10 +281,8 @@ export const LdapSettingsConnection = ({
|
||||||
fieldId="kc-console-bind-credentials"
|
fieldId="kc-console-bind-credentials"
|
||||||
isRequired
|
isRequired
|
||||||
>
|
>
|
||||||
<InputGroup>
|
<PasswordInput
|
||||||
<TextInput
|
|
||||||
isRequired
|
isRequired
|
||||||
type={isPasswordVisible ? "text" : "password"}
|
|
||||||
id="kc-console-bind-credentials"
|
id="kc-console-bind-credentials"
|
||||||
data-testid="ldap-bind-credentials"
|
data-testid="ldap-bind-credentials"
|
||||||
name="config.bindCredential[0]"
|
name="config.bindCredential[0]"
|
||||||
|
@ -297,14 +293,6 @@ export const LdapSettingsConnection = ({
|
||||||
},
|
},
|
||||||
})}
|
})}
|
||||||
/>
|
/>
|
||||||
<Button
|
|
||||||
variant="control"
|
|
||||||
aria-label="show password button for bind credentials"
|
|
||||||
onClick={() => setIsPasswordVisible(!isPasswordVisible)}
|
|
||||||
>
|
|
||||||
{!isPasswordVisible ? <EyeIcon /> : <EyeSlashIcon />}
|
|
||||||
</Button>
|
|
||||||
</InputGroup>
|
|
||||||
{form.errors.config &&
|
{form.errors.config &&
|
||||||
form.errors.config.bindCredential &&
|
form.errors.config.bindCredential &&
|
||||||
form.errors.config.bindCredential[0] && (
|
form.errors.config.bindCredential[0] && (
|
||||||
|
|
|
@ -15193,10 +15193,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.11:
|
keycloak-admin@1.14.15:
|
||||||
version "1.14.11"
|
version "1.14.15"
|
||||||
resolved "https://registry.yarnpkg.com/keycloak-admin/-/keycloak-admin-1.14.11.tgz#71415395eeb014f5a8675c951b23596ba33b6f35"
|
resolved "https://registry.yarnpkg.com/keycloak-admin/-/keycloak-admin-1.14.15.tgz#07d7433f69b802c94db9b4a9a36e7b968ca34c73"
|
||||||
integrity sha512-s0NNLdJ27oAx52pXsvJgm8O/KDb0dbPsnbc+f4uTaz/Gzh6QN6GJPCgAYJEZj/Re+oOm+OVRHTx8bhhlrom5hA==
|
integrity sha512-P5TPweX6o4KO2RcBNPIbT2uNlzN7eXxAU+jo2Zg6QZRWXlO8xa2mlZbvcBBk0Smu58Z6bXNxFqAs3sqwzEcPIQ==
|
||||||
dependencies:
|
dependencies:
|
||||||
axios "^0.21.0"
|
axios "^0.21.0"
|
||||||
camelize "^1.0.0"
|
camelize "^1.0.0"
|
||||||
|
|
Loading…
Reference in a new issue