Refactored scroll form so to easier hide section (#2697)

This commit is contained in:
Erik Jan de Wit 2022-05-30 13:07:33 +02:00 committed by GitHub
parent 0d0e086913
commit 48f68358f4
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
12 changed files with 1249 additions and 1108 deletions

View file

@ -1,107 +1,68 @@
import { import React from "react";
ActionGroup, import { useTranslation } from "react-i18next";
AlertVariant, import { useFormContext } from "react-hook-form";
Button, import { AlertVariant, PageSection, Text } from "@patternfly/react-core";
ButtonVariant,
ExpandableSection,
FormGroup,
InputGroup,
PageSection,
Split,
SplitItem,
Text,
ToolbarItem,
Tooltip,
} from "@patternfly/react-core";
import type ClientRepresentation from "@keycloak/keycloak-admin-client/lib/defs/clientRepresentation"; import type ClientRepresentation from "@keycloak/keycloak-admin-client/lib/defs/clientRepresentation";
import type GlobalRequestResult from "@keycloak/keycloak-admin-client/lib/defs/globalRequestResult"; import type GlobalRequestResult from "@keycloak/keycloak-admin-client/lib/defs/globalRequestResult";
import moment from "moment";
import React, { useEffect, useRef, useState } from "react"; import type { AddAlertFunction } from "../components/alert/Alerts";
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 { ScrollForm } from "../components/scroll-form/ScrollForm"; 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 { convertToFormValues, toUpperCase } from "../util";
import { AddHostDialog } from "./advanced/AddHostDialog";
import { AdvancedSettings } from "./advanced/AdvancedSettings"; import { AdvancedSettings } from "./advanced/AdvancedSettings";
import { AuthenticationOverrides } from "./advanced/AuthenticationOverrides"; import { AuthenticationOverrides } from "./advanced/AuthenticationOverrides";
import { FineGrainOpenIdConnect } from "./advanced/FineGrainOpenIdConnect"; import { FineGrainOpenIdConnect } from "./advanced/FineGrainOpenIdConnect";
import { FineGrainSamlEndpointConfig } from "./advanced/FineGrainSamlEndpointConfig"; import { FineGrainSamlEndpointConfig } from "./advanced/FineGrainSamlEndpointConfig";
import { OpenIdConnectCompatibilityModes } from "./advanced/OpenIdConnectCompatibilityModes"; import { OpenIdConnectCompatibilityModes } from "./advanced/OpenIdConnectCompatibilityModes";
import type { SaveOptions } from "./ClientDetails"; 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; save: (options?: SaveOptions) => void;
client: ClientRepresentation; client: ClientRepresentation;
}; };
export const AdvancedTab = ({ export const AdvancedTab = ({ save, client }: AdvancedProps) => {
save, const { t } = useTranslation("clients");
client: { const openIdConnect = "openid-connect";
id,
const { setValue, control, reset } = useFormContext();
const {
publicClient, publicClient,
registeredNodes,
attributes, attributes,
protocol, protocol,
authenticationFlowBindingOverrides, authenticationFlowBindingOverrides,
adminUrl, } = client;
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<HTMLElement>();
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
);
}
};
const resetFields = (names: string[]) => { const resetFields = (names: string[]) => {
const values: { [name: string]: string } = {}; const values: { [name: string]: string } = {};
@ -111,374 +72,131 @@ export const AdvancedTab = ({
reset(values); 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 ( return (
<PageSection variant="light" className="pf-u-py-0"> <PageSection variant="light" className="pf-u-py-0">
<ScrollForm sections={sections} borders> <ScrollForm
{protocol === openIdConnect && ( sections={[
<> {
<Text className="pf-u-pb-lg"> title: t("revocation"),
<Trans i18nKey="clients-help:notBeforeIntro"> isHidden: protocol !== openIdConnect,
In order to successfully push setup url on panel: <RevocationPanel client={client} save={save} />,
<Link to={toClient({ realm, clientId: id!, tab: "settings" })}> },
{t("settings")} {
</Link> title: t("clustering"),
tab isHidden: !publicClient,
</Trans> panel: <ClusteringPanel client={client} save={save} />,
</Text> },
<FormAccess {
role="manage-clients" title: t("fineGrainOpenIdConnectConfiguration"),
fineGrainedAccess={access?.configure} isHidden: protocol !== openIdConnect,
isHorizontal panel: (
> <>
<FormGroup <Text className="pf-u-pb-lg">
label={t("notBefore")} {t("clients-help:fineGrainOpenIdConnectConfiguration")}
fieldId="kc-not-before" </Text>
labelIcon={ <FineGrainOpenIdConnect
<HelpItem save={save}
helpText="clients-help:notBefore" reset={() =>
fieldLabelId="clients:notBefore" convertToFormValues(attributes, (key, value) =>
/> setValue(`attributes.${key}`, value)
}
>
<InputGroup>
<KeycloakTextInput
type="text"
id="kc-not-before"
name="notBefore"
isReadOnly
value={formatDate()}
/>
<Button
id="setToNow"
variant="control"
onClick={() => {
setNotBefore(moment.now() / 1000, "notBeforeSetToNow");
}}
>
{t("setToNow")}
</Button>
<Button
id="clear"
variant="control"
onClick={() => {
setNotBefore(0, "notBeforeNowClear");
}}
>
{t("clear")}
</Button>
</InputGroup>
</FormGroup>
<ActionGroup>
{!adminUrl && (
<Tooltip
reference={pushRevocationButtonRef}
content={t("clients-help:notBeforeTooltip")}
/>
)}
<Button
id="push"
variant="secondary"
onClick={push}
isAriaDisabled={!adminUrl}
ref={pushRevocationButtonRef}
>
{t("push")}
</Button>
</ActionGroup>
</FormAccess>
</>
)}
{publicClient && (
<>
<FormAccess
role="manage-clients"
fineGrainedAccess={access?.configure}
isHorizontal
>
<FormGroup
label={t("nodeReRegistrationTimeout")}
fieldId="kc-node-reregistration-timeout"
labelIcon={
<HelpItem
helpText="clients-help:nodeReRegistrationTimeout"
fieldLabelId="clients:nodeReRegistrationTimeout"
/>
}
>
<Split hasGutter>
<SplitItem>
<Controller
name="nodeReRegistrationTimeout"
defaultValue=""
control={control}
render={({ onChange, value }) => (
<TimeSelector value={value} onChange={onChange} />
)}
/>
</SplitItem>
<SplitItem>
<Button
variant={ButtonVariant.secondary}
onClick={() => save()}
>
{t("common:save")}
</Button>
</SplitItem>
</Split>
</FormGroup>
</FormAccess>
<>
<DeleteNodeConfirm />
<AddHostDialog
clientId={id!}
isOpen={addNodeOpen}
onAdded={(node) => {
nodes[node] = moment.now() / 1000;
refresh();
}}
onClose={() => setAddNodeOpen(false)}
/>
<ExpandableSection
toggleText={t("registeredClusterNodes")}
onToggle={setExpanded}
isExpanded={expanded}
>
<KeycloakDataTable
key={key}
ariaLabelKey="registeredClusterNodes"
loader={() =>
Promise.resolve(
Object.entries(nodes || {}).map((entry) => {
return { host: entry[0], registration: entry[1] };
})
) )
} }
toolbarItem={ />
<> </>
<ToolbarItem> ),
<Button },
id="testClusterAvailability" {
onClick={testCluster} title: t("openIdConnectCompatibilityModes"),
variant={ButtonVariant.secondary} isHidden: protocol !== openIdConnect,
isDisabled={Object.keys(nodes).length === 0} panel: (
> <>
{t("testClusterAvailability")} <Text className="pf-u-pb-lg">
</Button> {t("clients-help:openIdConnectCompatibilityModes")}
</ToolbarItem> </Text>
<ToolbarItem> <OpenIdConnectCompatibilityModes
<Button control={control}
id="registerNodeManually" save={() => save()}
onClick={() => setAddNodeOpen(true)} reset={() =>
variant={ButtonVariant.tertiary} resetFields(["exclude.session.state.from.auth.response"])
>
{t("registerNodeManually")}
</Button>
</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={
<ListEmptyState
message={t("noNodes")}
instructions={t("noNodesInstructions")}
primaryActionText={t("registerNodeManually")}
onPrimaryAction={() => setAddNodeOpen(true)}
/>
} }
/> />
</ExpandableSection> </>
</> ),
</> },
)} {
<> title: t("fineGrainSamlEndpointConfig"),
{protocol === openIdConnect && ( isHidden: protocol === openIdConnect,
<> panel: (
<Text className="pf-u-pb-lg"> <>
{t("clients-help:fineGrainOpenIdConnectConfiguration")} <Text className="pf-u-pb-lg">
</Text> {t("clients-help:fineGrainSamlEndpointConfig")}
<FineGrainOpenIdConnect </Text>
save={() => save()} <FineGrainSamlEndpointConfig
reset={() => control={control}
convertToFormValues(attributes, (key, value) => save={() => save()}
setValue(`attributes.${key}`, value) reset={() =>
) convertToFormValues(attributes, (key, value) =>
} setValue(`attributes.${key}`, value)
hasConfigureAccess={access?.configure} )
/> }
</> />
)} </>
{protocol !== openIdConnect && ( ),
<> },
<Text className="pf-u-pb-lg"> {
{t("clients-help:fineGrainSamlEndpointConfig")} title: t("advancedSettings"),
</Text> panel: (
<FineGrainSamlEndpointConfig <>
control={control} <Text className="pf-u-pb-lg">
save={() => save()} {t(
reset={() => "clients-help:advancedSettings" +
convertToFormValues(attributes, (key, value) => toUpperCase(protocol || "")
setValue(`attributes.${key}`, value) )}
) </Text>
} <AdvancedSettings
/> protocol={protocol}
</> control={control}
)} save={() => save()}
</> reset={() => {
{protocol === openIdConnect && ( resetFields([
<> "saml.assertion.lifespan",
<Text className="pf-u-pb-lg"> "access.token.lifespan",
{t("clients-help:openIdConnectCompatibilityModes")} "tls.client.certificate.bound.access.tokens",
</Text> "pkce.code.challenge.method",
<OpenIdConnectCompatibilityModes ]);
control={control} }}
save={() => save()} />
reset={() => </>
resetFields(["exclude.session.state.from.auth.response"]) ),
} },
hasConfigureAccess={access?.configure} {
/> title: t("authenticationOverrides"),
</> panel: (
)} <>
<> <Text className="pf-u-pb-lg">
<Text className="pf-u-pb-lg"> {t("clients-help:authenticationOverrides")}
{t("clients-help:advancedSettings" + toUpperCase(protocol || ""))} </Text>
</Text> <AuthenticationOverrides
<AdvancedSettings protocol={protocol}
protocol={protocol} control={control}
control={control} save={() => save()}
hasConfigureAccess={access?.configure} reset={() => {
save={() => save()} setValue(
reset={() => { "authenticationFlowBindingOverrides.browser",
resetFields([ authenticationFlowBindingOverrides?.browser
"saml.assertion.lifespan", );
"access.token.lifespan", setValue(
"tls.client.certificate.bound.access.tokens", "authenticationFlowBindingOverrides.direct_grant",
"pkce.code.challenge.method", authenticationFlowBindingOverrides?.direct_grant
]); );
}} }}
/> />
</> </>
<> ),
<Text className="pf-u-pb-lg"> },
{t("clients-help:authenticationOverrides")} ]}
</Text> borders
<AuthenticationOverrides />
protocol={protocol}
control={control}
save={() => save()}
reset={() => {
setValue(
"authenticationFlowBindingOverrides.browser",
authenticationFlowBindingOverrides?.browser
);
setValue(
"authenticationFlowBindingOverrides.direct_grant",
authenticationFlowBindingOverrides?.direct_grant
);
}}
hasConfigureAccess={access?.configure}
/>
</>
</ScrollForm>
</PageSection> </PageSection>
); );
}; };

View file

@ -1,569 +1,77 @@
import type ClientRepresentation from "@keycloak/keycloak-admin-client/lib/defs/clientRepresentation"; import React from "react";
import React, { useMemo, useState } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { import { useFormContext } from "react-hook-form";
FormGroup, import { Form } from "@patternfly/react-core";
Form,
Switch,
Select,
SelectVariant,
SelectOption,
ValidatedOptions,
} from "@patternfly/react-core";
import { Controller, useFormContext } from "react-hook-form";
import type ClientRepresentation from "@keycloak/keycloak-admin-client/lib/defs/clientRepresentation";
import { ScrollForm } from "../components/scroll-form/ScrollForm"; import { ScrollForm } from "../components/scroll-form/ScrollForm";
import { ClientDescription } from "./ClientDescription"; import { ClientDescription } from "./ClientDescription";
import { CapabilityConfig } from "./add/CapabilityConfig"; 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 { SamlConfig } from "./add/SamlConfig";
import { SamlSignature } from "./add/SamlSignature"; import { SamlSignature } from "./add/SamlSignature";
import environment from "../environment"; import { AccessSettings } from "./add/AccessSettings";
import { useRealm } from "../context/realm-context/RealmContext"; import { LoginSettingsPanel } from "./add/LoginSettingsPanel";
import { useAccess } from "../context/access/Access"; import { LogoutPanel } from "./add/LogoutPanel";
type ClientSettingsProps = { export type ClientSettingsProps = {
client: ClientRepresentation; client: ClientRepresentation;
save: () => void; save: () => void;
reset: () => void; reset: () => void;
}; };
export const ClientSettings = ({ export const ClientSettings = (props: ClientSettingsProps) => {
client,
save,
reset,
}: ClientSettingsProps) => {
const {
register,
control,
watch,
formState: { errors },
} = useFormContext<ClientRepresentation>();
const { t } = useTranslation("clients"); const { t } = useTranslation("clients");
const { realm } = useRealm();
const { hasAccess } = useAccess(); const { watch } = useFormContext<ClientRepresentation>();
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 protocol = watch("protocol"); const protocol = watch("protocol");
const frontchannelLogout = watch("frontchannelLogout");
const idpInitiatedSsoUrlName: string = watch(
"attributes.saml_idp_initiated_sso_url_name"
);
const sections = useMemo(() => { const { client } = props;
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]);
return ( return (
<ScrollForm <ScrollForm
className="pf-u-px-lg" className="pf-u-px-lg"
sections={sections.map((section) => t(section))} sections={[
> {
<Form isHorizontal> title: t("generalSettings"),
<ClientDescription panel: (
protocol={client.protocol} <Form isHorizontal>
hasConfigureAccess={client.access?.configure} <ClientDescription
/> protocol={client.protocol}
</Form> hasConfigureAccess={client.access?.configure}
{protocol === "saml" ? (
<SamlConfig />
) : (
!client.bearerOnly && <CapabilityConfig />
)}
{protocol === "saml" && <SamlSignature />}
<FormAccess
isHorizontal
role="manage-clients"
fineGrainedAccess={client.access?.configure}
>
{!client.bearerOnly && (
<>
<FormGroup
label={t("rootUrl")}
fieldId="kc-root-url"
labelIcon={
<HelpItem
helpText="clients-help:rootUrl"
fieldLabelId="clients:rootUrl"
/>
}
>
<KeycloakTextInput
type="text"
id="kc-root-url"
name="rootUrl"
ref={register}
/> />
</FormGroup> </Form>
<FormGroup ),
label={t("homeURL")} },
fieldId="kc-home-url" {
labelIcon={ title: t("accessSettings"),
<HelpItem panel: <AccessSettings {...props} />,
helpText="clients-help:homeURL" },
fieldLabelId="clients:homeURL" {
/> title: t("samlCapabilityConfig"),
} isHidden: protocol !== "saml" || client.bearerOnly,
> panel: <SamlConfig />,
<KeycloakTextInput },
type="text" {
id="kc-home-url" title: t("signatureAndEncryption"),
name="baseUrl" isHidden: protocol !== "saml" || client.bearerOnly,
ref={register} panel: <SamlSignature />,
/> },
</FormGroup> {
<FormGroup title: t("capabilityConfig"),
label={t("validRedirectUri")} isHidden: protocol !== "openid-connect" || client.bearerOnly,
fieldId="kc-redirect" panel: <CapabilityConfig />,
labelIcon={ },
<HelpItem {
helpText="clients-help:validRedirectURIs" title: t("loginSettings"),
fieldLabelId="clients:validRedirectUri" isHidden: protocol !== "openid-connect" || client.bearerOnly,
/> panel: <LoginSettingsPanel access={client.access?.configure} />,
} },
> {
<MultiLineInput title: t("logoutSettings"),
name="redirectUris" isHidden: client.bearerOnly,
aria-label={t("validRedirectUri")} panel: <LogoutPanel {...props} />,
addButtonLabel="clients:addRedirectUri" },
/> ]}
</FormGroup> />
{protocol === "saml" && (
<>
<FormGroup
label={t("idpInitiatedSsoUrlName")}
fieldId="idpInitiatedSsoUrlName"
labelIcon={
<HelpItem
helpText="clients-help:idpInitiatedSsoUrlName"
fieldLabelId="clients:idpInitiatedSsoUrlName"
/>
}
helperText={
idpInitiatedSsoUrlName !== "" &&
t("idpInitiatedSsoUrlNameHelp", {
url: `${environment.authServerUrl}/realms/${realm}/protocol/saml/clients/${idpInitiatedSsoUrlName}`,
})
}
>
<KeycloakTextInput
type="text"
id="idpInitiatedSsoUrlName"
name="attributes.saml_idp_initiated_sso_url_name"
ref={register}
/>
</FormGroup>
<FormGroup
label={t("idpInitiatedSsoRelayState")}
fieldId="idpInitiatedSsoRelayState"
labelIcon={
<HelpItem
helpText="clients-help:idpInitiatedSsoRelayState"
fieldLabelId="clients:idpInitiatedSsoRelayState"
/>
}
>
<KeycloakTextInput
type="text"
id="idpInitiatedSsoRelayState"
name="attributes.saml_idp_initiated_sso_relay_state"
ref={register}
/>
</FormGroup>
<FormGroup
label={t("masterSamlProcessingUrl")}
fieldId="masterSamlProcessingUrl"
labelIcon={
<HelpItem
helpText="clients-help:masterSamlProcessingUrl"
fieldLabelId="clients:masterSamlProcessingUrl"
/>
}
>
<KeycloakTextInput
type="text"
id="masterSamlProcessingUrl"
name="adminUrl"
ref={register}
/>
</FormGroup>
</>
)}
{protocol !== "saml" && (
<FormGroup
label={t("webOrigins")}
fieldId="kc-web-origins"
labelIcon={
<HelpItem
helpText="clients-help:webOrigins"
fieldLabelId="clients:webOrigins"
/>
}
>
<MultiLineInput
name="webOrigins"
aria-label={t("webOrigins")}
addButtonLabel="clients:addWebOrigins"
/>
</FormGroup>
)}
</>
)}
{protocol !== "saml" && (
<FormGroup
label={t("adminURL")}
fieldId="kc-admin-url"
labelIcon={
<HelpItem
helpText="clients-help:adminURL"
fieldLabelId="clients:adminURL"
/>
}
>
<KeycloakTextInput
type="text"
id="kc-admin-url"
name="adminUrl"
ref={register}
/>
</FormGroup>
)}
{client.bearerOnly && (
<SaveReset
className="keycloak__form_actions"
name="settings"
save={save}
reset={reset}
isActive={!isManager}
/>
)}
</FormAccess>
<FormAccess
isHorizontal
role="manage-clients"
fineGrainedAccess={client.access?.configure}
>
<FormGroup
label={t("loginTheme")}
labelIcon={
<HelpItem
helpText="clients-help:loginTheme"
fieldLabelId="clients:loginTheme"
/>
}
fieldId="loginTheme"
>
<Controller
name="attributes.login_theme"
defaultValue=""
control={control}
render={({ onChange, value }) => (
<Select
toggleId="loginTheme"
onToggle={setLoginThemeOpen}
onSelect={(_, value) => {
onChange(value.toString());
setLoginThemeOpen(false);
}}
selections={value || t("common:choose")}
variant={SelectVariant.single}
aria-label={t("loginTheme")}
isOpen={loginThemeOpen}
>
{[
<SelectOption key="empty" value="">
{t("common:choose")}
</SelectOption>,
...loginThemes.map((theme) => (
<SelectOption
selected={theme.name === value}
key={theme.name}
value={theme.name}
/>
)),
]}
</Select>
)}
/>
</FormGroup>
<FormGroup
label={t("consentRequired")}
labelIcon={
<HelpItem
helpText="clients-help:consentRequired"
fieldLabelId="clients:consentRequired"
/>
}
fieldId="kc-consent"
hasNoPaddingTop
>
<Controller
name="consentRequired"
defaultValue={false}
control={control}
render={({ onChange, value }) => (
<Switch
id="kc-consent-switch"
label={t("common:on")}
labelOff={t("common:off")}
isChecked={value}
onChange={onChange}
/>
)}
/>
</FormGroup>
<FormGroup
label={t("displayOnClient")}
labelIcon={
<HelpItem
helpText="clients-help:displayOnClient"
fieldLabelId="clients:displayOnClient"
/>
}
fieldId="kc-display-on-client"
hasNoPaddingTop
>
<Controller
name="attributes.display.on.consent.screen"
defaultValue={false}
control={control}
render={({ onChange, value }) => (
<Switch
id="kc-display-on-client-switch"
label={t("common:on")}
labelOff={t("common:off")}
isChecked={value === "true"}
onChange={(value) => onChange("" + value)}
isDisabled={!consentRequired}
/>
)}
/>
</FormGroup>
<FormGroup
label={t("consentScreenText")}
labelIcon={
<HelpItem
helpText="clients-help:consentScreenText"
fieldLabelId="clients:consentScreenText"
/>
}
fieldId="kc-consent-screen-text"
>
<KeycloakTextArea
id="kc-consent-screen-text"
name="attributes.consent.screen.text"
ref={register}
isDisabled={!(consentRequired && displayOnConsentScreen === "true")}
/>
</FormGroup>
{protocol === "saml" && (
<SaveReset
className="keycloak__form_actions"
name="settings"
save={save}
reset={reset}
isActive={isManager}
/>
)}
</FormAccess>
<FormAccess
isHorizontal
role="manage-clients"
fineGrainedAccess={client.access?.configure}
>
{protocol === "openid-connect" && (
<>
<FormGroup
label={t("frontchannelLogout")}
labelIcon={
<HelpItem
helpText="clients-help:frontchannelLogout"
fieldLabelId="clients:frontchannelLogout"
/>
}
fieldId="frontchannelLogout"
hasNoPaddingTop
>
<Controller
name="frontchannelLogout"
defaultValue={true}
control={control}
render={({ onChange, value }) => (
<Switch
id="frontchannelLogout"
label={t("common:on")}
labelOff={t("common:off")}
isChecked={value.toString() === "true"}
onChange={(value) => onChange(value.toString())}
/>
)}
/>
</FormGroup>
{frontchannelLogout?.toString() === "true" && (
<FormGroup
label={t("frontchannelLogoutUrl")}
fieldId="frontchannelLogoutUrl"
labelIcon={
<HelpItem
helpText="clients-help:frontchannelLogoutUrl"
fieldLabelId="clients:frontchannelLogoutUrl"
/>
}
helperTextInvalid={
errors.attributes?.frontchannel?.logout?.url?.message
}
validated={
errors.attributes?.frontchannel?.logout?.url?.message
? ValidatedOptions.error
: ValidatedOptions.default
}
>
<KeycloakTextInput
type="text"
id="frontchannelLogoutUrl"
name="attributes.frontchannel.logout.url"
ref={register({
validate: (uri) =>
((uri.startsWith("https://") ||
uri.startsWith("http://")) &&
!uri.includes("*")) ||
uri === "" ||
t("frontchannelUrlInvalid").toString(),
})}
validated={
errors.attributes?.frontchannel?.logout?.url?.message
? ValidatedOptions.error
: ValidatedOptions.default
}
/>
</FormGroup>
)}
</>
)}
<FormGroup
label={t("backchannelLogoutUrl")}
fieldId="backchannelLogoutUrl"
labelIcon={
<HelpItem
helpText="clients-help:backchannelLogoutUrl"
fieldLabelId="clients:backchannelLogoutUrl"
/>
}
helperTextInvalid={
errors.attributes?.backchannel?.logout?.url?.message
}
validated={
errors.attributes?.backchannel?.logout?.url?.message
? ValidatedOptions.error
: ValidatedOptions.default
}
>
<KeycloakTextInput
type="text"
id="backchannelLogoutUrl"
name="attributes.backchannel.logout.url"
ref={register({
validate: (uri) =>
((uri.startsWith("https://") || uri.startsWith("http://")) &&
!uri.includes("*")) ||
uri === "" ||
t("backchannelUrlInvalid").toString(),
})}
validated={
errors.attributes?.backchannel?.logout?.url?.message
? ValidatedOptions.error
: ValidatedOptions.default
}
/>
</FormGroup>
<FormGroup
label={t("backchannelLogoutSessionRequired")}
labelIcon={
<HelpItem
helpText="clients-help:backchannelLogoutSessionRequired"
fieldLabelId="clients:backchannelLogoutSessionRequired"
/>
}
fieldId="backchannelLogoutSessionRequired"
hasNoPaddingTop
>
<Controller
name="attributes.backchannel.logout.session.required"
defaultValue="true"
control={control}
render={({ onChange, value }) => (
<Switch
id="backchannelLogoutSessionRequired"
label={t("common:on")}
labelOff={t("common:off")}
isChecked={value === "true"}
onChange={(value) => onChange(value.toString())}
/>
)}
/>
</FormGroup>
<FormGroup
label={t("backchannelLogoutRevokeOfflineSessions")}
labelIcon={
<HelpItem
helpText="clients-help:backchannelLogoutRevokeOfflineSessions"
fieldLabelId="clients:backchannelLogoutRevokeOfflineSessions"
/>
}
fieldId="backchannelLogoutRevokeOfflineSessions"
hasNoPaddingTop
>
<Controller
name="attributes.backchannel.logout.revoke.offline.tokens"
defaultValue="false"
control={control}
render={({ onChange, value }) => (
<Switch
id="backchannelLogoutRevokeOfflineSessions"
label={t("common:on")}
labelOff={t("common:off")}
isChecked={value === "true"}
onChange={(value) => onChange(value.toString())}
/>
)}
/>
</FormGroup>
<SaveReset
className="keycloak__form_actions"
name="settings"
save={save}
reset={reset}
isActive={isManager}
/>
</FormAccess>
</ScrollForm>
); );
}; };

View file

@ -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<ClientRepresentation>();
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 (
<FormAccess
isHorizontal
fineGrainedAccess={client.access?.configure}
role="manage-clients"
>
{!client.bearerOnly && (
<>
<FormGroup
label={t("rootUrl")}
fieldId="kc-root-url"
labelIcon={
<HelpItem
helpText="clients-help:rootUrl"
fieldLabelId="clients:rootUrl"
/>
}
>
<KeycloakTextInput
type="text"
id="kc-root-url"
name="rootUrl"
ref={register}
/>
</FormGroup>
<FormGroup
label={t("homeURL")}
fieldId="kc-home-url"
labelIcon={
<HelpItem
helpText="clients-help:homeURL"
fieldLabelId="clients:homeURL"
/>
}
>
<KeycloakTextInput
type="text"
id="kc-home-url"
name="baseUrl"
ref={register}
/>
</FormGroup>
<FormGroup
label={t("validRedirectUri")}
fieldId="kc-redirect"
labelIcon={
<HelpItem
helpText="clients-help:validRedirectURIs"
fieldLabelId="clients:validRedirectUri"
/>
}
>
<MultiLineInput
name="redirectUris"
aria-label={t("validRedirectUri")}
addButtonLabel="clients:addRedirectUri"
/>
</FormGroup>
{protocol === "saml" && (
<>
<FormGroup
label={t("idpInitiatedSsoUrlName")}
fieldId="idpInitiatedSsoUrlName"
labelIcon={
<HelpItem
helpText="clients-help:idpInitiatedSsoUrlName"
fieldLabelId="clients:idpInitiatedSsoUrlName"
/>
}
helperText={
idpInitiatedSsoUrlName !== "" &&
t("idpInitiatedSsoUrlNameHelp", {
url: `${environment.authServerUrl}/realms/${realm}/protocol/saml/clients/${idpInitiatedSsoUrlName}`,
})
}
>
<KeycloakTextInput
type="text"
id="idpInitiatedSsoUrlName"
name="attributes.saml_idp_initiated_sso_url_name"
ref={register}
/>
</FormGroup>
<FormGroup
label={t("idpInitiatedSsoRelayState")}
fieldId="idpInitiatedSsoRelayState"
labelIcon={
<HelpItem
helpText="clients-help:idpInitiatedSsoRelayState"
fieldLabelId="clients:idpInitiatedSsoRelayState"
/>
}
>
<KeycloakTextInput
type="text"
id="idpInitiatedSsoRelayState"
name="attributes.saml_idp_initiated_sso_relay_state"
ref={register}
/>
</FormGroup>
<FormGroup
label={t("masterSamlProcessingUrl")}
fieldId="masterSamlProcessingUrl"
labelIcon={
<HelpItem
helpText="clients-help:masterSamlProcessingUrl"
fieldLabelId="clients:masterSamlProcessingUrl"
/>
}
>
<KeycloakTextInput
type="text"
id="masterSamlProcessingUrl"
name="adminUrl"
ref={register}
/>
</FormGroup>
</>
)}
{protocol !== "saml" && (
<FormGroup
label={t("webOrigins")}
fieldId="kc-web-origins"
labelIcon={
<HelpItem
helpText="clients-help:webOrigins"
fieldLabelId="clients:webOrigins"
/>
}
>
<MultiLineInput
name="webOrigins"
aria-label={t("webOrigins")}
addButtonLabel="clients:addWebOrigins"
/>
</FormGroup>
)}
</>
)}
{protocol !== "saml" && (
<FormGroup
label={t("adminURL")}
fieldId="kc-admin-url"
labelIcon={
<HelpItem
helpText="clients-help:adminURL"
fieldLabelId="clients:adminURL"
/>
}
>
<KeycloakTextInput
type="text"
id="kc-admin-url"
name="adminUrl"
ref={register}
/>
</FormGroup>
)}
{client.bearerOnly && (
<SaveReset
className="keycloak__form_actions"
name="settings"
save={save}
reset={reset}
isActive={!isManager}
/>
)}
</FormAccess>
);
};

View file

@ -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<ClientRepresentation>();
const [loginThemeOpen, setLoginThemeOpen] = useState(false);
const loginThemes = useServerInfo().themes!["login"];
const consentRequired = watch("consentRequired");
const displayOnConsentScreen: string = watch(
"attributes.display.on.consent.screen"
);
return (
<FormAccess isHorizontal fineGrainedAccess={access} role="manage-clients">
<FormGroup
label={t("loginTheme")}
labelIcon={
<HelpItem
helpText="clients-help:loginTheme"
fieldLabelId="clients:loginTheme"
/>
}
fieldId="loginTheme"
>
<Controller
name="attributes.login_theme"
defaultValue=""
control={control}
render={({ onChange, value }) => (
<Select
toggleId="loginTheme"
onToggle={setLoginThemeOpen}
onSelect={(_, value) => {
onChange(value.toString());
setLoginThemeOpen(false);
}}
selections={value || t("common:choose")}
variant={SelectVariant.single}
aria-label={t("loginTheme")}
isOpen={loginThemeOpen}
>
{[
<SelectOption key="empty" value="">
{t("common:choose")}
</SelectOption>,
...loginThemes.map((theme) => (
<SelectOption
selected={theme.name === value}
key={theme.name}
value={theme.name}
/>
)),
]}
</Select>
)}
/>
</FormGroup>
<FormGroup
label={t("consentRequired")}
labelIcon={
<HelpItem
helpText="clients-help:consentRequired"
fieldLabelId="clients:consentRequired"
/>
}
fieldId="kc-consent"
hasNoPaddingTop
>
<Controller
name="consentRequired"
defaultValue={false}
control={control}
render={({ onChange, value }) => (
<Switch
id="kc-consent-switch"
label={t("common:on")}
labelOff={t("common:off")}
isChecked={value}
onChange={onChange}
/>
)}
/>
</FormGroup>
<FormGroup
label={t("displayOnClient")}
labelIcon={
<HelpItem
helpText="clients-help:displayOnClient"
fieldLabelId="clients:displayOnClient"
/>
}
fieldId="kc-display-on-client"
hasNoPaddingTop
>
<Controller
name="attributes.display.on.consent.screen"
defaultValue={false}
control={control}
render={({ onChange, value }) => (
<Switch
id="kc-display-on-client-switch"
label={t("common:on")}
labelOff={t("common:off")}
isChecked={value === "true"}
onChange={(value) => onChange("" + value)}
isDisabled={!consentRequired}
/>
)}
/>
</FormGroup>
<FormGroup
label={t("consentScreenText")}
labelIcon={
<HelpItem
helpText="clients-help:consentScreenText"
fieldLabelId="clients:consentScreenText"
/>
}
fieldId="kc-consent-screen-text"
>
<KeycloakTextArea
id="kc-consent-screen-text"
name="attributes.consent.screen.text"
ref={register}
isDisabled={!(consentRequired && displayOnConsentScreen === "true")}
/>
</FormGroup>
</FormAccess>
);
};

View file

@ -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<ClientRepresentation>();
const { hasAccess } = useAccess();
const isManager = hasAccess("manage-clients") || access?.configure;
const protocol = watch("protocol");
const frontchannelLogout = watch("frontchannelLogout");
return (
<FormAccess
isHorizontal
fineGrainedAccess={access?.configure}
role="manage-clients"
>
{protocol === "openid-connect" && (
<>
<FormGroup
label={t("frontchannelLogout")}
labelIcon={
<HelpItem
helpText="clients-help:frontchannelLogout"
fieldLabelId="clients:frontchannelLogout"
/>
}
fieldId="frontchannelLogout"
hasNoPaddingTop
>
<Controller
name="frontchannelLogout"
defaultValue={true}
control={control}
render={({ onChange, value }) => (
<Switch
id="frontchannelLogout"
label={t("common:on")}
labelOff={t("common:off")}
isChecked={value.toString() === "true"}
onChange={(value) => onChange(value.toString())}
/>
)}
/>
</FormGroup>
{frontchannelLogout?.toString() === "true" && (
<FormGroup
label={t("frontchannelLogoutUrl")}
fieldId="frontchannelLogoutUrl"
labelIcon={
<HelpItem
helpText="clients-help:frontchannelLogoutUrl"
fieldLabelId="clients:frontchannelLogoutUrl"
/>
}
helperTextInvalid={
errors.attributes?.frontchannel?.logout?.url?.message
}
validated={
errors.attributes?.frontchannel?.logout?.url?.message
? ValidatedOptions.error
: ValidatedOptions.default
}
>
<KeycloakTextInput
type="text"
id="frontchannelLogoutUrl"
name="attributes.frontchannel.logout.url"
ref={register({
validate: (uri) =>
((uri.startsWith("https://") ||
uri.startsWith("http://")) &&
!uri.includes("*")) ||
uri === "" ||
t("frontchannelUrlInvalid").toString(),
})}
validated={
errors.attributes?.frontchannel?.logout?.url?.message
? ValidatedOptions.error
: ValidatedOptions.default
}
/>
</FormGroup>
)}
</>
)}
<FormGroup
label={t("backchannelLogoutUrl")}
fieldId="backchannelLogoutUrl"
labelIcon={
<HelpItem
helpText="clients-help:backchannelLogoutUrl"
fieldLabelId="clients:backchannelLogoutUrl"
/>
}
helperTextInvalid={errors.attributes?.backchannel?.logout?.url?.message}
validated={
errors.attributes?.backchannel?.logout?.url?.message
? ValidatedOptions.error
: ValidatedOptions.default
}
>
<KeycloakTextInput
type="text"
id="backchannelLogoutUrl"
name="attributes.backchannel.logout.url"
ref={register({
validate: (uri) =>
((uri.startsWith("https://") || uri.startsWith("http://")) &&
!uri.includes("*")) ||
uri === "" ||
t("backchannelUrlInvalid").toString(),
})}
validated={
errors.attributes?.backchannel?.logout?.url?.message
? ValidatedOptions.error
: ValidatedOptions.default
}
/>
</FormGroup>
<FormGroup
label={t("backchannelLogoutSessionRequired")}
labelIcon={
<HelpItem
helpText="clients-help:backchannelLogoutSessionRequired"
fieldLabelId="clients:backchannelLogoutSessionRequired"
/>
}
fieldId="backchannelLogoutSessionRequired"
hasNoPaddingTop
>
<Controller
name="attributes.backchannel.logout.session.required"
defaultValue="true"
control={control}
render={({ onChange, value }) => (
<Switch
id="backchannelLogoutSessionRequired"
label={t("common:on")}
labelOff={t("common:off")}
isChecked={value === "true"}
onChange={(value) => onChange(value.toString())}
/>
)}
/>
</FormGroup>
<FormGroup
label={t("backchannelLogoutRevokeOfflineSessions")}
labelIcon={
<HelpItem
helpText="clients-help:backchannelLogoutRevokeOfflineSessions"
fieldLabelId="clients:backchannelLogoutRevokeOfflineSessions"
/>
}
fieldId="backchannelLogoutRevokeOfflineSessions"
hasNoPaddingTop
>
<Controller
name="attributes.backchannel.logout.revoke.offline.tokens"
defaultValue="false"
control={control}
render={({ onChange, value }) => (
<Switch
id="backchannelLogoutRevokeOfflineSessions"
label={t("common:on")}
labelOff={t("common:off")}
isChecked={value === "true"}
onChange={(value) => onChange(value.toString())}
/>
)}
/>
</FormGroup>
<SaveReset
className="keycloak__form_actions"
name="settings"
save={save}
reset={reset}
isActive={isManager}
/>
</FormAccess>
);
};

View file

@ -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 (
<>
<FormAccess
role="manage-clients"
fineGrainedAccess={access?.configure}
isHorizontal
>
<FormGroup
label={t("nodeReRegistrationTimeout")}
fieldId="kc-node-reregistration-timeout"
labelIcon={
<HelpItem
helpText="clients-help:nodeReRegistrationTimeout"
fieldLabelId="clients:nodeReRegistrationTimeout"
/>
}
>
<Split hasGutter>
<SplitItem>
<Controller
name="nodeReRegistrationTimeout"
defaultValue=""
control={control}
render={({ onChange, value }) => (
<TimeSelector value={value} onChange={onChange} />
)}
/>
</SplitItem>
<SplitItem>
<Button variant={ButtonVariant.secondary} onClick={() => save()}>
{t("common:save")}
</Button>
</SplitItem>
</Split>
</FormGroup>
</FormAccess>
<>
<DeleteNodeConfirm />
<AddHostDialog
clientId={id!}
isOpen={addNodeOpen}
onAdded={(node) => {
nodes[node] = moment.now() / 1000;
refresh();
}}
onClose={() => setAddNodeOpen(false)}
/>
<ExpandableSection
toggleText={t("registeredClusterNodes")}
onToggle={setExpanded}
isExpanded={expanded}
>
<KeycloakDataTable
key={key}
ariaLabelKey="registeredClusterNodes"
loader={() =>
Promise.resolve(
Object.entries(nodes || {}).map((entry) => {
return { host: entry[0], registration: entry[1] };
})
)
}
toolbarItem={
<>
<ToolbarItem>
<Button
id="testClusterAvailability"
onClick={testCluster}
variant={ButtonVariant.secondary}
isDisabled={Object.keys(nodes).length === 0}
>
{t("testClusterAvailability")}
</Button>
</ToolbarItem>
<ToolbarItem>
<Button
id="registerNodeManually"
onClick={() => setAddNodeOpen(true)}
variant={ButtonVariant.tertiary}
>
{t("registerNodeManually")}
</Button>
</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={
<ListEmptyState
message={t("noNodes")}
instructions={t("noNodesInstructions")}
primaryActionText={t("registerNodeManually")}
onPrimaryAction={() => setAddNodeOpen(true)}
/>
}
/>
</ExpandableSection>
</>
</>
);
};

View file

@ -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<HTMLElement>();
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 (
<>
<Text className="pf-u-pb-lg">
<Trans i18nKey="clients-help:notBeforeIntro">
In order to successfully push setup url on
<Link to={toClient({ realm, clientId: id!, tab: "settings" })}>
{t("settings")}
</Link>
tab
</Trans>
</Text>
<FormAccess
role="manage-clients"
fineGrainedAccess={access?.configure}
isHorizontal
>
<FormGroup
label={t("notBefore")}
fieldId="kc-not-before"
labelIcon={
<HelpItem
helpText="clients-help:notBefore"
fieldLabelId="clients:notBefore"
/>
}
>
<InputGroup>
<KeycloakTextInput
type="text"
id="kc-not-before"
name="notBefore"
isReadOnly
value={formatDate()}
/>
<Button
id="setToNow"
variant="control"
onClick={() => {
setNotBefore(moment.now() / 1000, "notBeforeSetToNow");
}}
>
{t("setToNow")}
</Button>
<Button
id="clear"
variant="control"
onClick={() => {
setNotBefore(0, "notBeforeNowClear");
}}
>
{t("clear")}
</Button>
</InputGroup>
</FormGroup>
<ActionGroup>
{!adminUrl && (
<Tooltip
reference={pushRevocationButtonRef}
content={t("clients-help:notBeforeTooltip")}
/>
)}
<Button
id="push"
variant="secondary"
onClick={push}
isAriaDisabled={!adminUrl}
ref={pushRevocationButtonRef}
>
{t("push")}
</Button>
</ActionGroup>
</FormAccess>
</>
);
};

