diff --git a/src/clients/AdvancedTab.tsx b/src/clients/AdvancedTab.tsx index 379fbf802c..ec93472374 100644 --- a/src/clients/AdvancedTab.tsx +++ b/src/clients/AdvancedTab.tsx @@ -1,107 +1,68 @@ -import { - ActionGroup, - AlertVariant, - Button, - ButtonVariant, - ExpandableSection, - FormGroup, - InputGroup, - PageSection, - Split, - SplitItem, - Text, - ToolbarItem, - Tooltip, -} from "@patternfly/react-core"; +import React from "react"; +import { useTranslation } from "react-i18next"; +import { useFormContext } from "react-hook-form"; +import { AlertVariant, PageSection, Text } from "@patternfly/react-core"; + import type ClientRepresentation from "@keycloak/keycloak-admin-client/lib/defs/clientRepresentation"; import type GlobalRequestResult from "@keycloak/keycloak-admin-client/lib/defs/globalRequestResult"; -import moment from "moment"; -import React, { useEffect, useRef, useState } from "react"; -import { Controller, useFormContext } from "react-hook-form"; -import { Trans, useTranslation } from "react-i18next"; -import { Link } from "react-router-dom"; -import { useAlerts } from "../components/alert/Alerts"; -import { useConfirmDialog } from "../components/confirm-dialog/ConfirmDialog"; -import { FormAccess } from "../components/form-access/FormAccess"; -import { HelpItem } from "../components/help-enabler/HelpItem"; -import { ListEmptyState } from "../components/list-empty-state/ListEmptyState"; + +import type { AddAlertFunction } from "../components/alert/Alerts"; import { ScrollForm } from "../components/scroll-form/ScrollForm"; -import { KeycloakDataTable } from "../components/table-toolbar/KeycloakDataTable"; -import { TimeSelector } from "../components/time-selector/TimeSelector"; -import { KeycloakTextInput } from "../components/keycloak-text-input/KeycloakTextInput"; -import { useAdminClient } from "../context/auth/AdminClient"; -import { useRealm } from "../context/realm-context/RealmContext"; import { convertToFormValues, toUpperCase } from "../util"; -import { AddHostDialog } from "./advanced/AddHostDialog"; import { AdvancedSettings } from "./advanced/AdvancedSettings"; import { AuthenticationOverrides } from "./advanced/AuthenticationOverrides"; import { FineGrainOpenIdConnect } from "./advanced/FineGrainOpenIdConnect"; import { FineGrainSamlEndpointConfig } from "./advanced/FineGrainSamlEndpointConfig"; import { OpenIdConnectCompatibilityModes } from "./advanced/OpenIdConnectCompatibilityModes"; import type { SaveOptions } from "./ClientDetails"; -import { toClient } from "./routes/Client"; +import type { TFunction } from "i18next"; +import { RevocationPanel } from "./advanced/RevocationPanel"; +import { ClusteringPanel } from "./advanced/ClusteringPanel"; -type AdvancedProps = { +export const parseResult = ( + result: GlobalRequestResult, + prefixKey: string, + addAlert: AddAlertFunction, + t: TFunction +) => { + const successCount = result.successRequests?.length || 0; + const failedCount = result.failedRequests?.length || 0; + + if (successCount === 0 && failedCount === 0) { + addAlert(t("noAdminUrlSet"), AlertVariant.warning); + } else if (failedCount > 0) { + addAlert( + t(prefixKey + "Success", { successNodes: result.successRequests }), + AlertVariant.success + ); + addAlert( + t(prefixKey + "Fail", { failedNodes: result.failedRequests }), + AlertVariant.danger + ); + } else { + addAlert( + t(prefixKey + "Success", { successNodes: result.successRequests }), + AlertVariant.success + ); + } +}; + +export type AdvancedProps = { save: (options?: SaveOptions) => void; client: ClientRepresentation; }; -export const AdvancedTab = ({ - save, - client: { - id, +export const AdvancedTab = ({ save, client }: AdvancedProps) => { + const { t } = useTranslation("clients"); + const openIdConnect = "openid-connect"; + + const { setValue, control, reset } = useFormContext(); + const { publicClient, - registeredNodes, attributes, protocol, authenticationFlowBindingOverrides, - adminUrl, - access, - }, -}: AdvancedProps) => { - const { t } = useTranslation("clients"); - const adminClient = useAdminClient(); - const { realm } = useRealm(); - const { addAlert, addError } = useAlerts(); - const revocationFieldName = "notBefore"; - const openIdConnect = "openid-connect"; - - const { getValues, setValue, register, control, reset } = useFormContext(); - const [expanded, setExpanded] = useState(false); - const [selectedNode, setSelectedNode] = useState(""); - const [addNodeOpen, setAddNodeOpen] = useState(false); - const [key, setKey] = useState(0); - const refresh = () => setKey(new Date().getTime()); - const [nodes, setNodes] = useState(registeredNodes || {}); - const pushRevocationButtonRef = useRef(); - - const setNotBefore = (time: number, messageKey: string) => { - setValue(revocationFieldName, time); - save({ messageKey }); - }; - - const parseResult = (result: GlobalRequestResult, prefixKey: string) => { - const successCount = result.successRequests?.length || 0; - const failedCount = result.failedRequests?.length || 0; - - if (successCount === 0 && failedCount === 0) { - addAlert(t("noAdminUrlSet"), AlertVariant.warning); - } else if (failedCount > 0) { - addAlert( - t(prefixKey + "Success", { successNodes: result.successRequests }), - AlertVariant.success - ); - addAlert( - t(prefixKey + "Fail", { failedNodes: result.failedRequests }), - AlertVariant.danger - ); - } else { - addAlert( - t(prefixKey + "Success", { successNodes: result.successRequests }), - AlertVariant.success - ); - } - }; + } = client; const resetFields = (names: string[]) => { const values: { [name: string]: string } = {}; @@ -111,374 +72,131 @@ export const AdvancedTab = ({ reset(values); }; - const push = async () => { - const result = await adminClient.clients.pushRevocation({ - id: id!, - }); - parseResult(result, "notBeforePush"); - }; - - const testCluster = async () => { - const result = await adminClient.clients.testNodesAvailable({ id: id! }); - parseResult(result, "testCluster"); - }; - - const [toggleDeleteNodeConfirm, DeleteNodeConfirm] = useConfirmDialog({ - titleKey: "clients:deleteNode", - messageKey: t("deleteNodeBody", { - node: selectedNode, - }), - continueButtonLabel: "common:delete", - continueButtonVariant: ButtonVariant.danger, - onConfirm: async () => { - try { - await adminClient.clients.deleteClusterNode({ - id: id!, - node: selectedNode, - }); - setNodes({ - ...Object.keys(nodes).reduce((object: any, key) => { - if (key !== selectedNode) { - object[key] = nodes[key]; - } - return object; - }, {}), - }); - refresh(); - addAlert(t("deleteNodeSuccess"), AlertVariant.success); - } catch (error) { - addError("clients:deleteNodeFail", error); - } - }, - }); - - useEffect(() => { - register(revocationFieldName); - }, [register]); - - const formatDate = () => { - const date = getValues(revocationFieldName); - if (date > 0) { - return moment(date * 1000).format("LLL"); - } else { - return t("common:none"); - } - }; - - const sections = [ - t("revocation"), - t("clustering"), - protocol === openIdConnect - ? t("fineGrainOpenIdConnectConfiguration") - : t("fineGrainSamlEndpointConfig"), - t("advancedSettings"), - t("authenticationOverrides"), - ]; - if (protocol === openIdConnect) { - sections.splice(3, 0, t("openIdConnectCompatibilityModes")); - } - if (!publicClient) { - sections.splice(1, 1); - } - if (protocol !== openIdConnect) { - sections.splice(0, 1); - } - return ( - - {protocol === openIdConnect && ( - <> - - - In order to successfully push setup url on - - {t("settings")} - - tab - - - - - } - > - - - - - - - - {!adminUrl && ( - - )} - - - - - )} - {publicClient && ( - <> - - - } - > - - - ( - - )} - /> - - - - - - - - <> - - { - nodes[node] = moment.now() / 1000; - refresh(); - }} - onClose={() => setAddNodeOpen(false)} - /> - - - Promise.resolve( - Object.entries(nodes || {}).map((entry) => { - return { host: entry[0], registration: entry[1] }; - }) + , + }, + { + title: t("clustering"), + isHidden: !publicClient, + panel: , + }, + { + title: t("fineGrainOpenIdConnectConfiguration"), + isHidden: protocol !== openIdConnect, + panel: ( + <> + + {t("clients-help:fineGrainOpenIdConnectConfiguration")} + + + convertToFormValues(attributes, (key, value) => + setValue(`attributes.${key}`, value) ) } - toolbarItem={ - <> - - - - - - - - } - actions={[ - { - title: t("common:delete"), - onRowClick: (node) => { - setSelectedNode(node.host); - toggleDeleteNodeConfirm(); - }, - }, - ]} - columns={[ - { - name: "host", - displayKey: "clients:nodeHost", - }, - { - name: "registration", - displayKey: "clients:lastRegistration", - cellFormatters: [ - (value) => - value - ? moment(parseInt(value.toString()) * 1000).format( - "LLL" - ) - : "", - ], - }, - ]} - emptyState={ - setAddNodeOpen(true)} - /> + /> + + ), + }, + { + title: t("openIdConnectCompatibilityModes"), + isHidden: protocol !== openIdConnect, + panel: ( + <> + + {t("clients-help:openIdConnectCompatibilityModes")} + + save()} + reset={() => + resetFields(["exclude.session.state.from.auth.response"]) } /> - - - - )} - <> - {protocol === openIdConnect && ( - <> - - {t("clients-help:fineGrainOpenIdConnectConfiguration")} - - save()} - reset={() => - convertToFormValues(attributes, (key, value) => - setValue(`attributes.${key}`, value) - ) - } - hasConfigureAccess={access?.configure} - /> - - )} - {protocol !== openIdConnect && ( - <> - - {t("clients-help:fineGrainSamlEndpointConfig")} - - save()} - reset={() => - convertToFormValues(attributes, (key, value) => - setValue(`attributes.${key}`, value) - ) - } - /> - - )} - - {protocol === openIdConnect && ( - <> - - {t("clients-help:openIdConnectCompatibilityModes")} - - save()} - reset={() => - resetFields(["exclude.session.state.from.auth.response"]) - } - hasConfigureAccess={access?.configure} - /> - - )} - <> - - {t("clients-help:advancedSettings" + toUpperCase(protocol || ""))} - - save()} - reset={() => { - resetFields([ - "saml.assertion.lifespan", - "access.token.lifespan", - "tls.client.certificate.bound.access.tokens", - "pkce.code.challenge.method", - ]); - }} - /> - - <> - - {t("clients-help:authenticationOverrides")} - - save()} - reset={() => { - setValue( - "authenticationFlowBindingOverrides.browser", - authenticationFlowBindingOverrides?.browser - ); - setValue( - "authenticationFlowBindingOverrides.direct_grant", - authenticationFlowBindingOverrides?.direct_grant - ); - }} - hasConfigureAccess={access?.configure} - /> - - + + ), + }, + { + title: t("fineGrainSamlEndpointConfig"), + isHidden: protocol === openIdConnect, + panel: ( + <> + + {t("clients-help:fineGrainSamlEndpointConfig")} + + save()} + reset={() => + convertToFormValues(attributes, (key, value) => + setValue(`attributes.${key}`, value) + ) + } + /> + + ), + }, + { + title: t("advancedSettings"), + panel: ( + <> + + {t( + "clients-help:advancedSettings" + + toUpperCase(protocol || "") + )} + + save()} + reset={() => { + resetFields([ + "saml.assertion.lifespan", + "access.token.lifespan", + "tls.client.certificate.bound.access.tokens", + "pkce.code.challenge.method", + ]); + }} + /> + + ), + }, + { + title: t("authenticationOverrides"), + panel: ( + <> + + {t("clients-help:authenticationOverrides")} + + save()} + reset={() => { + setValue( + "authenticationFlowBindingOverrides.browser", + authenticationFlowBindingOverrides?.browser + ); + setValue( + "authenticationFlowBindingOverrides.direct_grant", + authenticationFlowBindingOverrides?.direct_grant + ); + }} + /> + + ), + }, + ]} + borders + /> ); }; diff --git a/src/clients/ClientSettings.tsx b/src/clients/ClientSettings.tsx index 08825f1f70..1821130af2 100644 --- a/src/clients/ClientSettings.tsx +++ b/src/clients/ClientSettings.tsx @@ -1,569 +1,77 @@ -import type ClientRepresentation from "@keycloak/keycloak-admin-client/lib/defs/clientRepresentation"; -import React, { useMemo, useState } from "react"; +import React from "react"; import { useTranslation } from "react-i18next"; -import { - FormGroup, - Form, - Switch, - Select, - SelectVariant, - SelectOption, - ValidatedOptions, -} from "@patternfly/react-core"; -import { Controller, useFormContext } from "react-hook-form"; +import { useFormContext } from "react-hook-form"; +import { Form } from "@patternfly/react-core"; +import type ClientRepresentation from "@keycloak/keycloak-admin-client/lib/defs/clientRepresentation"; import { ScrollForm } from "../components/scroll-form/ScrollForm"; import { ClientDescription } from "./ClientDescription"; import { CapabilityConfig } from "./add/CapabilityConfig"; -import { MultiLineInput } from "../components/multi-line-input/MultiLineInput"; -import { FormAccess } from "../components/form-access/FormAccess"; -import { HelpItem } from "../components/help-enabler/HelpItem"; -import { KeycloakTextInput } from "../components/keycloak-text-input/KeycloakTextInput"; -import { KeycloakTextArea } from "../components/keycloak-text-area/KeycloakTextArea"; -import { useServerInfo } from "../context/server-info/ServerInfoProvider"; -import { SaveReset } from "./advanced/SaveReset"; import { SamlConfig } from "./add/SamlConfig"; import { SamlSignature } from "./add/SamlSignature"; -import environment from "../environment"; -import { useRealm } from "../context/realm-context/RealmContext"; -import { useAccess } from "../context/access/Access"; +import { AccessSettings } from "./add/AccessSettings"; +import { LoginSettingsPanel } from "./add/LoginSettingsPanel"; +import { LogoutPanel } from "./add/LogoutPanel"; -type ClientSettingsProps = { +export type ClientSettingsProps = { client: ClientRepresentation; save: () => void; reset: () => void; }; -export const ClientSettings = ({ - client, - save, - reset, -}: ClientSettingsProps) => { - const { - register, - control, - watch, - formState: { errors }, - } = useFormContext(); +export const ClientSettings = (props: ClientSettingsProps) => { const { t } = useTranslation("clients"); - const { realm } = useRealm(); - const { hasAccess } = useAccess(); - const isManager = hasAccess("manage-clients") || client.access?.configure; - - const [loginThemeOpen, setLoginThemeOpen] = useState(false); - const loginThemes = useServerInfo().themes!["login"]; - const consentRequired = watch("consentRequired"); - const displayOnConsentScreen: string = watch( - "attributes.display.on.consent.screen" - ); + const { watch } = useFormContext(); const protocol = watch("protocol"); - const frontchannelLogout = watch("frontchannelLogout"); - const idpInitiatedSsoUrlName: string = watch( - "attributes.saml_idp_initiated_sso_url_name" - ); - const sections = useMemo(() => { - let result = ["generalSettings", "accessSettings"]; - - if (protocol === "saml") { - return [ - ...result, - "samlCapabilityConfig", - "signatureAndEncryption", - "loginSettings", - ]; - } else if (!client.bearerOnly) { - result = [...result, "capabilityConfig"]; - } else { - return result; - } - - return [...result, "loginSettings", "logoutSettings"]; - }, [protocol, client]); + const { client } = props; return ( t(section))} - > -
- - - {protocol === "saml" ? ( - - ) : ( - !client.bearerOnly && - )} - {protocol === "saml" && } - - {!client.bearerOnly && ( - <> - - } - > - + - - - } - > - - - - } - > - - - {protocol === "saml" && ( - <> - - } - helperText={ - idpInitiatedSsoUrlName !== "" && - t("idpInitiatedSsoUrlNameHelp", { - url: `${environment.authServerUrl}/realms/${realm}/protocol/saml/clients/${idpInitiatedSsoUrlName}`, - }) - } - > - - - - } - > - - - - } - > - - - - )} - {protocol !== "saml" && ( - - } - > - - - )} - - )} - {protocol !== "saml" && ( - - } - > - - - )} - {client.bearerOnly && ( - - )} - - - - } - fieldId="loginTheme" - > - ( - - )} - /> - - - } - fieldId="kc-consent" - hasNoPaddingTop - > - ( - - )} - /> - - - } - fieldId="kc-display-on-client" - hasNoPaddingTop - > - ( - onChange("" + value)} - isDisabled={!consentRequired} - /> - )} - /> - - - } - fieldId="kc-consent-screen-text" - > - - - {protocol === "saml" && ( - - )} - - - {protocol === "openid-connect" && ( - <> - - } - fieldId="frontchannelLogout" - hasNoPaddingTop - > - ( - onChange(value.toString())} - /> - )} - /> - - {frontchannelLogout?.toString() === "true" && ( - - } - helperTextInvalid={ - errors.attributes?.frontchannel?.logout?.url?.message - } - validated={ - errors.attributes?.frontchannel?.logout?.url?.message - ? ValidatedOptions.error - : ValidatedOptions.default - } - > - - ((uri.startsWith("https://") || - uri.startsWith("http://")) && - !uri.includes("*")) || - uri === "" || - t("frontchannelUrlInvalid").toString(), - })} - validated={ - errors.attributes?.frontchannel?.logout?.url?.message - ? ValidatedOptions.error - : ValidatedOptions.default - } - /> - - )} - - )} - - } - helperTextInvalid={ - errors.attributes?.backchannel?.logout?.url?.message - } - validated={ - errors.attributes?.backchannel?.logout?.url?.message - ? ValidatedOptions.error - : ValidatedOptions.default - } - > - - ((uri.startsWith("https://") || uri.startsWith("http://")) && - !uri.includes("*")) || - uri === "" || - t("backchannelUrlInvalid").toString(), - })} - validated={ - errors.attributes?.backchannel?.logout?.url?.message - ? ValidatedOptions.error - : ValidatedOptions.default - } - /> - - - } - fieldId="backchannelLogoutSessionRequired" - hasNoPaddingTop - > - ( - onChange(value.toString())} - /> - )} - /> - - - } - fieldId="backchannelLogoutRevokeOfflineSessions" - hasNoPaddingTop - > - ( - onChange(value.toString())} - /> - )} - /> - - - -
+ + ), + }, + { + title: t("accessSettings"), + panel: , + }, + { + title: t("samlCapabilityConfig"), + isHidden: protocol !== "saml" || client.bearerOnly, + panel: , + }, + { + title: t("signatureAndEncryption"), + isHidden: protocol !== "saml" || client.bearerOnly, + panel: , + }, + { + title: t("capabilityConfig"), + isHidden: protocol !== "openid-connect" || client.bearerOnly, + panel: , + }, + { + title: t("loginSettings"), + isHidden: protocol !== "openid-connect" || client.bearerOnly, + panel: , + }, + { + title: t("logoutSettings"), + isHidden: client.bearerOnly, + panel: , + }, + ]} + /> ); }; diff --git a/src/clients/add/AccessSettings.tsx b/src/clients/add/AccessSettings.tsx new file mode 100644 index 0000000000..e19d668c1e --- /dev/null +++ b/src/clients/add/AccessSettings.tsx @@ -0,0 +1,203 @@ +import React from "react"; +import { useTranslation } from "react-i18next"; +import { useFormContext } from "react-hook-form"; +import { FormGroup } from "@patternfly/react-core"; + +import type ClientRepresentation from "@keycloak/keycloak-admin-client/lib/defs/clientRepresentation"; +import type { ClientSettingsProps } from "../ClientSettings"; +import { MultiLineInput } from "../../components/multi-line-input/MultiLineInput"; +import { FormAccess } from "../../components/form-access/FormAccess"; +import { HelpItem } from "../../components/help-enabler/HelpItem"; +import { KeycloakTextInput } from "../../components/keycloak-text-input/KeycloakTextInput"; +import { SaveReset } from "../advanced/SaveReset"; +import environment from "../../environment"; +import { useRealm } from "../../context/realm-context/RealmContext"; +import { useAccess } from "../../context/access/Access"; + +export const AccessSettings = ({ + client, + save, + reset, +}: ClientSettingsProps) => { + const { t } = useTranslation("clients"); + const { register, watch } = useFormContext(); + const { realm } = useRealm(); + + const { hasAccess } = useAccess(); + const isManager = hasAccess("manage-clients") || client.access?.configure; + + const protocol = watch("protocol"); + const idpInitiatedSsoUrlName: string = watch( + "attributes.saml_idp_initiated_sso_url_name" + ); + + return ( + + {!client.bearerOnly && ( + <> + + } + > + + + + } + > + + + + } + > + + + {protocol === "saml" && ( + <> + + } + helperText={ + idpInitiatedSsoUrlName !== "" && + t("idpInitiatedSsoUrlNameHelp", { + url: `${environment.authServerUrl}/realms/${realm}/protocol/saml/clients/${idpInitiatedSsoUrlName}`, + }) + } + > + + + + } + > + + + + } + > + + + + )} + {protocol !== "saml" && ( + + } + > + + + )} + + )} + {protocol !== "saml" && ( + + } + > + + + )} + {client.bearerOnly && ( + + )} + + ); +}; diff --git a/src/clients/add/LoginSettingsPanel.tsx b/src/clients/add/LoginSettingsPanel.tsx new file mode 100644 index 0000000000..56ee7a15dc --- /dev/null +++ b/src/clients/add/LoginSettingsPanel.tsx @@ -0,0 +1,146 @@ +import React, { useState } from "react"; +import { useTranslation } from "react-i18next"; +import { Controller, useFormContext } from "react-hook-form"; +import { + FormGroup, + Select, + SelectOption, + SelectVariant, + Switch, +} from "@patternfly/react-core"; + +import type ClientRepresentation from "@keycloak/keycloak-admin-client/lib/defs/clientRepresentation"; +import { FormAccess } from "../../components/form-access/FormAccess"; +import { HelpItem } from "../../components/help-enabler/HelpItem"; +import { useServerInfo } from "../../context/server-info/ServerInfoProvider"; +import { KeycloakTextArea } from "../../components/keycloak-text-area/KeycloakTextArea"; + +export const LoginSettingsPanel = ({ access }: { access?: boolean }) => { + const { t } = useTranslation("clients"); + const { register, control, watch } = useFormContext(); + + const [loginThemeOpen, setLoginThemeOpen] = useState(false); + const loginThemes = useServerInfo().themes!["login"]; + const consentRequired = watch("consentRequired"); + const displayOnConsentScreen: string = watch( + "attributes.display.on.consent.screen" + ); + + return ( + + + } + fieldId="loginTheme" + > + ( + + )} + /> + + + } + fieldId="kc-consent" + hasNoPaddingTop + > + ( + + )} + /> + + + } + fieldId="kc-display-on-client" + hasNoPaddingTop + > + ( + onChange("" + value)} + isDisabled={!consentRequired} + /> + )} + /> + + + } + fieldId="kc-consent-screen-text" + > + + + + ); +}; diff --git a/src/clients/add/LogoutPanel.tsx b/src/clients/add/LogoutPanel.tsx new file mode 100644 index 0000000000..c38fd57fea --- /dev/null +++ b/src/clients/add/LogoutPanel.tsx @@ -0,0 +1,203 @@ +import React from "react"; +import { useTranslation } from "react-i18next"; +import { Controller, useFormContext } from "react-hook-form"; +import { FormGroup, Switch, ValidatedOptions } from "@patternfly/react-core"; + +import type ClientRepresentation from "@keycloak/keycloak-admin-client/lib/defs/clientRepresentation"; +import type { ClientSettingsProps } from "../ClientSettings"; +import { FormAccess } from "../../components/form-access/FormAccess"; +import { HelpItem } from "../../components/help-enabler/HelpItem"; +import { KeycloakTextInput } from "../../components/keycloak-text-input/KeycloakTextInput"; +import { useAccess } from "../../context/access/Access"; +import { SaveReset } from "../advanced/SaveReset"; + +export const LogoutPanel = ({ + save, + reset, + client: { access }, +}: ClientSettingsProps) => { + const { t } = useTranslation("clients"); + const { + register, + control, + watch, + formState: { errors }, + } = useFormContext(); + + const { hasAccess } = useAccess(); + const isManager = hasAccess("manage-clients") || access?.configure; + + const protocol = watch("protocol"); + const frontchannelLogout = watch("frontchannelLogout"); + + return ( + + {protocol === "openid-connect" && ( + <> + + } + fieldId="frontchannelLogout" + hasNoPaddingTop + > + ( + onChange(value.toString())} + /> + )} + /> + + {frontchannelLogout?.toString() === "true" && ( + + } + helperTextInvalid={ + errors.attributes?.frontchannel?.logout?.url?.message + } + validated={ + errors.attributes?.frontchannel?.logout?.url?.message + ? ValidatedOptions.error + : ValidatedOptions.default + } + > + + ((uri.startsWith("https://") || + uri.startsWith("http://")) && + !uri.includes("*")) || + uri === "" || + t("frontchannelUrlInvalid").toString(), + })} + validated={ + errors.attributes?.frontchannel?.logout?.url?.message + ? ValidatedOptions.error + : ValidatedOptions.default + } + /> + + )} + + )} + + } + helperTextInvalid={errors.attributes?.backchannel?.logout?.url?.message} + validated={ + errors.attributes?.backchannel?.logout?.url?.message + ? ValidatedOptions.error + : ValidatedOptions.default + } + > + + ((uri.startsWith("https://") || uri.startsWith("http://")) && + !uri.includes("*")) || + uri === "" || + t("backchannelUrlInvalid").toString(), + })} + validated={ + errors.attributes?.backchannel?.logout?.url?.message + ? ValidatedOptions.error + : ValidatedOptions.default + } + /> + + + } + fieldId="backchannelLogoutSessionRequired" + hasNoPaddingTop + > + ( + onChange(value.toString())} + /> + )} + /> + + + } + fieldId="backchannelLogoutRevokeOfflineSessions" + hasNoPaddingTop + > + ( + onChange(value.toString())} + /> + )} + /> + + + + ); +}; diff --git a/src/clients/advanced/ClusteringPanel.tsx b/src/clients/advanced/ClusteringPanel.tsx new file mode 100644 index 0000000000..788c90f344 --- /dev/null +++ b/src/clients/advanced/ClusteringPanel.tsx @@ -0,0 +1,200 @@ +import React, { useState } from "react"; +import { useTranslation } from "react-i18next"; +import { Controller, useFormContext } from "react-hook-form"; +import { + AlertVariant, + Button, + ButtonVariant, + ExpandableSection, + FormGroup, + Split, + SplitItem, + ToolbarItem, +} from "@patternfly/react-core"; +import moment from "moment"; + +import { AdvancedProps, parseResult } from "../AdvancedTab"; +import { useAlerts } from "../../components/alert/Alerts"; +import { useConfirmDialog } from "../../components/confirm-dialog/ConfirmDialog"; +import { FormAccess } from "../../components/form-access/FormAccess"; +import { HelpItem } from "../../components/help-enabler/HelpItem"; +import { ListEmptyState } from "../../components/list-empty-state/ListEmptyState"; +import { KeycloakDataTable } from "../../components/table-toolbar/KeycloakDataTable"; +import { TimeSelector } from "../../components/time-selector/TimeSelector"; +import { useAdminClient } from "../../context/auth/AdminClient"; +import { AddHostDialog } from ".././advanced/AddHostDialog"; + +export const ClusteringPanel = ({ + save, + client: { id, registeredNodes, access }, +}: AdvancedProps) => { + const { t } = useTranslation("clients"); + const { control } = useFormContext(); + const adminClient = useAdminClient(); + const { addAlert, addError } = useAlerts(); + + const [nodes, setNodes] = useState(registeredNodes || {}); + const [expanded, setExpanded] = useState(false); + const [selectedNode, setSelectedNode] = useState(""); + const [addNodeOpen, setAddNodeOpen] = useState(false); + const [key, setKey] = useState(0); + const refresh = () => setKey(new Date().getTime()); + + const testCluster = async () => { + const result = await adminClient.clients.testNodesAvailable({ id: id! }); + parseResult(result, "testCluster", addAlert, t); + }; + + const [toggleDeleteNodeConfirm, DeleteNodeConfirm] = useConfirmDialog({ + titleKey: "clients:deleteNode", + messageKey: t("deleteNodeBody", { + node: selectedNode, + }), + continueButtonLabel: "common:delete", + continueButtonVariant: ButtonVariant.danger, + onConfirm: async () => { + try { + await adminClient.clients.deleteClusterNode({ + id: id!, + node: selectedNode, + }); + setNodes({ + ...Object.keys(nodes).reduce((object: any, key) => { + if (key !== selectedNode) { + object[key] = nodes[key]; + } + return object; + }, {}), + }); + refresh(); + addAlert(t("deleteNodeSuccess"), AlertVariant.success); + } catch (error) { + addError("clients:deleteNodeFail", error); + } + }, + }); + + return ( + <> + + + } + > + + + ( + + )} + /> + + + + + + + + <> + + { + nodes[node] = moment.now() / 1000; + refresh(); + }} + onClose={() => setAddNodeOpen(false)} + /> + + + Promise.resolve( + Object.entries(nodes || {}).map((entry) => { + return { host: entry[0], registration: entry[1] }; + }) + ) + } + toolbarItem={ + <> + + + + + + + + } + actions={[ + { + title: t("common:delete"), + onRowClick: (node) => { + setSelectedNode(node.host); + toggleDeleteNodeConfirm(); + }, + }, + ]} + columns={[ + { + name: "host", + displayKey: "clients:nodeHost", + }, + { + name: "registration", + displayKey: "clients:lastRegistration", + cellFormatters: [ + (value) => + value + ? moment(parseInt(value.toString()) * 1000).format("LLL") + : "", + ], + }, + ]} + emptyState={ + setAddNodeOpen(true)} + /> + } + /> + + + + ); +}; diff --git a/src/clients/advanced/RevocationPanel.tsx b/src/clients/advanced/RevocationPanel.tsx new file mode 100644 index 0000000000..39a2d40a01 --- /dev/null +++ b/src/clients/advanced/RevocationPanel.tsx @@ -0,0 +1,137 @@ +import React, { useEffect, useRef } from "react"; +import { Link } from "react-router-dom"; +import { Trans, useTranslation } from "react-i18next"; +import { useFormContext } from "react-hook-form"; +import { + FormGroup, + InputGroup, + Button, + ActionGroup, + Tooltip, + Text, +} from "@patternfly/react-core"; +import moment from "moment"; + +import { AdvancedProps, parseResult } from "../AdvancedTab"; +import { useAlerts } from "../../components/alert/Alerts"; +import { FormAccess } from "../../components/form-access/FormAccess"; +import { HelpItem } from "../../components/help-enabler/HelpItem"; +import { KeycloakTextInput } from "../../components/keycloak-text-input/KeycloakTextInput"; +import { useAdminClient } from "../../context/auth/AdminClient"; +import { useRealm } from "../../context/realm-context/RealmContext"; +import { toClient } from "../routes/Client"; + +export const RevocationPanel = ({ + save, + client: { id, adminUrl, access }, +}: AdvancedProps) => { + const revocationFieldName = "notBefore"; + const pushRevocationButtonRef = useRef(); + + const { t } = useTranslation("clients"); + const adminClient = useAdminClient(); + const { realm } = useRealm(); + const { addAlert } = useAlerts(); + + const { getValues, setValue, register } = useFormContext(); + + const setNotBefore = (time: number, messageKey: string) => { + setValue(revocationFieldName, time); + save({ messageKey }); + }; + + useEffect(() => { + register(revocationFieldName); + }, [register]); + + const formatDate = () => { + const date = getValues(revocationFieldName); + if (date > 0) { + return moment(date * 1000).format("LLL"); + } else { + return t("common:none"); + } + }; + + const push = async () => { + const result = await adminClient.clients.pushRevocation({ + id: id!, + }); + parseResult(result, "notBeforePush", addAlert, t); + }; + + return ( + <> + + + In order to successfully push setup url on + + {t("settings")} + + tab + + + + + } + > + + + + + + + + {!adminUrl && ( + + )} + + + + + ); +}; diff --git a/src/components/alert/Alerts.tsx b/src/components/alert/Alerts.tsx index 393d97713d..5fa06a12e7 100644 --- a/src/components/alert/Alerts.tsx +++ b/src/components/alert/Alerts.tsx @@ -8,14 +8,17 @@ import useRequiredContext from "../../utils/useRequiredContext"; import useSetTimeout from "../../utils/useSetTimeout"; import { AlertPanel, AlertType } from "./AlertPanel"; -type AlertProps = { - addAlert: ( - message: string, - variant?: AlertVariant, - description?: string - ) => void; +export type AddAlertFunction = ( + message: string, + variant?: AlertVariant, + description?: string +) => void; - addError: (message: string, error: any) => void; +export type AddErrorFunction = (message: string, error: any) => void; + +type AlertProps = { + addAlert: AddAlertFunction; + addError: AddErrorFunction; }; export const AlertContext = createContext(undefined); diff --git a/src/components/scroll-form/ScrollForm.tsx b/src/components/scroll-form/ScrollForm.tsx index 34897c289a..347ab0421f 100644 --- a/src/components/scroll-form/ScrollForm.tsx +++ b/src/components/scroll-form/ScrollForm.tsx @@ -1,4 +1,4 @@ -import React, { Children, Fragment, FunctionComponent } from "react"; +import React, { Fragment, FunctionComponent, ReactNode, useMemo } from "react"; import { useTranslation } from "react-i18next"; import { Grid, @@ -15,8 +15,14 @@ import { FormPanel } from "./FormPanel"; import "./scroll-form.css"; +type ScrollSection = { + title: string; + panel: ReactNode; + isHidden?: boolean; +}; + type ScrollFormProps = GridProps & { - sections: string[]; + sections: ScrollSection[]; borders?: boolean; }; @@ -27,33 +33,34 @@ const spacesToHyphens = (string: string): string => { export const ScrollForm: FunctionComponent = ({ sections, borders = false, - children, ...rest }) => { const { t } = useTranslation("common"); - const nodes = Children.toArray(children); + const shownSections = useMemo( + () => sections.filter(({ isHidden }) => !isHidden), + [sections] + ); return ( - {sections.map((cat, index) => { - const scrollId = spacesToHyphens(cat.toLowerCase()); + {shownSections.map(({ title, panel }) => { + const scrollId = spacesToHyphens(title.toLowerCase()); return ( - - {!borders && ( - - {nodes[index]} - - )} - {borders && ( + + {borders ? ( - {nodes[index]} + {panel} + ) : ( + + {panel} + )} ); @@ -69,17 +76,17 @@ export const ScrollForm: FunctionComponent = ({ label={t("jumpToSection")} offset={100} > - {sections.map((cat) => { - const scrollId = spacesToHyphens(cat.toLowerCase()); + {shownSections.map(({ title }) => { + const scrollId = spacesToHyphens(title.toLowerCase()); return ( // note that JumpLinks currently does not work with spaces in the href - {cat} + {title} ); })} diff --git a/src/identity-providers/add/DetailSettings.tsx b/src/identity-providers/add/DetailSettings.tsx index cce41ca7da..cd633bc334 100644 --- a/src/identity-providers/add/DetailSettings.tsx +++ b/src/identity-providers/add/DetailSettings.tsx @@ -238,8 +238,6 @@ export default function DetailSettings() { return ; } - const sections = [t("generalSettings"), t("advancedSettings")]; - const isOIDC = provider.providerId!.includes("oidc"); const isSAML = provider.providerId!.includes("saml"); @@ -268,14 +266,81 @@ export default function DetailSettings() { return components; }; - if (isOIDC) { - sections.splice(1, 0, t("oidcSettings")); - } + const sections = [ + { + title: t("generalSettings"), + panel: ( + + {!isOIDC && !isSAML && } + {isOIDC && } + {isSAML && } + + ), + }, + { + title: t("oidcSettings"), + isHidden: !isOIDC, + panel: ( + <> + +
+ + + + + + ), + }, + { + title: t("samlSettings"), + isHidden: !isSAML, + panel: , + }, + { + title: t("reqAuthnConstraints"), + isHidden: !isSAML, + panel: ( + + + + ), + }, + { + title: t("advancedSettings"), + panel: ( + + - if (isSAML) { - sections.splice(1, 0, t("samlSettings")); - sections.splice(2, 0, t("reqAuthnConstraints")); - } + + + + + + ), + }, + ]; return ( @@ -302,61 +367,7 @@ export default function DetailSettings() { eventKey="settings" title={{t("common:settings")}} > - - - {!isOIDC && !isSAML && ( - - )} - {isOIDC && } - {isSAML && } - - {isOIDC && ( - <> - -
- - - - - - )} - {isSAML && } - {isSAML && ( - - - - )} - - - - - - - - -
+ }, + { title: t("permission"), panel: }, + { title: t("validations"), panel: }, + { title: t("annotations"), panel: }, ]} - > - - - - - + />