From c2b78c471b8d2f75fabf3f7687a791a86ecaf13f Mon Sep 17 00:00:00 2001 From: Erik Jan de Wit Date: Tue, 4 May 2021 10:11:58 +0200 Subject: [PATCH] added keys tab to client details (#557) --- .gitignore | 1 + cypress/integration/clients_test.spec.ts | 40 +++ .../pages/admin_console/ListingPage.ts | 4 +- .../admin_console/manage/clients/KeysTab.ts | 49 ++++ package.json | 2 +- src/clients/ClientDetails.tsx | 10 + src/clients/help.json | 10 +- src/clients/keys/GenerateKeyDialog.tsx | 118 +++++++++ src/clients/keys/ImportKeyDialog.tsx | 150 +++++++++++ src/clients/keys/Keys.tsx | 249 ++++++++++++++++++ src/clients/keys/StoreSettings.tsx | 78 ++++++ src/clients/messages.json | 24 +- src/common-help.json | 3 +- .../password-input/PasswordInput.tsx | 40 +++ .../ldap/LdapSettingsConnection.tsx | 38 +-- yarn.lock | 8 +- 16 files changed, 787 insertions(+), 37 deletions(-) create mode 100644 cypress/support/pages/admin_console/manage/clients/KeysTab.ts create mode 100644 src/clients/keys/GenerateKeyDialog.tsx create mode 100644 src/clients/keys/ImportKeyDialog.tsx create mode 100644 src/clients/keys/Keys.tsx create mode 100644 src/clients/keys/StoreSettings.tsx create mode 100644 src/components/password-input/PasswordInput.tsx diff --git a/.gitignore b/.gitignore index ebcf824a2b..8a97407d17 100644 --- a/.gitignore +++ b/.gitignore @@ -141,3 +141,4 @@ server/ ########### **/assets **/cypress.env.json +cypress/downloads/ diff --git a/cypress/integration/clients_test.spec.ts b/cypress/integration/clients_test.spec.ts index 5c472a1dad..75ee5e21ee 100644 --- a/cypress/integration/clients_test.spec.ts +++ b/cypress/integration/clients_test.spec.ts @@ -9,6 +9,7 @@ import AdminClient from "../support/util/AdminClient"; import InitialAccessTokenTab from "../support/pages/admin_console/manage/clients/InitialAccessTokenTab"; import { keycloakBefore } from "../support/util/keycloak_before"; import RoleMappingTab from "../support/pages/admin_console/manage/RoleMappingTab"; +import KeysTab from "../support/pages/admin_console/manage/clients/KeysTab"; let itemId = "client_crud"; const loginPage = new LoginPage(); @@ -198,4 +199,43 @@ describe("Clients test", function () { .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" + ); + }); + }); }); diff --git a/cypress/support/pages/admin_console/ListingPage.ts b/cypress/support/pages/admin_console/ListingPage.ts index b5ba133d89..8de151d20e 100644 --- a/cypress/support/pages/admin_console/ListingPage.ts +++ b/cypress/support/pages/admin_console/ListingPage.ts @@ -9,13 +9,13 @@ export default class ListingPage { importBtn: string; constructor() { - this.searchInput = '.pf-c-toolbar__item [type="search"]'; + this.searchInput = '.pf-c-toolbar__item [type="search"]:visible'; this.itemsRows = "table"; this.itemRowDrpDwn = ".pf-c-dropdown > button"; this.exportBtn = '[role="menuitem"]:nth-child(1)'; this.deleteBtn = '[role="menuitem"]:nth-child(2)'; 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 = ".pf-c-page__main .pf-c-toolbar__content-section button.pf-m-primary"; this.importBtn = diff --git a/cypress/support/pages/admin_console/manage/clients/KeysTab.ts b/cypress/support/pages/admin_console/manage/clients/KeysTab.ts new file mode 100644 index 0000000000..af221e64ae --- /dev/null +++ b/cypress/support/pages/admin_console/manage/clients/KeysTab.ts @@ -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; + } +} diff --git a/package.json b/package.json index 91f098887d..84cbc5feeb 100644 --- a/package.json +++ b/package.json @@ -27,7 +27,7 @@ "@patternfly/react-table": "4.26.7", "file-saver": "^2.0.5", "i18next": "^19.6.2", - "keycloak-admin": "1.14.11", + "keycloak-admin": "1.14.15", "lodash": "^4.17.20", "moment": "^2.29.1", "react": "^16.8.5", diff --git a/src/clients/ClientDetails.tsx b/src/clients/ClientDetails.tsx index c855c869f9..a894a84476 100644 --- a/src/clients/ClientDetails.tsx +++ b/src/clients/ClientDetails.tsx @@ -45,6 +45,7 @@ import { ServiceAccount } from "./service-account/ServiceAccount"; import { KeycloakTabs } from "../components/keycloak-tabs/KeycloakTabs"; import { AdvancedTab } from "./AdvancedTab"; import { useRealm } from "../context/realm-context/RealmContext"; +import { Keys } from "./keys/Keys"; type ClientDetailHeaderProps = { onChange: (value: boolean) => void; @@ -290,6 +291,15 @@ export const ClientDetails = () => { reset={() => setupForm(client)} /> + {!client.publicClient && ( + {t("keys")}} + > + save()} /> + + )} {!client.publicClient && ( void; + save: (keyStoreConfig: KeyStoreConfig) => void; +}; + +export const GenerateKeyDialog = ({ + save, + toggleDialog, +}: GenerateKeyDialogProps) => { + const { t } = useTranslation("clients"); + const { register, control, handleSubmit } = useForm(); + + const [openArchiveFormat, setOpenArchiveFormat] = useState(false); + + return ( + { + handleSubmit((config) => { + save(config); + toggleDialog(); + })(); + }} + > + {t("generate")} + , + , + ]} + > + + {t("clients-help:generateKeysDescription")} + +
+ + } + fieldId="archiveFormat" + > + ( + + )} + /> + + + +
+ ); +}; diff --git a/src/clients/keys/ImportKeyDialog.tsx b/src/clients/keys/ImportKeyDialog.tsx new file mode 100644 index 0000000000..adfab62b09 --- /dev/null +++ b/src/clients/keys/ImportKeyDialog.tsx @@ -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(); + + const [openArchiveFormat, setOpenArchiveFormat] = useState(false); + + const format = useWatch({ + control, + name: "keystoreFormat", + defaultValue: "JKS", + }); + return ( + { + handleSubmit((importFile) => { + save(importFile); + toggleDialog(); + })(); + }} + > + {t("import")} + , + , + ]} + > + + {t("clients-help:generateKeysDescription")} + +
+ + } + fieldId="archiveFormat" + > + ( + + )} + /> + + {baseFormats.includes(format) && ( + + )} + + ( + onChange({ value, filename })} + /> + )} + /> + + +
+ ); +}; diff --git a/src/clients/keys/Keys.tsx b/src/clients/keys/Keys.tsx new file mode 100644 index 0000000000..85ee90833e --- /dev/null +++ b/src/clients/keys/Keys.tsx @@ -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(); + const adminClient = useAdminClient(); + const errorHandler = useErrorHandler(); + const { addAlert } = useAlerts(); + + const [keyInfo, setKeyInfo] = useState(); + 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 ( + + {openGenerateKeys && ( + setOpenGenerateKeys(!openGenerateKeys)} + save={generate} + /> + )} + {openImportKeys && ( + setOpenImportKeys(!openImportKeys)} + save={importKey} + /> + )} + + + {t("jwksUrlConfig")} + + + + {t("keysIntro")} + + + + + + } + > + ( + onChange(`${value}`)} + /> + )} + /> + + {useJwksUrl !== "true" && ( + <> + {keyInfo ? ( + + } + > +