From 48a006e1faa33cf9025a53ba493b13b0656917e1 Mon Sep 17 00:00:00 2001 From: Erik Jan de Wit Date: Mon, 1 Nov 2021 08:49:23 +0100 Subject: [PATCH] put shared logic into common component + fixes (#1415) * put shared logic into common component + fixes fixes: #1398 * removed conflicted name * code review * pr review comments * update admin-client for better retrun types import * use image file instead of encoded data:uri * fix package lock * is typechecked to be string --- .../integration/identity_providers.spec.ts | 6 +- .../identity_providers/CreateProviderPage.ts | 6 - package-lock.json | 2 +- package.json | 2 +- public/discovery-load-indicator.svg | 1 + .../json-file-upload/FileUploadForm.tsx | 159 ++++++++++++++++ .../json-file-upload/JsonFileUpload.tsx | 176 ++---------------- src/identity-providers/add/AddSamlConnect.tsx | 9 +- .../add/OpenIdConnectSettings.tsx | 171 ++++------------- .../add/SamlConnectSettings.tsx | 172 ++++------------- .../component/DiscoveryEndpointField.tsx | 142 ++++++++++++++ 11 files changed, 411 insertions(+), 435 deletions(-) create mode 100644 public/discovery-load-indicator.svg create mode 100644 src/components/json-file-upload/FileUploadForm.tsx create mode 100644 src/identity-providers/component/DiscoveryEndpointField.tsx diff --git a/cypress/integration/identity_providers.spec.ts b/cypress/integration/identity_providers.spec.ts index da6991028a..5fc7875e34 100644 --- a/cypress/integration/identity_providers.spec.ts +++ b/cypress/integration/identity_providers.spec.ts @@ -28,8 +28,8 @@ describe("Identity provider test", () => { const keycloakServer = Cypress.env("KEYCLOAK_SERVER"); const discoveryUrl = `${keycloakServer}/auth/realms/master/.well-known/openid-configuration`; + const samlDiscoveryUrl = `${keycloakServer}/auth/realms/master/protocol/saml/descriptor`; const authorizationUrl = `${keycloakServer}/auth/realms/master/protocol/openid-connect/auth`; - const ssoServiceUrl = `${keycloakServer}/auth/realms/sso`; describe("Identity provider creation", () => { const identityProviderName = "github"; @@ -112,8 +112,8 @@ describe("Identity provider test", () => { createProviderPage .clickCreateDropdown() .clickItem(samlProviderName) - .toggleEntityDescriptor() - .fillSsoServiceUrl(ssoServiceUrl) + .fillDiscoveryUrl(samlDiscoveryUrl) + .shouldBeSuccessful() .clickAdd(); masthead.checkNotificationMessage(createSuccessMsg); }); diff --git a/cypress/support/pages/admin_console/manage/identity_providers/CreateProviderPage.ts b/cypress/support/pages/admin_console/manage/identity_providers/CreateProviderPage.ts index da90100ec3..31fb877043 100644 --- a/cypress/support/pages/admin_console/manage/identity_providers/CreateProviderPage.ts +++ b/cypress/support/pages/admin_console/manage/identity_providers/CreateProviderPage.ts @@ -6,7 +6,6 @@ export default class CreateProviderPage { private clientSecretField = "clientSecret"; private discoveryEndpoint = "discoveryEndpoint"; private authorizationUrl = "authorizationUrl"; - private useEntityDescriptorSwitch = "useEntityDescriptor"; private addButton = "createProvider"; private ssoServiceUrl = "sso-service-url"; @@ -96,9 +95,4 @@ export default class CreateProviderPage { cy.findByTestId(this.authorizationUrl).should("have.value", value); return this; } - - toggleEntityDescriptor() { - cy.findByTestId(this.useEntityDescriptorSwitch).click({ force: true }); - return this; - } } diff --git a/package-lock.json b/package-lock.json index ec3e388b3d..fae7318e51 100644 --- a/package-lock.json +++ b/package-lock.json @@ -7,7 +7,7 @@ "name": "keycloak-admin-ui", "license": "Apache", "dependencies": { - "@keycloak/keycloak-admin-client": "^16.0.0-dev.34", + "@keycloak/keycloak-admin-client": "^16.0.0-dev.37", "@patternfly/patternfly": "^4.144.5", "@patternfly/react-code-editor": "^4.3.85", "@patternfly/react-core": "^4.162.3", diff --git a/package.json b/package.json index dc269e5250..a8bd6cd06c 100644 --- a/package.json +++ b/package.json @@ -23,7 +23,7 @@ "prepare": "husky install" }, "dependencies": { - "@keycloak/keycloak-admin-client": "^16.0.0-dev.34", + "@keycloak/keycloak-admin-client": "^16.0.0-dev.37", "@patternfly/patternfly": "^4.144.5", "@patternfly/react-code-editor": "^4.3.85", "@patternfly/react-core": "^4.162.3", diff --git a/public/discovery-load-indicator.svg b/public/discovery-load-indicator.svg new file mode 100644 index 0000000000..74c7d7eed6 --- /dev/null +++ b/public/discovery-load-indicator.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/src/components/json-file-upload/FileUploadForm.tsx b/src/components/json-file-upload/FileUploadForm.tsx new file mode 100644 index 0000000000..72c92d13cc --- /dev/null +++ b/src/components/json-file-upload/FileUploadForm.tsx @@ -0,0 +1,159 @@ +import React, { useState } from "react"; +import { + FormGroup, + FileUpload, + Modal, + ModalVariant, + Button, + FileUploadProps, +} from "@patternfly/react-core"; +import { useTranslation } from "react-i18next"; +import { CodeEditor, Language } from "@patternfly/react-code-editor"; + +type FileUploadType = { + value: string; + filename: string; + isLoading: boolean; + modal: boolean; +}; + +export type FileUploadEvent = + | React.DragEvent // User dragged/dropped a file + | React.ChangeEvent // User typed in the TextArea + | React.MouseEvent; // User clicked Clear button + +export type FileUploadFormProps = Omit & { + id: string; + extension: string; + onChange: (value: string) => void; + helpText?: string; + unWrap?: boolean; + language?: Language; +}; + +export const FileUploadForm = ({ + id, + onChange, + helpText = "common-help:helpFileUpload", + unWrap = false, + language, + extension, + ...rest +}: FileUploadFormProps) => { + const { t } = useTranslation(); + const defaultUpload: FileUploadType = { + value: "", + filename: "", + isLoading: false, + modal: false, + }; + const [fileUpload, setFileUpload] = useState(defaultUpload); + const removeDialog = () => setFileUpload({ ...fileUpload, modal: false }); + const handleChange = ( + value: string | File, + filename: string, + event: + | React.DragEvent + | React.ChangeEvent + | React.MouseEvent + ): void => { + if ( + event.nativeEvent instanceof MouseEvent && + !(event.nativeEvent instanceof DragEvent) + ) { + setFileUpload({ ...fileUpload, modal: true }); + } else { + setFileUpload({ + ...fileUpload, + value: value.toString(), + filename, + }); + + onChange(value.toString()); + } + }; + + return ( + <> + {fileUpload.modal && ( + { + setFileUpload(defaultUpload); + onChange(""); + }} + > + {t("clear")} + , + , + ]} + > + {t("clearFileExplain")} + + )} + {unWrap && ( + + setFileUpload({ ...fileUpload, isLoading: true }) + } + onReadFinished={() => + setFileUpload({ ...fileUpload, isLoading: false }) + } + isLoading={fileUpload.isLoading} + dropzoneProps={{ + accept: extension, + }} + /> + )} + {!unWrap && ( + + + setFileUpload({ ...fileUpload, isLoading: true }) + } + onReadFinished={() => + setFileUpload({ ...fileUpload, isLoading: false }) + } + isLoading={fileUpload.isLoading} + hideDefaultPreview + > + + handleChange(value ?? "", fileUpload.filename, event) + } + /> + + + )} + + ); +}; diff --git a/src/components/json-file-upload/JsonFileUpload.tsx b/src/components/json-file-upload/JsonFileUpload.tsx index 52eb64a251..60b6c00e77 100644 --- a/src/components/json-file-upload/JsonFileUpload.tsx +++ b/src/components/json-file-upload/JsonFileUpload.tsx @@ -1,167 +1,31 @@ -import React, { useState } from "react"; -import { - FormGroup, - FileUpload, - Modal, - ModalVariant, - Button, - FileUploadProps, -} from "@patternfly/react-core"; -import { useTranslation } from "react-i18next"; -import { CodeEditor, Language } from "@patternfly/react-code-editor"; +import React from "react"; +import { Language } from "@patternfly/react-code-editor"; -type FileUpload = { - value: string; - filename: string; - isLoading: boolean; - modal: boolean; -}; +import { FileUploadForm, FileUploadFormProps } from "./FileUploadForm"; -export type JsonFileUploadEvent = - | React.DragEvent // User dragged/dropped a file - | React.ChangeEvent // User typed in the TextArea - | React.MouseEvent; // User clicked Clear button - -export type JsonFileUploadProps = Omit & { - id: string; +export type JsonFileUploadProps = Omit< + FileUploadFormProps, + "onChange" | "language" | "extension" +> & { onChange: (obj: object) => void; - helpText?: string; - unWrap?: boolean; }; -export const JsonFileUpload = ({ - id, - onChange, - helpText = "common-help:helpFileUpload", - unWrap = false, - ...rest -}: JsonFileUploadProps) => { - const { t } = useTranslation(); - const defaultUpload = { - value: "", - filename: "", - isLoading: false, - modal: false, - }; - const [fileUpload, setFileUpload] = useState(defaultUpload); - const removeDialog = () => setFileUpload({ ...fileUpload, modal: false }); - const handleChange = ( - value: string | File, - filename: string, - event: - | React.DragEvent - | React.ChangeEvent - | React.MouseEvent - ): void => { - if ( - event.nativeEvent instanceof MouseEvent && - !(event.nativeEvent instanceof DragEvent) - ) { - setFileUpload({ ...fileUpload, modal: true }); - } else { - setFileUpload({ - ...fileUpload, - value: value as string, - filename, - }); - - if (value) { - let obj = {}; - try { - obj = JSON.parse(value as string); - } catch (error) { - console.warn("Invalid json, ignoring value using {}"); - } - - onChange(obj); - } +export const JsonFileUpload = ({ onChange, ...props }: JsonFileUploadProps) => { + const handleChange = (value: string) => { + try { + onChange(JSON.parse(value)); + } catch (error) { + onChange({}); + console.warn("Invalid json, ignoring value using {}"); } }; return ( - <> - {fileUpload.modal && ( - { - setFileUpload(defaultUpload); - onChange({}); - }} - > - {t("clear")} - , - , - ]} - > - {t("clearFileExplain")} - - )} - {unWrap && ( - - setFileUpload({ ...fileUpload, isLoading: true }) - } - onReadFinished={() => - setFileUpload({ ...fileUpload, isLoading: false }) - } - isLoading={fileUpload.isLoading} - dropzoneProps={{ - accept: ".json", - }} - /> - )} - {!unWrap && ( - - - setFileUpload({ ...fileUpload, isLoading: true }) - } - onReadFinished={() => - setFileUpload({ ...fileUpload, isLoading: false }) - } - isLoading={fileUpload.isLoading} - dropzoneProps={{ - accept: ".json", - }} - hideDefaultPreview - > - - handleChange(value ?? "", fileUpload.filename, event) - } - /> - - - )} - + ); }; diff --git a/src/identity-providers/add/AddSamlConnect.tsx b/src/identity-providers/add/AddSamlConnect.tsx index dafbe4f421..99212bf148 100644 --- a/src/identity-providers/add/AddSamlConnect.tsx +++ b/src/identity-providers/add/AddSamlConnect.tsx @@ -18,12 +18,16 @@ import { SamlConnectSettings } from "./SamlConnectSettings"; import { useRealm } from "../../context/realm-context/RealmContext"; import { useAlerts } from "../../components/alert/Alerts"; +type DiscoveryIdentityProvider = IdentityProviderRepresentation & { + discoveryEndpoint?: string; +}; + export default function AddSamlConnect() { const { t } = useTranslation("identity-providers"); const history = useHistory(); const id = "saml"; - const form = useForm({ + const form = useForm({ defaultValues: { alias: id }, }); const { @@ -35,7 +39,8 @@ export default function AddSamlConnect() { const { addAlert } = useAlerts(); const { realm } = useRealm(); - const save = async (provider: IdentityProviderRepresentation) => { + const save = async (provider: DiscoveryIdentityProvider) => { + delete provider.discoveryEndpoint; try { await adminClient.identityProviders.create({ ...provider, diff --git a/src/identity-providers/add/OpenIdConnectSettings.tsx b/src/identity-providers/add/OpenIdConnectSettings.tsx index 299df64594..837dcd9d10 100644 --- a/src/identity-providers/add/OpenIdConnectSettings.tsx +++ b/src/identity-providers/add/OpenIdConnectSettings.tsx @@ -1,19 +1,15 @@ -import React, { useEffect, useState } from "react"; +import React from "react"; import { useFormContext } from "react-hook-form"; -import { FormGroup, Switch, TextInput, Title } from "@patternfly/react-core"; +import { FormGroup, Title } from "@patternfly/react-core"; import { HelpItem } from "../../components/help-enabler/HelpItem"; import { useTranslation } from "react-i18next"; import { useAdminClient } from "../../context/auth/AdminClient"; -import type { OIDCConfigurationRepresentation } from "../OIDCConfigurationRepresentation"; import { JsonFileUpload } from "../../components/json-file-upload/JsonFileUpload"; import { useRealm } from "../../context/realm-context/RealmContext"; import { DiscoverySettings } from "./DiscoverySettings"; import { getBaseUrl } from "../../util"; - -type Result = OIDCConfigurationRepresentation & { - error: string; -}; +import { DiscoveryEndpointField } from "../component/DiscoveryEndpointField"; export const OpenIdConnectSettings = () => { const { t } = useTranslation("identity-providers"); @@ -21,40 +17,13 @@ export const OpenIdConnectSettings = () => { const adminClient = useAdminClient(); const { realm } = useRealm(); - const { setValue, register, errors } = useFormContext(); - - const [discovery, setDiscovery] = useState(true); - const [discoveryUrl, setDiscoveryUrl] = useState(""); - const [discovering, setDiscovering] = useState(false); - const [discoveryResult, setDiscoveryResult] = useState(); + const { setValue, errors, setError } = useFormContext(); const setupForm = (result: any) => { Object.keys(result).map((k) => setValue(`config.${k}`, result[k])); }; - useEffect(() => { - if (discovering) { - setDiscovering(!!discoveryUrl); - if (discoveryUrl) - (async () => { - let result; - try { - result = await adminClient.identityProviders.importFromUrl({ - providerId: id, - fromUrl: discoveryUrl, - }); - } catch (error) { - result = { error }; - } - - setDiscoveryResult(result as Result); - setupForm(result); - setDiscovering(false); - })(); - } - }, [discovering]); - - const fileUpload = async (obj: object) => { + const fileUpload = async (obj?: object) => { if (obj) { const formData = new FormData(); formData.append("providerId", id); @@ -75,8 +44,11 @@ export const OpenIdConnectSettings = () => { ); const result = await response.json(); setupForm(result); - } catch (error: any) { - setDiscoveryResult({ error }); + } catch (error) { + setError("discoveryError", { + type: "manual", + message: (error as Error).message, + }); } } }; @@ -86,103 +58,36 @@ export const OpenIdConnectSettings = () => { {t("oidcSettings")} - + + + } + validated={errors.discoveryError ? "error" : "default"} + helperTextInvalid={errors.discoveryError} + > + fileUpload(value)} + /> + } > - - - {discovery && ( - - } - validated={ - discoveryResult?.error || errors.discoveryEndpoint - ? "error" - : !discoveryResult - ? "default" - : "success" - } - helperTextInvalid={ - errors.discoveryEndpoint - ? t("common:required") - : t("noValidMetaDataFound") - } - isRequired - > - setDiscovering(!discovering)} - validated={ - discoveryResult?.error || errors.discoveryEndpoint - ? "error" - : !discoveryResult - ? "default" - : "success" - } - customIconUrl={ - discovering - ? 'data:image/svg+xml;charset=utf8,%3Csvg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" viewBox="0 0 100 100" preserveAspectRatio="xMidYMid"%3E%3Ccircle cx="50" cy="50" fill="none" stroke="%230066cc" stroke-width="10" r="35" stroke-dasharray="164.93361431346415 56.97787143782138"%3E%3CanimateTransform attributeName="transform" type="rotate" repeatCount="indefinite" dur="1s" values="0 50 50;360 50 50" keyTimes="0;1"%3E%3C/animateTransform%3E%3C/circle%3E%3C/svg%3E' - : "" - } - ref={register({ required: true })} - /> - - )} - {!discovery && ( - - } - validated={discoveryResult?.error ? "error" : "default"} - helperTextInvalid={discoveryResult?.error?.toString()} - > - fileUpload(value)} - /> - - )} - {discovery && discoveryResult && !discoveryResult.error && ( - - )} - {!discovery && } + {(readonly) => } + ); }; diff --git a/src/identity-providers/add/SamlConnectSettings.tsx b/src/identity-providers/add/SamlConnectSettings.tsx index 4d70938a08..31914ecc9c 100644 --- a/src/identity-providers/add/SamlConnectSettings.tsx +++ b/src/identity-providers/add/SamlConnectSettings.tsx @@ -1,26 +1,17 @@ -import React, { useEffect, useState } from "react"; +import React from "react"; import { useFormContext } from "react-hook-form"; -import { - FormGroup, - Switch, - TextInput, - Title, - ValidatedOptions, -} from "@patternfly/react-core"; +import { FormGroup, TextInput, Title } from "@patternfly/react-core"; import { HelpItem } from "../../components/help-enabler/HelpItem"; import { useTranslation } from "react-i18next"; import { useAdminClient } from "../../context/auth/AdminClient"; import type IdentityProviderRepresentation from "@keycloak/keycloak-admin-client/lib/defs/identityProviderRepresentation"; -import { JsonFileUpload } from "../../components/json-file-upload/JsonFileUpload"; +import { FileUploadForm } from "../../components/json-file-upload/FileUploadForm"; import { useRealm } from "../../context/realm-context/RealmContext"; import { DescriptorSettings } from "./DescriptorSettings"; import { getBaseUrl } from "../../util"; - -type Result = IdentityProviderRepresentation & { - error: string; -}; +import { DiscoveryEndpointField } from "../component/DiscoveryEndpointField"; export const SamlConnectSettings = () => { const { t } = useTranslation("identity-providers"); @@ -28,16 +19,7 @@ export const SamlConnectSettings = () => { const adminClient = useAdminClient(); const { realm } = useRealm(); - const { setValue, register, errors } = useFormContext(); - - const [descriptor, setDescriptor] = useState(true); - - const [entityUrl, setEntityUrl] = useState(""); - const [descriptorUrl, setDescriptorUrl] = useState(""); - const [discovering, setDiscovering] = useState(false); - const [discoveryResult, setDiscoveryResult] = useState(); - - const defaultEntityUrl = `${getBaseUrl(adminClient)}realms/${realm}`; + const { setValue, register, errors, setError } = useFormContext(); const setupForm = (result: IdentityProviderRepresentation) => { Object.entries(result).map(([key, value]) => @@ -45,38 +27,10 @@ export const SamlConnectSettings = () => { ); }; - useEffect(() => { - if (!discovering) { - return; - } - - setDiscovering(!!entityUrl); - - if (!entityUrl) { - return; - } - - (async () => { - let result; - try { - result = await adminClient.identityProviders.importFromUrl({ - providerId: id, - fromUrl: entityUrl, - }); - } catch (error) { - result = { error }; - } - - setDiscoveryResult(result as Result); - setupForm(result); - setDiscovering(false); - })(); - }, [discovering]); - - const fileUpload = async (obj: object) => { + const fileUpload = async (xml: string) => { const formData = new FormData(); formData.append("providerId", id); - formData.append("file", new Blob([JSON.stringify(obj)])); + formData.append("file", new Blob([xml])); try { const response = await fetch( @@ -93,8 +47,11 @@ export const SamlConnectSettings = () => { ); const result = await response.json(); setupForm(result); - } catch (error: any) { - setDiscoveryResult({ error }); + } catch (error) { + setError("discoveryError", { + type: "manual", + message: (error as Error).message, + }); } }; @@ -120,90 +77,39 @@ export const SamlConnectSettings = () => { name="config.entityId" data-testid="serviceProviderEntityId" id="kc-service-provider-entity-id" - value={entityUrl || defaultEntityUrl} - onChange={setEntityUrl} ref={register()} /> - + + } + validated={errors.discoveryError ? "error" : "default"} + helperTextInvalid={errors.discoveryError} + > + fileUpload(value)} + /> + } > - - - - {descriptor && ( - - } - > - - - )} - {!descriptor && ( - - } - validated={discoveryResult?.error ? "error" : "default"} - helperTextInvalid={discoveryResult?.error.toString()} - > - fileUpload(value)} - /> - - )} - - {descriptor && discoveryResult && !discoveryResult.error && ( - - )} - {!descriptor && } + {(readonly) => } + ); }; diff --git a/src/identity-providers/component/DiscoveryEndpointField.tsx b/src/identity-providers/component/DiscoveryEndpointField.tsx new file mode 100644 index 0000000000..923b39f518 --- /dev/null +++ b/src/identity-providers/component/DiscoveryEndpointField.tsx @@ -0,0 +1,142 @@ +import React, { ReactNode, useEffect, useState } from "react"; +import { useTranslation } from "react-i18next"; +import { useFormContext } from "react-hook-form"; +import { FormGroup, TextInput, Switch } from "@patternfly/react-core"; + +import environment from "../../environment"; +import { HelpItem } from "../../components/help-enabler/HelpItem"; +import { useAdminClient } from "../../context/auth/AdminClient"; + +type DiscoveryEndpointFieldProps = { + id: string; + fileUpload: ReactNode; + children: (readOnly: boolean) => ReactNode; +}; + +export const DiscoveryEndpointField = ({ + id, + fileUpload, + children, +}: DiscoveryEndpointFieldProps) => { + const { t } = useTranslation("identity-providers"); + + const adminClient = useAdminClient(); + + const { setValue, register, errors, setError, watch, clearErrors } = + useFormContext(); + const discoveryUrl = watch("discoveryEndpoint"); + + const [discovery, setDiscovery] = useState(true); + const [discovering, setDiscovering] = useState(false); + const [discoveryResult, setDiscoveryResult] = + useState>(); + + const setupForm = (result: Record) => { + Object.keys(result).map((k) => setValue(`config.${k}`, result[k])); + }; + + useEffect(() => { + if (!discoveryUrl) { + setDiscovering(false); + return; + } + + (async () => { + clearErrors("discoveryError"); + try { + const result = await adminClient.identityProviders.importFromUrl({ + providerId: id, + fromUrl: discoveryUrl, + }); + setupForm(result); + setDiscoveryResult(result); + } catch (error) { + setError("discoveryError", { + type: "manual", + message: (error as Error).message, + }); + } + + setDiscovering(false); + })(); + }, [discovering]); + + return ( + <> + + } + > + + + {discovery && ( + + } + validated={ + errors.discoveryError || errors.discoveryEndpoint + ? "error" + : !discoveryResult + ? "default" + : "success" + } + helperTextInvalid={ + errors.discoveryEndpoint + ? t("common:required") + : t("noValidMetaDataFound") + } + isRequired + > + setDiscovering(true)} + validated={ + errors.discoveryError || errors.discoveryEndpoint + ? "error" + : !discoveryResult + ? "default" + : "success" + } + customIconUrl={ + discovering + ? environment.resourceUrl + "./discovery-load-indicator.svg" + : "" + } + ref={register({ required: true })} + /> + + )} + {!discovery && fileUpload} + {discovery && !errors.discoveryError && children(true)} + {!discovery && children(false)} + + ); +};