View file

@ -8,14 +8,17 @@ import useRequiredContext from "../../utils/useRequiredContext";
import useSetTimeout from "../../utils/useSetTimeout"; import useSetTimeout from "../../utils/useSetTimeout";
import { AlertPanel, AlertType } from "./AlertPanel"; import { AlertPanel, AlertType } from "./AlertPanel";
type AlertProps = { export type AddAlertFunction = (
addAlert: ( message: string,
message: string, variant?: AlertVariant,
variant?: AlertVariant, description?: string
description?: string ) => void;
) => 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<AlertProps | undefined>(undefined); export const AlertContext = createContext<AlertProps | undefined>(undefined);

View file

@ -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 { useTranslation } from "react-i18next";
import { import {
Grid, Grid,
@ -15,8 +15,14 @@ import { FormPanel } from "./FormPanel";
import "./scroll-form.css"; import "./scroll-form.css";
type ScrollSection = {
title: string;
panel: ReactNode;
isHidden?: boolean;
};
type ScrollFormProps = GridProps & { type ScrollFormProps = GridProps & {
sections: string[]; sections: ScrollSection[];
borders?: boolean; borders?: boolean;
}; };
@ -27,33 +33,34 @@ const spacesToHyphens = (string: string): string => {
export const ScrollForm: FunctionComponent<ScrollFormProps> = ({ export const ScrollForm: FunctionComponent<ScrollFormProps> = ({
sections, sections,
borders = false, borders = false,
children,
...rest ...rest
}) => { }) => {
const { t } = useTranslation("common"); const { t } = useTranslation("common");
const nodes = Children.toArray(children); const shownSections = useMemo(
() => sections.filter(({ isHidden }) => !isHidden),
[sections]
);
return ( return (
<Grid hasGutter {...rest}> <Grid hasGutter {...rest}>
<GridItem span={8}> <GridItem span={8}>
{sections.map((cat, index) => { {shownSections.map(({ title, panel }) => {
const scrollId = spacesToHyphens(cat.toLowerCase()); const scrollId = spacesToHyphens(title.toLowerCase());
return ( return (
<Fragment key={cat}> <Fragment key={title}>
{!borders && ( {borders ? (
<ScrollPanel scrollId={scrollId} title={cat}>
{nodes[index]}
</ScrollPanel>
)}
{borders && (
<FormPanel <FormPanel
scrollId={scrollId} scrollId={scrollId}
title={cat} title={title}
className="kc-form-panel__panel" className="kc-form-panel__panel"
> >
{nodes[index]} {panel}
</FormPanel> </FormPanel>
) : (
<ScrollPanel scrollId={scrollId} title={title}>
{panel}
</ScrollPanel>
)} )}
</Fragment> </Fragment>
); );
@ -69,17 +76,17 @@ export const ScrollForm: FunctionComponent<ScrollFormProps> = ({
label={t("jumpToSection")} label={t("jumpToSection")}
offset={100} offset={100}
> >
{sections.map((cat) => { {shownSections.map(({ title }) => {
const scrollId = spacesToHyphens(cat.toLowerCase()); const scrollId = spacesToHyphens(title.toLowerCase());
return ( return (
// note that JumpLinks currently does not work with spaces in the href // note that JumpLinks currently does not work with spaces in the href
<JumpLinksItem <JumpLinksItem
key={cat} key={title}
href={`#${scrollId}`} href={`#${scrollId}`}
data-testid={`jump-link-${scrollId}`} data-testid={`jump-link-${scrollId}`}
> >
{cat} {title}
</JumpLinksItem> </JumpLinksItem>
); );
})} })}

View file

@ -238,8 +238,6 @@ export default function DetailSettings() {
return <KeycloakSpinner />; return <KeycloakSpinner />;
} }
const sections = [t("generalSettings"), t("advancedSettings")];
const isOIDC = provider.providerId!.includes("oidc"); const isOIDC = provider.providerId!.includes("oidc");
const isSAML = provider.providerId!.includes("saml"); const isSAML = provider.providerId!.includes("saml");
@ -268,14 +266,81 @@ export default function DetailSettings() {
return components; return components;
}; };
if (isOIDC) { const sections = [
sections.splice(1, 0, t("oidcSettings")); {
} title: t("generalSettings"),
panel: (
<FormAccess
role="manage-identity-providers"
isHorizontal
onSubmit={handleSubmit(save)}
>
{!isOIDC && !isSAML && <GeneralSettings create={false} id={alias} />}
{isOIDC && <OIDCGeneralSettings id={alias} />}
{isSAML && <SamlGeneralSettings id={alias} />}
</FormAccess>
),
},
{
title: t("oidcSettings"),
isHidden: !isOIDC,
panel: (
<>
<DiscoverySettings readOnly={false} />
<Form isHorizontal className="pf-u-py-lg">
<Divider />
<OIDCAuthentication create={false} />
</Form>
<ExtendedNonDiscoverySettings />
</>
),
},
{
title: t("samlSettings"),
isHidden: !isSAML,
panel: <DescriptorSettings readOnly={false} />,
},
{
title: t("reqAuthnConstraints"),
isHidden: !isSAML,
panel: (
<FormAccess
role="manage-identity-providers"
isHorizontal
onSubmit={handleSubmit(save)}
>
<ReqAuthnConstraints />
</FormAccess>
),
},
{
title: t("advancedSettings"),
panel: (
<FormAccess
role="manage-identity-providers"
isHorizontal
onSubmit={handleSubmit(save)}
>
<AdvancedSettings isOIDC={isOIDC!} isSAML={isSAML!} />
if (isSAML) { <ActionGroup className="keycloak__form_actions">
sections.splice(1, 0, t("samlSettings")); <Button data-testid={"save"} type="submit">
sections.splice(2, 0, t("reqAuthnConstraints")); {t("common:save")}
} </Button>
<Button
data-testid={"revert"}
variant="link"
onClick={() => {
reset();
}}
>
{t("common:revert")}
</Button>
</ActionGroup>
</FormAccess>
),
},
];
return ( return (
<FormProvider {...form}> <FormProvider {...form}>
@ -302,61 +367,7 @@ export default function DetailSettings() {
eventKey="settings" eventKey="settings"
title={<TabTitleText>{t("common:settings")}</TabTitleText>} title={<TabTitleText>{t("common:settings")}</TabTitleText>}
> >
<ScrollForm className="pf-u-px-lg" sections={sections}> <ScrollForm className="pf-u-px-lg" sections={sections} />
<FormAccess
role="manage-identity-providers"
isHorizontal
onSubmit={handleSubmit(save)}
>
{!isOIDC && !isSAML && (
<GeneralSettings create={false} id={alias} />
)}
{isOIDC && <OIDCGeneralSettings id={alias} />}
{isSAML && <SamlGeneralSettings id={alias} />}
</FormAccess>
{isOIDC && (
<>
<DiscoverySettings readOnly={false} />
<Form isHorizontal className="pf-u-py-lg">
<Divider />
<OIDCAuthentication create={false} />
</Form>
<ExtendedNonDiscoverySettings />
</>
)}
{isSAML && <DescriptorSettings readOnly={false} />}
{isSAML && (
<FormAccess
role="manage-identity-providers"
isHorizontal
onSubmit={handleSubmit(save)}
>
<ReqAuthnConstraints />
</FormAccess>
)}
<FormAccess
role="manage-identity-providers"
isHorizontal
onSubmit={handleSubmit(save)}
>
<AdvancedSettings isOIDC={isOIDC!} isSAML={isSAML!} />
<ActionGroup className="keycloak__form_actions">
<Button data-testid={"save"} type="submit">
{t("common:save")}
</Button>
<Button
data-testid={"revert"}
variant="link"
onClick={() => {
reset();
}}
>
{t("common:revert")}
</Button>
</ActionGroup>
</FormAccess>
</ScrollForm>
</Tab> </Tab>
<Tab <Tab
id="mappers" id="mappers"

View file

@ -69,17 +69,12 @@ const CreateAttributeFormContent = ({
<UserProfileProvider> <UserProfileProvider>
<ScrollForm <ScrollForm
sections={[ sections={[
t("generalSettings"), { title: t("generalSettings"), panel: <AttributeGeneralSettings /> },
t("permission"), { title: t("permission"), panel: <AttributePermission /> },
t("validations"), { title: t("validations"), panel: <AttributeValidations /> },
t("annotations"), { title: t("annotations"), panel: <AttributeAnnotations /> },
]} ]}
> />
<AttributeGeneralSettings />
<AttributePermission />
<AttributeValidations />
<AttributeAnnotations />
</ScrollForm>
<Form onSubmit={form.handleSubmit(save)}> <Form onSubmit={form.handleSubmit(save)}>
<ActionGroup className="keycloak__form_actions"> <ActionGroup className="keycloak__form_actions">
<Button <Button

View file

@ -55,23 +55,33 @@ const AddLdapFormContent = ({
<> <>
<ScrollForm <ScrollForm
sections={[ sections={[
t("generalOptions"), {
t("connectionAndAuthenticationSettings"), title: t("generalOptions"),
t("ldapSearchingAndUpdatingSettings"), panel: <LdapSettingsGeneral form={form} vendorEdit={!!id} />,
t("synchronizationSettings"), },
t("kerberosIntegration"), {
t("cacheSettings"), title: t("connectionAndAuthenticationSettings"),
t("advancedSettings"), panel: <LdapSettingsConnection form={form} id={id} />,
},
{
title: t("ldapSearchingAndUpdatingSettings"),
panel: <LdapSettingsSearching form={form} />,
},
{
title: t("synchronizationSettings"),
panel: <LdapSettingsSynchronization form={form} />,
},
{
title: t("kerberosIntegration"),
panel: <LdapSettingsKerberosIntegration form={form} />,
},
{ title: t("cacheSettings"), panel: <SettingsCache form={form} /> },
{
title: t("advancedSettings"),
panel: <LdapSettingsAdvanced form={form} id={id} />,
},
]} ]}
> />
<LdapSettingsGeneral form={form} vendorEdit={!!id} />
<LdapSettingsConnection form={form} id={id} />
<LdapSettingsSearching form={form} />
<LdapSettingsSynchronization form={form} />
<LdapSettingsKerberosIntegration form={form} />
<SettingsCache form={form} />
<LdapSettingsAdvanced form={form} id={id} />
</ScrollForm>
<Form onSubmit={form.handleSubmit(save)}> <Form onSubmit={form.handleSubmit(save)}>
<ActionGroup className="keycloak__form_actions"> <ActionGroup className="keycloak__form_actions">
<Button <Button