Advanced tab (#373)

* initial version of the advanced tab

* added registered nodes

* added fine grain open id connect configuration

* added open id connect compatibility section

* added advanced section

* added backend

* fixed type

* renamed 'advanced' to advancedtab

to prevent strange add of '/index.js' by snowpack

* fixed storybook stories

* change '_' to '-' because '_' is also used

* fix spacing buttons

* stop passing the form

* cypress test for advanced tab

* more tests

* saml section

* changed to use NumberInput

* added authetnication flow override

* fixed merge error

* updated text and added link to settings tab

* fixed test

* added filter on flows and better reset

* added now mandetory error handler

* added sorting

* Revert "changed to use NumberInput"

This reverts commit 7829f2656ae8fc8ed4a4a6b1c4b1961241a09d8e.

* allow users to put empty string as value

* already on detail page after save

* fixed merge error
This commit is contained in:
Erik Jan de Wit 2021-02-28 21:02:31 +01:00 committed by GitHub
parent da2fa32a69
commit bfa0c6e1ea
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
46 changed files with 2112 additions and 351 deletions

View file

@ -4,6 +4,8 @@ import ListingPage from "../support/pages/admin_console/ListingPage";
import SidebarPage from "../support/pages/admin_console/SidebarPage";
import CreateClientPage from "../support/pages/admin_console/manage/clients/CreateClientPage";
import ModalUtils from "../support/util/ModalUtils";
import AdvancedTab from "../support/pages/admin_console/manage/clients/AdvancedTab";
import AdminClient from "../support/util/AdminClient";
let itemId = "client_crud";
const loginPage = new LoginPage();
@ -77,4 +79,65 @@ describe("Clients test", function () {
listingPage.itemExist(itemId, false);
});
});
describe("Advanced tab test", () => {
const advancedTab = new AdvancedTab();
let client: string;
beforeEach(() => {
cy.visit("");
loginPage.logIn();
sidebarPage.goToClients();
client = "client_" + (Math.random() + 1).toString(36).substring(7);
listingPage.goToCreateItem();
createClientPage
.selectClientType("openid-connect")
.fillClientData(client)
.continue()
.continue();
advancedTab.goToTab();
});
afterEach(() => {
new AdminClient().deleteClient(client);
});
it("Revocation", () => {
advancedTab.checkNone();
advancedTab.clickSetToNow().checkSetToNow();
advancedTab.clickClear().checkNone();
advancedTab.clickPush();
masthead.checkNotificationMessage(
"No push sent. No admin URI configured or no registered cluster nodes available"
);
});
it("Clustering", () => {
advancedTab.expandClusterNode().checkTestClusterAvailability(false);
advancedTab
.clickRegisterNodeManually()
.fillHost("localhost")
.clickSaveHost();
advancedTab.checkTestClusterAvailability(true);
});
it("Fine grain OpenID connect configuration", () => {
const algorithm = "ES384";
advancedTab
.selectAccessTokenSignatureAlgorithm(algorithm)
.clickSaveFineGrain();
advancedTab
.selectAccessTokenSignatureAlgorithm("HS384")
.clickReloadFineGrain();
advancedTab.checkAccessTokenSignatureAlgorithm(algorithm);
});
});
});

View file

@ -0,0 +1,105 @@
import moment from "moment";
export default class AdvancedTab {
private setToNow = "#setToNow";
private clear = "#clear";
private push = "#push";
private notBefore = "#kc-not-before";
private clusterNodesExpand =
".pf-c-expandable-section .pf-c-expandable-section__toggle";
private testClusterAvailability = "#testClusterAvailability";
private registerNodeManually = "#registerNodeManually";
private nodeHost = "#nodeHost";
private addNodeConfirm = "#add-node-confirm";
private accessTokenSignatureAlgorithm = "#accessTokenSignatureAlgorithm";
private fineGrainSave = "#fineGrainSave";
private fineGrainReload = "#fineGrainReload";
private advancedTab = "#pf-tab-advanced-advanced";
goToTab() {
cy.get(this.advancedTab).click();
return this;
}
clickSetToNow() {
cy.get(this.setToNow).click();
return this;
}
clickClear() {
cy.get(this.clear).click();
return this;
}
clickPush() {
cy.get(this.push).click();
return this;
}
checkNone() {
cy.get(this.notBefore).should("have.value", "None");
return this;
}
checkSetToNow() {
cy.get(this.notBefore).should("have.value", moment().format("LLL"));
return this;
}
expandClusterNode() {
cy.get(this.clusterNodesExpand).click();
return this;
}
checkTestClusterAvailability(active: boolean) {
cy.get(this.testClusterAvailability).should(
(active ? "not." : "") + "have.class",
"pf-m-disabled"
);
return this;
}
clickRegisterNodeManually() {
cy.get(this.registerNodeManually).click();
return this;
}
fillHost(host: string) {
cy.get(this.nodeHost).type(host);
return this;
}
clickSaveHost() {
cy.get(this.addNodeConfirm).click();
return this;
}
selectAccessTokenSignatureAlgorithm(algorithm: string) {
cy.get(this.accessTokenSignatureAlgorithm).click();
cy.get(this.accessTokenSignatureAlgorithm + " + ul")
.contains(algorithm)
.click();
return this;
}
checkAccessTokenSignatureAlgorithm(algorithm: string) {
cy.get(this.accessTokenSignatureAlgorithm).should("have.text", algorithm);
return this;
}
clickSaveFineGrain() {
cy.get(this.fineGrainSave).click();
return this;
}
clickReloadFineGrain() {
cy.get(this.fineGrainReload).click();
return this;
}
}

View file

@ -22,4 +22,12 @@ export default class AdminClient {
await this.login();
await this.client.realms.del({ realm });
}
async deleteClient(clientName: string) {
await this.login();
const client = (
await this.client.clients.find({ clientId: clientName })
)[0];
await this.client.clients.del({ id: client.id! });
}
}

View file

@ -1,10 +0,0 @@
{
"compilerOptions": {
"target": "es5",
"lib": ["es5", "dom"],
"types": ["cypress"]
},
"include": [
"**/*.ts"
]
}

View file

@ -24,7 +24,7 @@
"@patternfly/react-table": "4.23.0",
"file-saver": "^2.0.2",
"i18next": "^19.6.2",
"keycloak-admin": "1.14.6",
"keycloak-admin": "1.14.7",
"lodash": "^4.17.20",
"moment": "^2.29.1",
"react": "^16.8.5",

View file

@ -69,13 +69,12 @@ export const ClientScopesSection = () => {
columns={[
{
name: "name",
displayKey: t("common:name"),
cellRenderer: ClientScopeDetailLink,
},
{ name: "description", displayKey: t("common:description") },
{ name: "description" },
{
name: "protocol",
displayKey: t("protocol"),
displayKey: "client-scopes:protocol",
},
]}
/>

View file

@ -299,7 +299,7 @@ export const RoleMappingForm = () => {
ref={register()}
type="text"
id="newRoleName"
name="config.new_role_name"
name="config.new-role-name"
/>
</FormGroup>
<ActionGroup>

View file

@ -211,7 +211,7 @@ export const MappingDetails = () => {
ref={register()}
type="text"
id="prefix"
name="config.usermodel_realmRoleMapping_rolePrefix"
name="config.usermodel-realmRoleMapping-rolePrefix"
/>
</FormGroup>
<FormGroup
@ -255,7 +255,7 @@ export const MappingDetails = () => {
ref={register()}
type="text"
id="claimName"
name="config.claim_name"
name="config.claim-name"
/>
</FormGroup>
<FormGroup
@ -270,7 +270,7 @@ export const MappingDetails = () => {
fieldId="claimJsonType"
>
<Controller
name="config.jsonType_label"
name="config.jsonType-label"
defaultValue=""
control={control}
render={({ onChange, value }) => (
@ -308,7 +308,7 @@ export const MappingDetails = () => {
<Flex>
<FlexItem>
<Controller
name="config.id_token_claim"
name="config.id-token-claim"
defaultValue="false"
control={control}
render={({ onChange, value }) => (
@ -323,7 +323,7 @@ export const MappingDetails = () => {
</FlexItem>
<FlexItem>
<Controller
name="config.access_token_claim"
name="config.access-token-claim"
defaultValue="false"
control={control}
render={({ onChange, value }) => (
@ -338,7 +338,7 @@ export const MappingDetails = () => {
</FlexItem>
<FlexItem>
<Controller
name="config.userinfo_token_claim"
name="config.userinfo-token-claim"
defaultValue="false"
control={control}
render={({ onChange, value }) => (

View file

@ -218,7 +218,7 @@ export const ClientScopeForm = () => {
fieldId="kc-display.on.consent.screen"
>
<Controller
name="attributes.display_on_consent_screen"
name="attributes.display-on-consent-screen"
control={control}
defaultValue="false"
render={({ onChange, value }) => (
@ -247,7 +247,7 @@ export const ClientScopeForm = () => {
ref={register}
type="text"
id="kc-consent-screen-text"
name="attributes.consent_screen_text"
name="attributes.consent-screen-text"
/>
</FormGroup>
<FormGroup
@ -263,7 +263,7 @@ export const ClientScopeForm = () => {
fieldId="includeInTokenScope"
>
<Controller
name="attributes.include_in_token_scope"
name="attributes.include-in-token-scope"
control={control}
defaultValue="false"
render={({ onChange, value }) => (
@ -298,7 +298,7 @@ export const ClientScopeForm = () => {
ref={register({ pattern: /^([0-9]*)$/ })}
type="text"
id="kc-gui-order"
name="attributes.gui_order"
name="attributes.gui-order"
validated={
errors.attributes && errors.attributes["gui_order"]
? ValidatedOptions.error

449
src/clients/AdvancedTab.tsx Normal file
View file

@ -0,0 +1,449 @@
import React, { useEffect, useState } from "react";
import { Link } from "react-router-dom";
import { Trans, useTranslation } from "react-i18next";
import { Controller, useFormContext } from "react-hook-form";
import moment from "moment";
import {
ActionGroup,
AlertVariant,
Button,
ButtonVariant,
Card,
CardBody,
ExpandableSection,
FormGroup,
Split,
SplitItem,
Text,
TextInput,
ToolbarItem,
} from "@patternfly/react-core";
import GlobalRequestResult from "keycloak-admin/lib/defs/globalRequestResult";
import ClientRepresentation from "keycloak-admin/lib/defs/clientRepresentation";
import { convertToFormValues, toUpperCase } from "../util";
import { FormAccess } from "../components/form-access/FormAccess";
import { ScrollForm } from "../components/scroll-form/ScrollForm";
import { HelpItem } from "../components/help-enabler/HelpItem";
import { KeycloakDataTable } from "../components/table-toolbar/KeycloakDataTable";
import { FineGrainOpenIdConnect } from "./advanced/FineGrainOpenIdConnect";
import { OpenIdConnectCompatibilityModes } from "./advanced/OpenIdConnectCompatibilityModes";
import { AdvancedSettings } from "./advanced/AdvancedSettings";
import { TimeSelector } from "../components/time-selector/TimeSelector";
import { useAdminClient } from "../context/auth/AdminClient";
import { useAlerts } from "../components/alert/Alerts";
import { useConfirmDialog } from "../components/confirm-dialog/ConfirmDialog";
import { AddHostDialog } from "./advanced/AddHostDialog";
import { FineGrainSamlEndpointConfig } from "./advanced/FineGrainSamlEndpointConfig";
import { AuthenticationOverrides } from "./advanced/AuthenticationOverrides";
import { useRealm } from "../context/realm-context/RealmContext";
type AdvancedProps = {
save: () => void;
client: ClientRepresentation;
};
export const AdvancedTab = ({
save,
client: {
id,
registeredNodes,
attributes,
protocol,
authenticationFlowBindingOverrides,
},
}: AdvancedProps) => {
const { t } = useTranslation("clients");
const adminClient = useAdminClient();
const { realm } = useRealm();
const { addAlert } = 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 setNotBefore = (time: number) => {
setValue(revocationFieldName, time);
save();
};
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 values: { [name: string]: string } = {};
for (const name of names) {
values[`attributes.${name}`] = attributes
? attributes[name.replace(/-/g, ".")] || ""
: "";
}
reset(values);
};
const push = async () => {
const result = ((await adminClient.clients.pushRevocation({
id: id!,
})) as unknown) as GlobalRequestResult;
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) {
addAlert(
t("deleteNodeFail", { error: error.response?.data?.error || error }),
AlertVariant.danger
);
}
},
});
useEffect(() => {
register(revocationFieldName);
}, [register]);
const formatDate = () => {
const date = getValues(revocationFieldName);
if (date > 0) {
return moment(date * 1000).format("LLL");
} else {
return t("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"));
}
return (
<ScrollForm sections={sections}>
<Card>
<CardBody>
<Text>
<Trans i18nKey="clients-help:notBeforeIntro">
In order to successfully push setup url on
<Link to={`/${realm}/clients/${id}/settings`}>
{t("settings")}
</Link>
tab
</Trans>
</Text>
</CardBody>
<CardBody>
<FormAccess role="manage-clients" isHorizontal>
<FormGroup
label={t("notBefore")}
fieldId="kc-not-before"
labelIcon={
<HelpItem
helpText="clients-help:notBefore"
forLabel={t("notBefore")}
forID="kc-not-before"
/>
}
>
<TextInput
type="text"
id="kc-not-before"
name="notBefore"
isReadOnly
value={formatDate()}
/>
</FormGroup>
<ActionGroup>
<Button
id="setToNow"
variant="tertiary"
onClick={() => setNotBefore(moment.now() / 1000)}
>
{t("setToNow")}
</Button>
<Button
id="clear"
variant="tertiary"
onClick={() => setNotBefore(0)}
>
{t("clear")}
</Button>
<Button id="push" variant="secondary" onClick={push}>
{t("push")}
</Button>
</ActionGroup>
</FormAccess>
</CardBody>
</Card>
<Card>
<CardBody>
<FormAccess role="manage-clients" isHorizontal>
<FormGroup
label={t("nodeReRegistrationTimeout")}
fieldId="kc-node-reregistration-timeout"
labelIcon={
<HelpItem
helpText="clients-help:nodeReRegistrationTimeout"
forLabel={t("nodeReRegistrationTimeout")}
forID="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>
</CardBody>
<CardBody>
<DeleteNodeConfirm />
<AddHostDialog
clientId={id!}
isOpen={addNodeOpen}
onAdded={(node) => {
nodes[node] = moment.now() / 1000;
refresh();
}}
onClose={() => setAddNodeOpen(false)}
/>
<ExpandableSection
toggleText={t("registeredClusterNodes")}
onToggle={() => setExpanded(!expanded)}
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"
)
: "",
],
},
]}
/>
</ExpandableSection>
</CardBody>
</Card>
<Card>
{protocol === openIdConnect && (
<>
<CardBody>
<Text>
{t("clients-help:fineGrainOpenIdConnectConfiguration")}
</Text>
</CardBody>
<CardBody>
<FineGrainOpenIdConnect
control={control}
save={save}
reset={() =>
convertToFormValues(attributes, "attributes", setValue)
}
/>
</CardBody>
</>
)}
{protocol !== openIdConnect && (
<>
<CardBody>
<Text>{t("clients-help:fineGrainSamlEndpointConfig")}</Text>
</CardBody>
<CardBody>
<FineGrainSamlEndpointConfig
control={control}
save={save}
reset={() =>
convertToFormValues(attributes, "attributes", setValue)
}
/>
</CardBody>
</>
)}
</Card>
{protocol === openIdConnect && (
<Card>
<CardBody>
<Text>{t("clients-help:openIdConnectCompatibilityModes")}</Text>
</CardBody>
<CardBody>
<OpenIdConnectCompatibilityModes
control={control}
save={save}
reset={() =>
resetFields(["exclude-session-state-from-auth-response"])
}
/>
</CardBody>
</Card>
)}
<Card>
<CardBody>
<Text>
{t("clients-help:advancedSettings" + toUpperCase(protocol!))}
</Text>
</CardBody>
<CardBody>
<AdvancedSettings
protocol={protocol}
control={control}
save={save}
reset={() => {
resetFields([
"saml-assertion-lifespan",
"access-token-lifespan",
"tls-client-certificate-bound-access-tokens",
"pkce-code-challenge-method",
]);
}}
/>
</CardBody>
</Card>
<Card>
<CardBody>
<Text>{t("clients-help:authenticationOverrides")}</Text>
</CardBody>
<CardBody>
<AuthenticationOverrides
protocol={protocol}
control={control}
save={save}
reset={() => {
setValue(
"authenticationFlowBindingOverrides.browser",
authenticationFlowBindingOverrides?.browser
);
setValue(
"authenticationFlowBindingOverrides.direct_grant",
authenticationFlowBindingOverrides?.direct_grant
);
}}
/>
</CardBody>
</Card>
</ScrollForm>
);
};

View file

@ -1,16 +1,14 @@
import React from "react";
import { FormGroup, TextInput, ValidatedOptions } from "@patternfly/react-core";
import { UseFormMethods } from "react-hook-form";
import { useFormContext } from "react-hook-form";
import { useTranslation } from "react-i18next";
import { FormAccess } from "../components/form-access/FormAccess";
import { ClientForm } from "./ClientDetails";
type ClientDescriptionProps = {
form: UseFormMethods;
};
export const ClientDescription = ({ form }: ClientDescriptionProps) => {
export const ClientDescription = () => {
const { t } = useTranslation("clients");
const { register, errors } = form;
const { register, errors } = useFormContext<ClientForm>();
return (
<FormAccess role="manage-clients" unWrap>
<FormGroup

View file

@ -11,13 +11,14 @@ import {
import { useParams } from "react-router-dom";
import { useErrorHandler } from "react-error-boundary";
import { useTranslation } from "react-i18next";
import { Controller, useForm, useWatch } from "react-hook-form";
import { Controller, FormProvider, useForm, useWatch } from "react-hook-form";
import ClientRepresentation from "keycloak-admin/lib/defs/clientRepresentation";
import _ from "lodash";
import { ClientSettings } from "./ClientSettings";
import { useAlerts } from "../components/alert/Alerts";
import { useConfirmDialog } from "../components/confirm-dialog/ConfirmDialog";
import { useDownloadDialog } from "../components/download-dialog/DownloadDialog";
import { DownloadDialog } from "../components/download-dialog/DownloadDialog";
import { ViewHeader } from "../components/view-header/ViewHeader";
import { useAdminClient, asyncStateFetch } from "../context/auth/AdminClient";
import { Credentials } from "./credentials/Credentials";
@ -28,6 +29,7 @@ import {
} from "../util";
import {
convertToMultiline,
MultiLine,
toValue,
} from "../components/multi-line-input/MultiLineInput";
import { ClientScopes } from "./scopes/ClientScopes";
@ -35,6 +37,7 @@ import { EvaluateScopes } from "./scopes/EvaluateScopes";
import { RolesList } from "../realm-roles/RolesList";
import { ServiceAccount } from "./service-account/ServiceAccount";
import { KeycloakTabs } from "../components/keycloak-tabs/KeycloakTabs";
import { AdvancedTab } from "./AdvancedTab";
type ClientDetailHeaderProps = {
onChange: (value: boolean) => void;
@ -94,14 +97,24 @@ const ClientDetailHeader = ({
);
};
export type ClientForm = Omit<
ClientRepresentation,
"redirectUris" | "webOrigins"
> & {
redirectUris: MultiLine[];
webOrigins: MultiLine[];
};
export const ClientDetails = () => {
const { t } = useTranslation("clients");
const adminClient = useAdminClient();
const handleError = useErrorHandler();
const { addAlert } = useAlerts();
const [downloadDialogOpen, setDownloadDialogOpen] = useState(false);
const toggleDownloadDialog = () => setDownloadDialogOpen(!downloadDialogOpen);
const form = useForm();
const form = useForm<ClientForm>();
const publicClient = useWatch({
control: form.control,
name: "publicClient",
@ -114,18 +127,7 @@ export const ClientDetails = () => {
const loader = async () => {
const roles = await adminClient.clients.listRoles({ id });
return roles.sort((r1, r2) => {
const r1Name = r1.name?.toUpperCase();
const r2Name = r2.name?.toUpperCase();
if (r1Name! < r2Name!) {
return -1;
}
if (r1Name! > r2Name!) {
return 1;
}
return 0;
});
return _.sortBy(roles, (role) => role.name?.toUpperCase());
};
const [toggleDeleteDialog, DeleteConfirm] = useConfirmDialog({
@ -143,13 +145,10 @@ export const ClientDetails = () => {
},
});
const [toggleDownloadDialog, DownloadDialog] = useDownloadDialog({
id,
protocol: form.getValues("protocol"),
});
const setupForm = (client: ClientRepresentation) => {
form.reset(client);
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const { redirectUris, webOrigins, ...formValues } = client;
form.reset(formValues);
Object.entries(client).map((entry) => {
if (entry[0] === "redirectUris" || entry[0] === "webOrigins") {
form.setValue(entry[0], convertToMultiline(entry[1]));
@ -170,26 +169,27 @@ export const ClientDetails = () => {
},
handleError
);
}, []);
}, [id]);
const save = async () => {
if (await form.trigger()) {
const redirectUris = toValue(form.getValues()["redirectUris"]);
const webOrigins = toValue(form.getValues()["webOrigins"]);
const attributes = form.getValues()["attributes"]
? convertFormValuesToObject(form.getValues()["attributes"])
: {};
const attributes = convertFormValuesToObject(
form.getValues()["attributes"]
);
try {
const client = {
const newClient: ClientRepresentation = {
...client,
...form.getValues(),
redirectUris,
webOrigins,
attributes,
};
await adminClient.clients.update({ id }, client);
setupForm(client as ClientRepresentation);
setClient(client);
await adminClient.clients.update({ id }, newClient);
setupForm(newClient);
setClient(newClient);
addAlert(t("clientSaveSuccess"), AlertVariant.success);
} catch (error) {
addAlert(`${t("clientSaveError")} '${error}'`, AlertVariant.danger);
@ -207,7 +207,12 @@ export const ClientDetails = () => {
return (
<>
<DeleteConfirm />
<DownloadDialog />
<DownloadDialog
id={client.id!}
protocol={client.protocol}
open={downloadDialogOpen}
toggleDialog={toggleDownloadDialog}
/>
<Controller
name="enabled"
control={form.control}
@ -224,39 +229,46 @@ export const ClientDetails = () => {
)}
/>
<PageSection variant="light">
<FormProvider {...form}>
<KeycloakTabs isBox>
<Tab
id="settings"
eventKey="settings"
title={<TabTitleText>{t("common:settings")}</TabTitleText>}
>
<ClientSettings form={form} save={save} />
<ClientSettings save={save} />
</Tab>
{publicClient && (
<Tab
id="credentials"
eventKey="credentials"
title={<TabTitleText>{t("credentials")}</TabTitleText>}
>
<Credentials clientId={id} form={form} save={save} />
<Credentials clientId={id} save={save} />
</Tab>
)}
<Tab
id="roles"
eventKey="roles"
title={<TabTitleText>{t("roles")}</TabTitleText>}
>
<RolesList loader={loader} paginated={false} />
</Tab>
<Tab
id="clientScopes"
eventKey="clientScopes"
title={<TabTitleText>{t("clientScopes")}</TabTitleText>}
>
<KeycloakTabs paramName="subtab" isSecondary>
<Tab
id="setup"
eventKey="setup"
title={<TabTitleText>{t("setup")}</TabTitleText>}
>
<ClientScopes clientId={id} protocol={client!.protocol!} />
</Tab>
<Tab
id="evaluate"
eventKey="evaluate"
title={<TabTitleText>{t("evaluate")}</TabTitleText>}
>
@ -264,15 +276,24 @@ export const ClientDetails = () => {
</Tab>
</KeycloakTabs>
</Tab>
{client && client.serviceAccountsEnabled && (
{client!.serviceAccountsEnabled && (
<Tab
id="serviceAccount"
eventKey="serviceAccount"
title={<TabTitleText>{t("serviceAccount")}</TabTitleText>}
>
<ServiceAccount clientId={id} />
</Tab>
)}
<Tab
id="advanced"
eventKey="advanced"
title={<TabTitleText>{t("advanced")}</TabTitleText>}
>
<AdvancedTab save={save} client={client} />
</Tab>
</KeycloakTabs>
</FormProvider>
</PageSection>
</>
);

View file

@ -9,7 +9,7 @@ import {
ActionGroup,
Button,
} from "@patternfly/react-core";
import { Controller, UseFormMethods } from "react-hook-form";
import { Controller, useFormContext } from "react-hook-form";
import { ScrollForm } from "../components/scroll-form/ScrollForm";
import { ClientDescription } from "./ClientDescription";
@ -19,11 +19,11 @@ import { FormAccess } from "../components/form-access/FormAccess";
import { HelpItem } from "../components/help-enabler/HelpItem";
type ClientSettingsProps = {
form: UseFormMethods;
save: () => void;
};
export const ClientSettings = ({ form, save }: ClientSettingsProps) => {
export const ClientSettings = ({ save }: ClientSettingsProps) => {
const { register, control } = useFormContext();
const { t } = useTranslation("clients");
return (
@ -36,9 +36,9 @@ export const ClientSettings = ({ form, save }: ClientSettingsProps) => {
t("loginSettings"),
]}
>
<CapabilityConfig form={form} />
<CapabilityConfig />
<Form isHorizontal>
<ClientDescription form={form} />
<ClientDescription />
</Form>
<FormAccess isHorizontal role="manage-clients">
<FormGroup label={t("rootUrl")} fieldId="kc-root-url">
@ -46,18 +46,18 @@ export const ClientSettings = ({ form, save }: ClientSettingsProps) => {
type="text"
id="kc-root-url"
name="rootUrl"
ref={form.register}
ref={register}
/>
</FormGroup>
<FormGroup label={t("validRedirectUri")} fieldId="kc-redirect">
<MultiLineInput form={form} name="redirectUris" />
<MultiLineInput name="redirectUris" />
</FormGroup>
<FormGroup label={t("homeURL")} fieldId="kc-home-url">
<TextInput
type="text"
id="kc-home-url"
name="baseUrl"
ref={form.register}
ref={register}
/>
</FormGroup>
<FormGroup
@ -71,7 +71,7 @@ export const ClientSettings = ({ form, save }: ClientSettingsProps) => {
/>
}
>
<MultiLineInput form={form} name="webOrigins" />
<MultiLineInput name="webOrigins" />
</FormGroup>
<FormGroup
label={t("adminURL")}
@ -88,7 +88,7 @@ export const ClientSettings = ({ form, save }: ClientSettingsProps) => {
type="text"
id="kc-admin-url"
name="adminUrl"
ref={form.register}
ref={register}
/>
</FormGroup>
</FormAccess>
@ -101,7 +101,7 @@ export const ClientSettings = ({ form, save }: ClientSettingsProps) => {
<Controller
name="consentRequired"
defaultValue={false}
control={form.control}
control={control}
render={({ onChange, value }) => (
<Switch
id="kc-consent"
@ -119,16 +119,16 @@ export const ClientSettings = ({ form, save }: ClientSettingsProps) => {
hasNoPaddingTop
>
<Controller
name="attributes.display_on_consent_screen"
name="attributes.display-on-consent-screen"
defaultValue={false}
control={form.control}
control={control}
render={({ onChange, value }) => (
<Switch
id="kc-display-on-client"
label={t("common:on")}
labelOff={t("common:off")}
isChecked={value}
onChange={onChange}
isChecked={value === "true"}
onChange={(value) => onChange("" + value)}
/>
)}
/>
@ -139,12 +139,12 @@ export const ClientSettings = ({ form, save }: ClientSettingsProps) => {
>
<TextArea
id="kc-consent-screen-text"
name="attributes.consent_screen_text"
ref={form.register}
name="attributes.consent-screen-text"
ref={register}
/>
</FormGroup>
<ActionGroup>
<Button variant="primary" onClick={() => save()}>
<Button variant="primary" onClick={save}>
{t("common:save")}
</Button>
<Button variant="link">{t("common:cancel")}</Button>

View file

@ -7,16 +7,14 @@ import {
Grid,
GridItem,
} from "@patternfly/react-core";
import { UseFormMethods, Controller } from "react-hook-form";
import { Controller, useFormContext } from "react-hook-form";
import { FormAccess } from "../../components/form-access/FormAccess";
import { ClientForm } from "../ClientDetails";
type CapabilityConfigProps = {
form: UseFormMethods;
};
export const CapabilityConfig = ({ form }: CapabilityConfigProps) => {
export const CapabilityConfig = () => {
const { t } = useTranslation("clients");
const { control } = useFormContext<ClientForm>();
return (
<FormAccess isHorizontal role="manage-clients">
<FormGroup
@ -27,7 +25,7 @@ export const CapabilityConfig = ({ form }: CapabilityConfigProps) => {
<Controller
name="publicClient"
defaultValue={false}
control={form.control}
control={control}
render={({ onChange, value }) => (
<Switch
id="kc-authentication"
@ -48,7 +46,7 @@ export const CapabilityConfig = ({ form }: CapabilityConfigProps) => {
<Controller
name="authorizationServicesEnabled"
defaultValue={false}
control={form.control}
control={control}
render={({ onChange, value }) => (
<Switch
id="kc-authorization"
@ -71,7 +69,7 @@ export const CapabilityConfig = ({ form }: CapabilityConfigProps) => {
<Controller
name="standardFlowEnabled"
defaultValue={false}
control={form.control}
control={control}
render={({ onChange, value }) => (
<Checkbox
label={t("standardFlow")}
@ -87,7 +85,7 @@ export const CapabilityConfig = ({ form }: CapabilityConfigProps) => {
<Controller
name="directAccessGrantsEnabled"
defaultValue={false}
control={form.control}
control={control}
render={({ onChange, value }) => (
<Checkbox
label={t("directAccess")}
@ -103,7 +101,7 @@ export const CapabilityConfig = ({ form }: CapabilityConfigProps) => {
<Controller
name="implicitFlowEnabled"
defaultValue={false}
control={form.control}
control={control}
render={({ onChange, value }) => (
<Checkbox
label={t("implicitFlow")}
@ -119,7 +117,7 @@ export const CapabilityConfig = ({ form }: CapabilityConfigProps) => {
<Controller
name="serviceAccountsEnabled"
defaultValue={false}
control={form.control}
control={control}
render={({ onChange, value }) => (
<Checkbox
label={t("serviceAccount")}

View file

@ -6,19 +6,15 @@ import {
SelectOption,
} from "@patternfly/react-core";
import { useTranslation } from "react-i18next";
import { Controller, UseFormMethods } from "react-hook-form";
import { Controller, useFormContext } from "react-hook-form";
import { useLoginProviders } from "../../context/server-info/ServerInfoProvider";
import { ClientDescription } from "../ClientDescription";
import { FormAccess } from "../../components/form-access/FormAccess";
type GeneralSettingsProps = {
form: UseFormMethods;
};
export const GeneralSettings = ({ form }: GeneralSettingsProps) => {
export const GeneralSettings = () => {
const { t } = useTranslation();
const { errors, control } = form;
const { errors, control } = useFormContext();
const providers = useLoginProviders();
const [open, isOpen] = useState(false);
@ -63,7 +59,7 @@ export const GeneralSettings = ({ form }: GeneralSettingsProps) => {
)}
/>
</FormGroup>
<ClientDescription form={form} />
<ClientDescription />
</FormAccess>
);
};

View file

@ -9,7 +9,7 @@ import {
Button,
} from "@patternfly/react-core";
import { useTranslation } from "react-i18next";
import { useForm } from "react-hook-form";
import { FormProvider, useForm } from "react-hook-form";
import { GeneralSettings } from "./GeneralSettings";
import { CapabilityConfig } from "./CapabilityConfig";
@ -99,6 +99,7 @@ export const NewClientForm = () => {
subKey="clients:clientsExplain"
/>
<PageSection variant="light">
<FormProvider {...methods}>
<Wizard
onClose={() => history.push(`/${realm}/clients`)}
navAriaLabel={`${title} steps`}
@ -106,16 +107,17 @@ export const NewClientForm = () => {
steps={[
{
name: t("generalSettings"),
component: <GeneralSettings form={methods} />,
component: <GeneralSettings />,
},
{
name: t("capabilityConfig"),
component: <CapabilityConfig form={methods} />,
component: <CapabilityConfig />,
},
]}
footer={<Footer />}
onSave={() => save()}
/>
</FormProvider>
</PageSection>
</>
);

View file

@ -0,0 +1,88 @@
import React from "react";
import { useForm } from "react-hook-form";
import { useTranslation } from "react-i18next";
import {
AlertVariant,
Button,
ButtonVariant,
Form,
FormGroup,
Modal,
TextInput,
} from "@patternfly/react-core";
import { useAdminClient } from "../../context/auth/AdminClient";
import { useAlerts } from "../../components/alert/Alerts";
type Host = {
node: string;
};
type AddHostDialogProps = {
clientId: string;
isOpen: boolean;
onAdded: (host: string) => void;
onClose: () => void;
};
export const AddHostDialog = ({
clientId: id,
isOpen,
onAdded,
onClose,
}: AddHostDialogProps) => {
const { t } = useTranslation("clients");
const { register, getValues } = useForm<Host>();
const adminClient = useAdminClient();
const { addAlert } = useAlerts();
return (
<Modal
title={t("addNode")}
isOpen={isOpen}
onClose={onClose}
variant="small"
actions={[
<Button
id="add-node-confirm"
key="confirm"
onClick={async () => {
try {
const node = getValues("node");
await adminClient.clients.addClusterNode({
id,
node,
});
onAdded(node);
addAlert(t("addedNodeSuccess"), AlertVariant.success);
} catch (error) {
addAlert(
t("addedNodeFail", {
error: error.response?.data?.error || error,
}),
AlertVariant.danger
);
}
onClose();
}}
>
{t("common:save")}
</Button>,
<Button
id="add-node-cancel"
key="cancel"
variant={ButtonVariant.secondary}
onClick={() => onClose()}
>
{t("common:cancel")}
</Button>,
]}
>
<Form isHorizontal>
<FormGroup label={t("nodeHost")} fieldId="nodeHost">
<TextInput id="nodeHost" ref={register} name="node" />
</FormGroup>
</Form>
</Modal>
);
};

View file

@ -0,0 +1,162 @@
import React, { useState } from "react";
import { useTranslation } from "react-i18next";
import { Control, Controller } from "react-hook-form";
import {
ActionGroup,
Button,
FormGroup,
Select,
SelectOption,
SelectVariant,
Switch,
} from "@patternfly/react-core";
import { FormAccess } from "../../components/form-access/FormAccess";
import { HelpItem } from "../../components/help-enabler/HelpItem";
import { TimeSelector } from "../../components/time-selector/TimeSelector";
type AdvancedSettingsProps = {
control: Control<Record<string, any>>;
save: () => void;
reset: () => void;
protocol?: string;
};
export const AdvancedSettings = ({
control,
save,
reset,
protocol,
}: AdvancedSettingsProps) => {
const { t } = useTranslation("clients");
const [open, setOpen] = useState(false);
return (
<FormAccess role="manage-realm" isHorizontal>
{protocol !== "openid-connect" && (
<FormGroup
label={t("assertionLifespan")}
fieldId="assertionLifespan"
labelIcon={
<HelpItem
helpText="clients-help:assertionLifespan"
forLabel={t("assertionLifespan")}
forID="assertionLifespan"
/>
}
>
<Controller
name="attributes.saml-assertion-lifespan"
defaultValue=""
control={control}
render={({ onChange, value }) => (
<TimeSelector
units={["minutes", "days", "hours"]}
value={value}
onChange={onChange}
/>
)}
/>
</FormGroup>
)}
{protocol === "openid-connect" && (
<>
<FormGroup
label={t("accessTokenLifespan")}
fieldId="accessTokenLifespan"
labelIcon={
<HelpItem
helpText="clients-help:accessTokenLifespan"
forLabel={t("accessTokenLifespan")}
forID="accessTokenLifespan"
/>
}
>
<Controller
name="attributes.access-token-lifespan"
defaultValue=""
control={control}
render={({ onChange, value }) => (
<TimeSelector
units={["minutes", "days", "hours"]}
value={value}
onChange={onChange}
/>
)}
/>
</FormGroup>
<FormGroup
label={t("oAuthMutual")}
fieldId="oAuthMutual"
hasNoPaddingTop
labelIcon={
<HelpItem
helpText="clients-help:oAuthMutual"
forLabel={t("oAuthMutual")}
forID="oAuthMutual"
/>
}
>
<Controller
name="attributes.tls-client-certificate-bound-access-tokens"
defaultValue={false}
control={control}
render={({ onChange, value }) => (
<Switch
id="oAuthMutual"
label={t("common:on")}
labelOff={t("common:off")}
isChecked={value === "true"}
onChange={(value) => onChange("" + value)}
/>
)}
/>
</FormGroup>
<FormGroup
label={t("keyForCodeExchange")}
fieldId="keyForCodeExchange"
hasNoPaddingTop
labelIcon={
<HelpItem
helpText="clients-help:keyForCodeExchange"
forLabel={t("keyForCodeExchange")}
forID="keyForCodeExchange"
/>
}
>
<Controller
name="attributes.pkce-code-challenge-method"
defaultValue={false}
control={control}
render={({ onChange, value }) => (
<Select
toggleId="keyForCodeExchange"
variant={SelectVariant.single}
onToggle={() => setOpen(!open)}
isOpen={open}
onSelect={(_, value) => {
onChange(value);
setOpen(false);
}}
selections={[value]}
>
{["", "S256", "plain"].map((v) => (
<SelectOption key={v} value={v} />
))}
</Select>
)}
/>
</FormGroup>
</>
)}
<ActionGroup>
<Button variant="tertiary" onClick={save}>
{t("common:save")}
</Button>
<Button variant="link" onClick={reset}>
{t("common:reload")}
</Button>
</ActionGroup>
</FormAccess>
);
};

View file

@ -0,0 +1,137 @@
import React, { useEffect, useState } from "react";
import { Control, Controller } from "react-hook-form";
import { useTranslation } from "react-i18next";
import _ from "lodash";
import {
FormGroup,
Select,
SelectOption,
SelectVariant,
} from "@patternfly/react-core";
import { FormAccess } from "../../components/form-access/FormAccess";
import { HelpItem } from "../../components/help-enabler/HelpItem";
import {
asyncStateFetch,
useAdminClient,
} from "../../context/auth/AdminClient";
import { SaveReset } from "./SaveReset";
import { useErrorHandler } from "react-error-boundary";
type AuthenticationOverridesProps = {
control: Control<Record<string, any>>;
save: () => void;
reset: () => void;
protocol?: string;
};
export const AuthenticationOverrides = ({
protocol,
control,
save,
reset,
}: AuthenticationOverridesProps) => {
const adminClient = useAdminClient();
const { t } = useTranslation("clients");
const [flows, setFlows] = useState<JSX.Element[]>([]);
const handleError = useErrorHandler();
const [browserFlowOpen, setBrowserFlowOpen] = useState(false);
const [directGrantOpen, setDirectGrantOpen] = useState(false);
useEffect(
() =>
asyncStateFetch(
() => adminClient.authenticationManagement.getFlows(),
(flows) => {
let filteredFlows = [
...flows.filter((flow) => flow.providerId !== "client-flow"),
];
filteredFlows = _.sortBy(filteredFlows, [(f) => f.alias]);
setFlows([
<SelectOption key="empty" value="">
{t("common:choose")}
</SelectOption>,
...filteredFlows.map((flow) => (
<SelectOption key={flow.id} value={flow.id}>
{flow.alias}
</SelectOption>
)),
]);
},
handleError
),
[]
);
return (
<FormAccess role="manage-clients" isHorizontal>
<FormGroup
label={t("browserFlow")}
fieldId="browserFlow"
labelIcon={
<HelpItem
helpText="clients-help:browserFlow"
forLabel={t("browserFlow")}
forID="browserFlow"
/>
}
>
<Controller
name="authenticationFlowBindingOverrides.browser"
defaultValue=""
control={control}
render={({ onChange, value }) => (
<Select
toggleId="browserFlow"
variant={SelectVariant.single}
onToggle={() => setBrowserFlowOpen(!browserFlowOpen)}
isOpen={browserFlowOpen}
onSelect={(_, value) => {
onChange(value);
setBrowserFlowOpen(false);
}}
selections={[value]}
>
{flows}
</Select>
)}
/>
</FormGroup>
{protocol === "openid-connect" && (
<FormGroup
label={t("directGrant")}
fieldId="directGrant"
labelIcon={
<HelpItem
helpText="clients-help:directGrant"
forLabel={t("directGrant")}
forID="directGrant"
/>
}
>
<Controller
name="authenticationFlowBindingOverrides.direct_grant"
defaultValue=""
control={control}
render={({ onChange, value }) => (
<Select
toggleId="directGrant"
variant={SelectVariant.single}
onToggle={() => setDirectGrantOpen(!directGrantOpen)}
isOpen={directGrantOpen}
onSelect={(_, value) => {
onChange(value);
setDirectGrantOpen(false);
}}
selections={[value]}
>
{flows}
</Select>
)}
/>
</FormGroup>
)}
<SaveReset name="authenticationOverrides" save={save} reset={reset} />
</FormAccess>
);
};

View file

@ -0,0 +1,357 @@
import React, { useState } from "react";
import { useTranslation } from "react-i18next";
import { Control, Controller } from "react-hook-form";
import {
ActionGroup,
Button,
FormGroup,
Select,
SelectOption,
SelectVariant,
} from "@patternfly/react-core";
import { FormAccess } from "../../components/form-access/FormAccess";
import { HelpItem } from "../../components/help-enabler/HelpItem";
import { useServerInfo } from "../../context/server-info/ServerInfoProvider";
import { sortProviders } from "../../util";
type FineGrainOpenIdConnectProps = {
control: Control<Record<string, any>>;
save: () => void;
reset: () => void;
};
export const FineGrainOpenIdConnect = ({
control,
save,
reset,
}: FineGrainOpenIdConnectProps) => {
const { t } = useTranslation("clients");
const providers = useServerInfo().providers;
const clientSignatureProviders = providers?.clientSignature.providers;
const contentEncryptionProviders = providers?.contentencryption.providers;
const cekManagementProviders = providers?.cekmanagement.providers;
const signatureProviders = providers?.signature.providers;
const [accessTokenOpen, setAccessTokenOpen] = useState(false);
const [idTokenOpen, setIdTokenOpen] = useState(false);
const [idTokenKeyManagementOpen, setIdTokenKeyManagementOpen] = useState(
false
);
const [idTokenContentOpen, setIdTokenContentOpen] = useState(false);
const [userInfoSignedResponseOpen, setUserInfoSignedResponseOpen] = useState(
false
);
const [requestObjectSignatureOpen, setRequestObjectSignatureOpen] = useState(
false
);
const [requestObjectRequiredOpen, setRequestObjectRequiredOpen] = useState(
false
);
const keyOptions = [
<SelectOption key="empty" value="">
{t("common:choose")}
</SelectOption>,
...sortProviders(clientSignatureProviders!).map((p) => (
<SelectOption key={p} value={p} />
)),
];
const cekManagementOptions = [
<SelectOption key="empty" value="">
{t("common:choose")}
</SelectOption>,
...sortProviders(cekManagementProviders!).map((p) => (
<SelectOption key={p} value={p} />
)),
];
const signatureOptions = [
<SelectOption key="unsigned" value="">
{t("unsigned")}
</SelectOption>,
...sortProviders(signatureProviders!).map((p) => (
<SelectOption key={p} value={p} />
)),
];
const contentOptions = [
<SelectOption key="empty" value="">
{t("common:choose")}
</SelectOption>,
...sortProviders(contentEncryptionProviders!).map((p) => (
<SelectOption key={p} value={p} />
)),
];
const requestObjectOptions = [
<SelectOption key="any" value="any">
{t("any")}
</SelectOption>,
<SelectOption key="none" value="none">
{t("none")}
</SelectOption>,
...sortProviders(clientSignatureProviders!).map((p) => (
<SelectOption key={p} value={p} />
)),
];
const requestObjectRequiredOptions = [
"not required",
"request or request_uri",
"request only",
"request_uri only",
].map((p) => (
<SelectOption key={p} value={p}>
{t(`requestObject.${p}`)}
</SelectOption>
));
const selectOptionToString = (value: string, options: JSX.Element[]) => {
const selectOption = options.find((s) => s.props.value === value);
return selectOption?.props.children || selectOption?.props.value;
};
return (
<FormAccess role="manage-clients" isHorizontal>
<FormGroup
label={t("accessTokenSignatureAlgorithm")}
fieldId="accessTokenSignatureAlgorithm"
labelIcon={
<HelpItem
helpText="clients-help:accessTokenSignatureAlgorithm"
forLabel={t("accessTokenSignatureAlgorithm")}
forID="accessTokenSignatureAlgorithm"
/>
}
>
<Controller
name="attributes.access-token-signed-response-alg"
defaultValue=""
control={control}
render={({ onChange, value }) => (
<Select
toggleId="accessTokenSignatureAlgorithm"
variant={SelectVariant.single}
onToggle={() => setAccessTokenOpen(!accessTokenOpen)}
isOpen={accessTokenOpen}
onSelect={(_, value) => {
onChange(value);
setAccessTokenOpen(false);
}}
selections={[selectOptionToString(value, keyOptions)]}
>
{keyOptions}
</Select>
)}
/>
</FormGroup>
<FormGroup
label={t("idTokenSignatureAlgorithm")}
fieldId="kc-id-token-signature"
labelIcon={
<HelpItem
helpText="clients-help:idTokenSignatureAlgorithm"
forLabel={t("idTokenSignatureAlgorithm")}
forID="idTokenSignatureAlgorithm"
/>
}
>
<Controller
name="attributes.id-token-signed-response-alg"
defaultValue=""
control={control}
render={({ onChange, value }) => (
<Select
toggleId="idTokenSignatureAlgorithm"
variant={SelectVariant.single}
onToggle={() => setIdTokenOpen(!idTokenOpen)}
isOpen={idTokenOpen}
onSelect={(_, value) => {
onChange(value);
setIdTokenOpen(false);
}}
selections={[selectOptionToString(value, keyOptions)]}
>
{keyOptions}
</Select>
)}
/>
</FormGroup>
<FormGroup
label={t("idTokenEncryptionKeyManagementAlgorithm")}
fieldId="idTokenEncryptionKeyManagementAlgorithm"
labelIcon={
<HelpItem
helpText="clients-help:idTokenEncryptionKeyManagementAlgorithm"
forLabel={t("idTokenEncryptionKeyManagementAlgorithm")}
forID="idTokenEncryptionKeyManagementAlgorithm"
/>
}
>
<Controller
name="attributes.id-token-encrypted-response-alg"
defaultValue=""
control={control}
render={({ onChange, value }) => (
<Select
toggleId="idTokenEncryptionKeyManagementAlgorithm"
variant={SelectVariant.single}
onToggle={() =>
setIdTokenKeyManagementOpen(!idTokenKeyManagementOpen)
}
isOpen={idTokenKeyManagementOpen}
onSelect={(_, value) => {
onChange(value);
setIdTokenKeyManagementOpen(false);
}}
selections={[selectOptionToString(value, cekManagementOptions)]}
>
{cekManagementOptions}
</Select>
)}
/>
</FormGroup>
<FormGroup
label={t("idTokenEncryptionContentEncryptionAlgorithm")}
fieldId="idTokenEncryptionContentEncryptionAlgorithm"
labelIcon={
<HelpItem
helpText="clients-help:idTokenEncryptionContentEncryptionAlgorithm"
forLabel={t("idTokenEncryptionContentEncryptionAlgorithm")}
forID="idTokenEncryptionContentEncryptionAlgorithm"
/>
}
>
<Controller
name="attributes.id-token-encrypted-response-enc"
defaultValue=""
control={control}
render={({ onChange, value }) => (
<Select
toggleId="idTokenEncryptionContentEncryptionAlgorithm"
variant={SelectVariant.single}
onToggle={() => setIdTokenContentOpen(!idTokenContentOpen)}
isOpen={idTokenContentOpen}
onSelect={(_, value) => {
onChange(value);
setIdTokenContentOpen(false);
}}
selections={[selectOptionToString(value, contentOptions)]}
>
{contentOptions}
</Select>
)}
/>
</FormGroup>
<FormGroup
label={t("userInfoSignedResponseAlgorithm")}
fieldId="userInfoSignedResponseAlgorithm"
labelIcon={
<HelpItem
helpText="clients-help:userInfoSignedResponseAlgorithm"
forLabel={t("userInfoSignedResponseAlgorithm")}
forID="userInfoSignedResponseAlgorithm"
/>
}
>
<Controller
name="attributes.user-info-response-signature-alg"
defaultValue=""
control={control}
render={({ onChange, value }) => (
<Select
toggleId="userInfoSignedResponseAlgorithm"
variant={SelectVariant.single}
onToggle={() =>
setUserInfoSignedResponseOpen(!userInfoSignedResponseOpen)
}
isOpen={userInfoSignedResponseOpen}
onSelect={(_, value) => {
onChange(value);
setUserInfoSignedResponseOpen(false);
}}
selections={[selectOptionToString(value, signatureOptions)]}
>
{signatureOptions}
</Select>
)}
/>
</FormGroup>
<FormGroup
label={t("requestObjectSignatureAlgorithm")}
fieldId="requestObjectSignatureAlgorithm"
labelIcon={
<HelpItem
helpText="clients-help:requestObjectSignatureAlgorithm"
forLabel={t("requestObjectSignatureAlgorithm")}
forID="requestObjectSignatureAlgorithm"
/>
}
>
<Controller
name="attributes.request_object_signature_alg"
defaultValue=""
control={control}
render={({ onChange, value }) => (
<Select
toggleId="requestObjectSignatureAlgorithm"
variant={SelectVariant.single}
onToggle={() =>
setRequestObjectSignatureOpen(!requestObjectSignatureOpen)
}
isOpen={requestObjectSignatureOpen}
onSelect={(_, value) => {
onChange(value);
setRequestObjectSignatureOpen(false);
}}
selections={[selectOptionToString(value, requestObjectOptions)]}
>
{requestObjectOptions}
</Select>
)}
/>
</FormGroup>
<FormGroup
label={t("requestObjectRequired")}
fieldId="requestObjectRequired"
labelIcon={
<HelpItem
helpText="clients-help:requestObjectRequired"
forLabel={t("requestObjectRequired")}
forID="requestObjectRequired"
/>
}
>
<Controller
name="attributes.request-object-required"
defaultValue=""
control={control}
render={({ onChange, value }) => (
<Select
toggleId="requestObjectRequired"
variant={SelectVariant.single}
onToggle={() =>
setRequestObjectRequiredOpen(!requestObjectRequiredOpen)
}
isOpen={requestObjectRequiredOpen}
onSelect={(_, value) => {
onChange(value);
setRequestObjectRequiredOpen(false);
}}
selections={[
selectOptionToString(value, requestObjectRequiredOptions),
]}
>
{requestObjectRequiredOptions}
</Select>
)}
/>
</FormGroup>
<ActionGroup>
<Button id="fineGrainSave" variant="tertiary" onClick={save}>
{t("common:save")}
</Button>
<Button id="fineGrainReload" variant="link" onClick={reset}>
{t("common:reload")}
</Button>
</ActionGroup>
</FormAccess>
);
};

View file

@ -0,0 +1,111 @@
import React from "react";
import { useTranslation } from "react-i18next";
import { Control } from "react-hook-form";
import {
ActionGroup,
Button,
FormGroup,
TextInput,
} from "@patternfly/react-core";
import { FormAccess } from "../../components/form-access/FormAccess";
import { HelpItem } from "../../components/help-enabler/HelpItem";
type FineGrainSamlEndpointConfigProps = {
control: Control<Record<string, any>>;
save: () => void;
reset: () => void;
};
export const FineGrainSamlEndpointConfig = ({
control: { register },
save,
reset,
}: FineGrainSamlEndpointConfigProps) => {
const { t } = useTranslation("clients");
return (
<FormAccess role="manage-realm" isHorizontal>
<FormGroup
label={t("assertionConsumerServicePostBindingURL")}
fieldId="assertionConsumerServicePostBindingURL"
labelIcon={
<HelpItem
helpText="clients-help:assertionConsumerServicePostBindingURL"
forLabel={t("assertionConsumerServicePostBindingURL")}
forID="assertionConsumerServicePostBindingURL"
/>
}
>
<TextInput
ref={register()}
type="text"
id="assertionConsumerServicePostBindingURL"
name="attributes.saml_assertion_consumer_url_post"
/>
</FormGroup>
<FormGroup
label={t("assertionConsumerServiceRedirectBindingURL")}
fieldId="assertionConsumerServiceRedirectBindingURL"
labelIcon={
<HelpItem
helpText="clients-help:assertionConsumerServiceRedirectBindingURL"
forLabel={t("assertionConsumerServiceRedirectBindingURL")}
forID="assertionConsumerServiceRedirectBindingURL"
/>
}
>
<TextInput
ref={register()}
type="text"
id="assertionConsumerServiceRedirectBindingURL"
name="attributes.saml_assertion_consumer_url_redirect"
/>
</FormGroup>
<FormGroup
label={t("logoutServicePostBindingURL")}
fieldId="logoutServicePostBindingURL"
labelIcon={
<HelpItem
helpText="clients-help:logoutServicePostBindingURL"
forLabel={t("logoutServicePostBindingURL")}
forID="logoutServicePostBindingURL"
/>
}
>
<TextInput
ref={register()}
type="text"
id="logoutServicePostBindingURL"
name="attributes.saml_single_logout_service_url_post"
/>
</FormGroup>
<FormGroup
label={t("logoutServiceRedirectBindingURL")}
fieldId="logoutServiceRedirectBindingURL"
labelIcon={
<HelpItem
helpText="clients-help:logoutServiceRedirectBindingURL"
forLabel={t("logoutServiceRedirectBindingURL")}
forID="logoutServiceRedirectBindingURL"
/>
}
>
<TextInput
ref={register()}
type="text"
id="logoutServiceRedirectBindingURL"
name="attributes.saml_single_logout_service_url_redirect"
/>
</FormGroup>
<ActionGroup>
<Button variant="tertiary" onClick={save}>
{t("common:save")}
</Button>
<Button variant="link" onClick={reset}>
{t("common:reload")}
</Button>
</ActionGroup>
</FormAccess>
);
};

View file

@ -0,0 +1,59 @@
import React from "react";
import { useTranslation } from "react-i18next";
import { Control, Controller } from "react-hook-form";
import { ActionGroup, Button, FormGroup, Switch } from "@patternfly/react-core";
import { FormAccess } from "../../components/form-access/FormAccess";
import { HelpItem } from "../../components/help-enabler/HelpItem";
type OpenIdConnectCompatibilityModesProps = {
control: Control<Record<string, any>>;
save: () => void;
reset: () => void;
};
export const OpenIdConnectCompatibilityModes = ({
control,
save,
reset,
}: OpenIdConnectCompatibilityModesProps) => {
const { t } = useTranslation("clients");
return (
<FormAccess role="manage-realm" isHorizontal>
<FormGroup
label={t("excludeSessionStateFromAuthenticationResponse")}
fieldId="excludeSessionStateFromAuthenticationResponse"
labelIcon={
<HelpItem
helpText="clients-help:excludeSessionStateFromAuthenticationResponse"
forLabel={t("excludeSessionStateFromAuthenticationResponse")}
forID="excludeSessionStateFromAuthenticationResponse"
/>
}
>
<Controller
name="attributes.exclude-session-state-from-auth-response"
defaultValue=""
control={control}
render={({ onChange, value }) => (
<Switch
id="excludeSessionStateFromAuthenticationResponse"
label={t("common:on")}
labelOff={t("common:off")}
isChecked={value === "true"}
onChange={(value) => onChange("" + value)}
/>
)}
/>
</FormGroup>
<ActionGroup>
<Button variant="tertiary" onClick={save}>
{t("common:save")}
</Button>
<Button variant="link" onClick={reset}>
{t("common:reload")}
</Button>
</ActionGroup>
</FormAccess>
);
};

View file

@ -0,0 +1,23 @@
import React from "react";
import { useTranslation } from "react-i18next";
import { ActionGroup, Button } from "@patternfly/react-core";
type SaveResetProps = {
name: string;
save: () => void;
reset: () => void;
};
export const SaveReset = ({ name, save, reset }: SaveResetProps) => {
const { t } = useTranslation();
return (
<ActionGroup>
<Button data-testid={name + "Save"} variant="tertiary" onClick={save}>
{t("common:save")}
</Button>
<Button data-testid={name + "Reload"} variant="link" onClick={reset}>
{t("common:reload")}
</Button>
</ActionGroup>
);
};

View file

@ -1,5 +1,5 @@
import React, { useEffect, useState } from "react";
import { Controller, UseFormMethods, useWatch } from "react-hook-form";
import { Controller, useFormContext, useWatch } from "react-hook-form";
import { useTranslation } from "react-i18next";
import { useErrorHandler } from "react-error-boundary";
import {
@ -48,17 +48,20 @@ type AccessToken = {
export type CredentialsProps = {
clientId: string;
form: UseFormMethods;
save: () => void;
};
export const Credentials = ({ clientId, form, save }: CredentialsProps) => {
export const Credentials = ({ clientId, save }: CredentialsProps) => {
const { t } = useTranslation("clients");
const adminClient = useAdminClient();
const handleError = useErrorHandler();
const { addAlert } = useAlerts();
const {
control,
formState: { isDirty },
} = useFormContext();
const clientAuthenticatorType = useWatch({
control: form.control,
control: control,
name: "clientAuthenticatorType",
});
@ -158,7 +161,7 @@ export const Credentials = ({ clientId, form, save }: CredentialsProps) => {
>
<Controller
name="clientAuthenticatorType"
control={form.control}
control={control}
render={({ onChange, value }) => (
<Select
toggleId="kc-client-authenticator-type"
@ -186,15 +189,13 @@ export const Credentials = ({ clientId, form, save }: CredentialsProps) => {
)}
/>
</FormGroup>
{clientAuthenticatorType === "client-jwt" && (
<SignedJWT form={form} />
)}
{clientAuthenticatorType === "client-x509" && <X509 form={form} />}
{clientAuthenticatorType === "client-jwt" && <SignedJWT />}
{clientAuthenticatorType === "client-x509" && <X509 />}
<ActionGroup>
<Button
variant="primary"
onClick={() => save()}
isDisabled={!form.formState.isDirty}
isDisabled={!isDirty}
>
{t("common:save")}
</Button>

View file

@ -1,6 +1,6 @@
import React, { useState } from "react";
import { useTranslation } from "react-i18next";
import { Controller, UseFormMethods } from "react-hook-form";
import { Controller, useFormContext } from "react-hook-form";
import {
FormGroup,
Select,
@ -12,11 +12,8 @@ import { useServerInfo } from "../../context/server-info/ServerInfoProvider";
import { HelpItem } from "../../components/help-enabler/HelpItem";
import { sortProviders } from "../../util";
export type SignedJWTProps = {
form: UseFormMethods;
};
export const SignedJWT = ({ form }: SignedJWTProps) => {
export const SignedJWT = () => {
const { control } = useFormContext();
const providers = sortProviders(
useServerInfo().providers!.clientSignature.providers
);
@ -37,9 +34,9 @@ export const SignedJWT = ({ form }: SignedJWTProps) => {
}
>
<Controller
name="attributes.token_endpoint_auth_signing_alg"
name="attributes.token-endpoint-auth-signing-alg"
defaultValue={providers[0]}
control={form.control}
control={control}
render={({ onChange, value }) => (
<Select
toggleId="kc-signature-algorithm"

View file

@ -1,15 +1,12 @@
import React from "react";
import { useTranslation } from "react-i18next";
import { UseFormMethods } from "react-hook-form";
import { useFormContext } from "react-hook-form";
import { FormGroup, TextInput } from "@patternfly/react-core";
import { HelpItem } from "../../components/help-enabler/HelpItem";
export type X509Props = {
form: UseFormMethods;
};
export const X509 = ({ form }: X509Props) => {
export const X509 = () => {
const { t } = useTranslation("clients");
const { register } = useFormContext();
return (
<FormGroup
label={t("subject")}
@ -23,10 +20,10 @@ export const X509 = ({ form }: X509Props) => {
}
>
<TextInput
ref={form.register()}
ref={register()}
type="text"
id="kc-subject"
name="attributes.x509_subjectdn"
name="attributes.x509-subjectdn"
/>
</FormGroup>
);

View file

@ -10,6 +10,33 @@
"subject": "A regular expression for validating Subject DN in the Client Certificate. Use \"(.*?)(?:$)\" to match all kind of expressions.",
"evaluateExplain": "This page allows you to see all protocol mappers and role scope mappings",
"scopeParameter": "You can copy/paste this value of scope parameter and use it in initial OpenID Connect Authentication Request sent from this client adapter. Default client scopes and selected optional client scopes will be used when generating token issued for this client",
"user": "Optionally select user, for whom the example access token will be generated. If you do not select a user, example access token will not be generated during evaluation"
"user": "Optionally select user, for whom the example access token will be generated. If you do not select a user, example access token will not be generated during evaluation",
"notBefore": "Revoke any tokens issued before this date for this client.",
"notBeforeIntro": "In order to successfully push a revocation policy to the client, you need to set an Admin URL under the <1>Settings</1> tab for this client first",
"nodeReRegistrationTimeout": "Interval to specify max time for registered clients cluster nodes to re-register. If cluster node will not send re-registration request to Keycloak within this time, it will be unregistered from Keycloak",
"fineGrainOpenIdConnectConfiguration": "This section is used to configure advanced settings of this client related to OpenID connect protocol.",
"fineGrainSamlEndpointConfig": "This section to configure exact URLs for Assertion Consumer and Single Logout Service.",
"accessTokenSignatureAlgorithm": "JWA algorithm used for signing access tokens.",
"idTokenSignatureAlgorithm": "JWA algorithm used for signing ID tokens.",
"idTokenEncryptionKeyManagementAlgorithm": "JWA Algorithm used for key management in encrypting ID tokens. This option is needed if you want encrypted ID tokens. If left empty, ID Tokens are just signed, but not encrypted.",
"idTokenEncryptionContentEncryptionAlgorithm": "JWA Algorithm used for content encryption in encrypting ID tokens. This option is needed just if you want encrypted ID tokens. If left empty, ID Tokens are just signed, but not encrypted.",
"userInfoSignedResponseAlgorithm": "JWA algorithm used for signed User Info Endpoint response. If set to 'unsigned', User Info Response won't be signed and will be returned in application/json format.",
"requestObjectSignatureAlgorithm": "JWA algorithm, which client needs to use when sending OIDC request object specified by 'request' or 'request_uri' parameters. If set to 'any', Request object can be signed by any algorithm (including 'none' ).",
"requestObjectRequired": "Specifies if the client needs to provide a request object with their authorization requests, and what method they can use for this. If set to \"not required\", providing a request object is optional. In all other cases, providing a request object is mandatory. If set to \"request\", the request object must be provided by value. If set to \"request_uri\", the request object must be provided by reference. If set to \"request or request_uri\", either method can be used.",
"openIdConnectCompatibilityModes": "This section is used to configure settings for backward compatibility with older OpenID Connect / OAuth 2 adaptors. It's useful especially if your client uses older version of Keycloak / RH-SSO adapter.",
"excludeSessionStateFromAuthenticationResponse": "If this is on, the parameter 'session_state' will not be included in OpenID Connect Authentication Response. It is useful if your client uses older OIDC / OAuth2 adapter, which does not support 'session_state' parameter.",
"advancedSettingsOpenid-connect": "This section is used to configure advanced settings of this client related to OpenID Connect protocol",
"advancedSettingsSaml": "This section is used to configure advanced settings of this client",
"assertionLifespan": "Lifespan set in the SAML assertion conditions. After that time the assertion will be invalid. The \"SessionNotOnOrAfter\" attribute is not modified and continue using the \"SSO Session Max\" time defined at realm level.",
"accessTokenLifespan": "Max time before an access token is expired. This value is recommended to be short relative to the SSO timeout.",
"oAuthMutual": "This enables support for OAuth 2.0 Mutual TLS Certificate Bound Access Tokens, which means that keycloak bind an access token and a refresh token with a X.509 certificate of a token requesting client exchanged in mutual TLS between keycloak's Token Endpoint and this client. These tokens can be treated as Holder-of-Key tokens instead of bearer tokens.",
"keyForCodeExchange": "Choose which code challenge method for PKCE is used. If not specified, keycloak does not applies PKCE to a client unless the client sends an authorization request with appropriate code challenge and code exchange method.",
"assertionConsumerServicePostBindingURL": "SAML POST Binding URL for the client's assertion consumer service (login responses). You can leave this blank if you do not have a URL for this binding.",
"assertionConsumerServiceRedirectBindingURL": "SAML Redirect Binding URL for the client's assertion consumer service (login responses). You can leave this blank if you do not have a URL for this binding.",
"logoutServicePostBindingURL": "SAML POST Binding URL for the client's single logout service. You can leave this blank if you are using a different binding",
"logoutServiceRedirectBindingURL": "SAML Redirect Binding URL for the client's single logout service. You can leave this blank if you are using a different binding.",
"authenticationOverrides": "Override realm authentication flow bindings.",
"browserFlow": "Select the flow you want to use for browser authentication.",
"directGrant": "Select the flow you want to use for direct grant authentication."
}
}

View file

@ -7,7 +7,7 @@ import {
Button,
AlertVariant,
} from "@patternfly/react-core";
import { useForm } from "react-hook-form";
import { FormProvider, useForm } from "react-hook-form";
import { useTranslation } from "react-i18next";
import { ClientDescription } from "../ClientDescription";
@ -60,8 +60,9 @@ export const ImportForm = () => {
onSubmit={handleSubmit(save)}
role="manage-clients"
>
<FormProvider {...form}>
<JsonFileUpload id="realm-file" onChange={handleFileChange} />
<ClientDescription form={form} />
<ClientDescription />
<FormGroup label={t("common:type")} fieldId="kc-type">
<TextInput
type="text"
@ -77,6 +78,7 @@ export const ImportForm = () => {
</Button>
<Button variant="link">{t("common:cancel")}</Button>
</ActionGroup>
</FormProvider>
</FormAccess>
</PageSection>
</>

View file

@ -100,6 +100,63 @@
"accessTokenError": "Could not regenerate access token due to: {{error}}",
"signatureAlgorithm": "Signature algorithm",
"subject": "Subject DN",
"searchForClient": "Search for client"
"searchForClient": "Search for client",
"advanced": "Advanced",
"revocation": "Revocation",
"clustering": "Clustering",
"notBefore": "Not before",
"setToNow": "Set to now",
"noAdminUrlSet": "No push sent. No admin URI configured or no registered cluster nodes available",
"notBeforePushFail": "Failed to push \"not before\" to: {{failedNodes}}",
"notBeforePushSuccess": "Successfully push \"not before\" to: {{successNodes}}",
"testClusterFail": "Failed verified availability for: {{failedNodes}}. Fix or unregister failed cluster nodes and try again",
"testClusterSuccess": "Successfully verified availability for: {{successNodes}}",
"deleteNode": "Delete node?",
"deleteNodeBody": "Are you sure you want to permanently delete the node \"{{node}}\"",
"deleteNodeSuccess": "Node successfully removed",
"deleteNodeFail": "Could not delete node: '{{error}}'",
"addedNodeSuccess": "Node successfully added",
"addedNodeFail": "Could not add node: '{{error}}'",
"addNode": "Add node",
"push": "Push",
"clear": "Clear",
"none": "None",
"any": "Any",
"nodeReRegistrationTimeout": "Node Re-registration timeout",
"registeredClusterNodes": "Registered cluster nodes",
"nodeHost": "Node host",
"lastRegistration": "Last registration",
"testClusterAvailability": "Test cluster availability",
"registerNodeManually": "Register node manually",
"fineGrainOpenIdConnectConfiguration": "Fine grain OpenID connect configuration",
"fineGrainSamlEndpointConfig": "Fine Grain SAML Endpoint Configuration",
"accessTokenSignatureAlgorithm": "Access token signature algorithm",
"idTokenSignatureAlgorithm": "ID token signature algorithm",
"idTokenEncryptionKeyManagementAlgorithm": "ID token encryption key management algorithm",
"idTokenEncryptionContentEncryptionAlgorithm": "ID token encryption content encryption algorithm",
"userInfoSignedResponseAlgorithm": "User info signed response algorithm",
"requestObjectSignatureAlgorithm": "Request object signature algorithm",
"requestObjectRequired": "Request object required",
"requestObject": {
"not required": "Not required",
"request or request_uri": "Request or Request URI",
"request only": "Request only",
"request_uri only": "Request URI only"
},
"openIdConnectCompatibilityModes": "Open ID Connect Compatibly Modes",
"excludeSessionStateFromAuthenticationResponse": "Exclude Session State From Authentication Response",
"assertionConsumerServicePostBindingURL": "Assertion Consumer Service POST Binding URL",
"assertionConsumerServiceRedirectBindingURL" :"Assertion Consumer Service Redirect Binding URL",
"logoutServicePostBindingURL": "Logout Service POST Binding URL",
"logoutServiceRedirectBindingURL": "Logout Service Redirect Binding URL",
"advancedSettings": "Advanced Settings",
"assertionLifespan": "Assertion Lifespan",
"accessTokenLifespan": "Access Token Lifespan",
"oAuthMutual": "OAuth 2.0 Mutual TLS Certificate Bound Access Tokens Enabled",
"keyForCodeExchange": "Proof Key for Code Exchange Code Challenge Method",
"authenticationOverrides": "Authentication flow overrides",
"browserFlow": "Browser Flow",
"directGrant": "Direct Grant Flow"
}
}

View file

@ -325,7 +325,10 @@ export const EvaluateScopes = ({ clientId, protocol }: EvaluateScopesProps) => {
setUserSearch(value);
return userItems;
}}
onClear={() => setUser(undefined)}
onClear={() => {
setUser(undefined);
setUserSearch("");
}}
selections={[user]}
onSelect={(_, value) => {
setUser(value as UserRepresentation);

View file

@ -84,6 +84,14 @@
"Wednesday": "Wednesday",
"Thursday": "Thursday",
"Friday": "Friday",
"Saturday": "Saturday"
"Saturday": "Saturday",
"unitLabel": "Select a time unit",
"times": {
"seconds": "Seconds",
"minutes": "Minutes",
"hours": "Hours",
"days": "Days"
}
}
}

View file

@ -1,4 +1,4 @@
import React, { useState, useEffect, ReactElement, useContext } from "react";
import React, { useState, useEffect, useContext } from "react";
import { useErrorHandler } from "react-error-boundary";
import {
Alert,
@ -25,37 +25,19 @@ import {
} from "../../context/auth/AdminClient";
import { HelpContext } from "../help-enabler/HelpHeader";
export type DownloadDialogProps = {
type DownloadDialogProps = {
id: string;
protocol?: string;
};
type DownloadDialogModalProps = DownloadDialogProps & {
open: boolean;
toggleDialog: () => void;
};
export const useDownloadDialog = (
props: DownloadDialogProps
): [() => void, () => ReactElement] => {
const [show, setShow] = useState(false);
function toggleDialog() {
setShow((show) => !show);
}
const Dialog = () => (
<DownloadDialog {...props} open={show} toggleDialog={toggleDialog} />
);
return [toggleDialog, Dialog];
};
export const DownloadDialog = ({
id,
open,
toggleDialog,
protocol = "openid-connect",
}: DownloadDialogModalProps) => {
}: DownloadDialogProps) => {
const adminClient = useAdminClient();
const handleError = useErrorHandler();
const { t } = useTranslation("common");
@ -85,7 +67,7 @@ export const DownloadDialog = ({
(snippet) => setSnippet(snippet),
handleError
);
}, [selected]);
}, [id, selected]);
return (
<ConfirmDialogModal
titleKey={t("clients:downloadAdaptorTitle")}

View file

@ -3,6 +3,7 @@ import React, {
cloneElement,
isValidElement,
ReactElement,
ReactNode,
} from "react";
import { Controller } from "react-hook-form";
import {
@ -39,7 +40,7 @@ export type FormAccessProps = FormProps & {
* @type {boolean}
*/
unWrap?: boolean;
children: ReactElement[];
children: ReactNode;
};
/**
@ -56,9 +57,9 @@ export const FormAccess = ({
const { hasAccess } = useAccess();
const recursiveCloneChildren = (
children: ReactElement[],
children: ReactNode,
newProps: any
): ReactElement[] => {
): ReactNode => {
return Children.map(children, (child) => {
if (!isValidElement(child)) {
return child;

View file

@ -1,5 +1,5 @@
import React, { useEffect } from "react";
import { useFieldArray, UseFormMethods } from "react-hook-form";
import { useFieldArray, useFormContext, UseFormMethods } from "react-hook-form";
import {
TextInput,
Split,
@ -10,7 +10,7 @@ import {
} from "@patternfly/react-core";
import { MinusIcon, PlusIcon } from "@patternfly/react-icons";
type MultiLine = {
export type MultiLine = {
value: string;
};
@ -25,22 +25,17 @@ export function toValue(formValue: MultiLine[]): string[] {
}
export type MultiLineInputProps = Omit<TextInputProps, "form"> & {
form: UseFormMethods;
name: string;
};
export const MultiLineInput = ({
name,
form,
...rest
}: MultiLineInputProps) => {
const { register, control } = form;
export const MultiLineInput = ({ name, ...rest }: MultiLineInputProps) => {
const { register, control, reset } = useFormContext();
const { fields, append, remove } = useFieldArray({
name,
control,
});
useEffect(() => {
form.reset({
reset({
[name]: [{ value: "" }],
});
}, []);

View file

@ -84,7 +84,7 @@ export type DataListProps<T> = {
canSelectAll?: boolean;
isPaginated?: boolean;
ariaLabelKey: string;
searchPlaceholderKey: string;
searchPlaceholderKey?: string;
columns: Field<T>[];
actions?: Action<T>[];
actionResolver?: IActionsResolver;
@ -254,10 +254,12 @@ export function KeycloakDataTable<T>({
setFirst(first);
setMax(max);
}}
inputGroupName={`${ariaLabelKey}input`}
inputGroupName={
searchPlaceholderKey ? `${ariaLabelKey}input` : undefined
}
inputGroupOnChange={searchOnChange}
inputGroupOnClick={refresh}
inputGroupPlaceholder={t(searchPlaceholderKey)}
inputGroupPlaceholder={t(searchPlaceholderKey || "")}
searchTypeComponent={searchTypeComponent}
toolbarItem={toolbarItem}
>
@ -278,10 +280,12 @@ export function KeycloakDataTable<T>({
)}
{rows && !isPaginated && (
<TableToolbar
inputGroupName={`${ariaLabelKey}input`}
inputGroupName={
searchPlaceholderKey ? `${ariaLabelKey}input` : undefined
}
inputGroupOnChange={searchOnChange}
inputGroupOnClick={() => {}}
inputGroupPlaceholder={t(searchPlaceholderKey)}
inputGroupPlaceholder={t(searchPlaceholderKey || "")}
toolbarItem={toolbarItem}
searchTypeComponent={searchTypeComponent}
>

View file

@ -0,0 +1,109 @@
import {
Select,
SelectOption,
SelectVariant,
Split,
SplitItem,
TextInput,
} from "@patternfly/react-core";
import React, { useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
export type Unit = "seconds" | "minutes" | "hours" | "days";
export type TimeSelectorProps = {
value: number;
units?: Unit[];
onChange: (time: number | string) => void;
};
export const TimeSelector = ({
value,
units = ["seconds", "minutes", "hours", "days"],
onChange,
}: TimeSelectorProps) => {
const { t } = useTranslation("common");
const allTimes: { unit: Unit; label: string; multiplier: number }[] = [
{ unit: "seconds", label: t("times.seconds"), multiplier: 1 },
{ unit: "minutes", label: t("times.minutes"), multiplier: 60 },
{ unit: "hours", label: t("times.hours"), multiplier: 3600 },
{ unit: "days", label: t("times.days"), multiplier: 86400 },
];
const times = allTimes.filter((t) => units.includes(t.unit));
const defaultMultiplier = allTimes.find((time) => time.unit === units[0])
?.multiplier;
const [timeValue, setTimeValue] = useState<"" | number>("");
const [multiplier, setMultiplier] = useState(defaultMultiplier);
const [open, setOpen] = useState(false);
useEffect(() => {
const x = times.reduce(
(v, time) =>
value % time.multiplier === 0 && v < time.multiplier
? time.multiplier
: v,
1
);
if (value) {
setMultiplier(x);
setTimeValue(value / x);
} else {
setTimeValue("");
setMultiplier(defaultMultiplier);
}
}, [value]);
const updateTimeout = (
timeout: "" | number,
times: number | undefined = multiplier
) => {
if (timeout !== "") {
onChange(timeout * (times || 1));
setTimeValue(timeout);
} else {
onChange("");
}
};
return (
<Split hasGutter>
<SplitItem>
<TextInput
type="number"
id={`kc-time-${new Date().getTime()}`}
min="0"
value={timeValue}
onChange={(value) => {
updateTimeout("" === value ? value : parseInt(value));
}}
/>
</SplitItem>
<SplitItem>
<Select
variant={SelectVariant.single}
aria-label={t("unitLabel")}
onSelect={(_, value) => {
setMultiplier(value as number);
updateTimeout(timeValue, value as number);
setOpen(false);
}}
selections={[multiplier]}
onToggle={() => {
setOpen(!open);
}}
isOpen={open}
>
{times.map((time) => (
<SelectOption key={time.label} value={time.multiplier}>
{time.label}
</SelectOption>
))}
</Select>
</SplitItem>
</Split>
);
};

View file

@ -19,12 +19,14 @@ export const useAccess = () => useContext(AccessContext);
type AccessProviderProps = { children: React.ReactNode };
export const AccessContextProvider = ({ children }: AccessProviderProps) => {
const { whoAmI } = useContext(WhoAmIContext);
const realmCtx = useContext(RealmContext);
const { realm } = useContext(RealmContext);
const [access, setAccess] = useState<readonly AccessType[]>([]);
useEffect(() => {
setAccess(whoAmI.getRealmAccess()[realmCtx.realm]);
}, [whoAmI]);
if (whoAmI.getRealmAccess()[realm]) {
setAccess(whoAmI.getRealmAccess()[realm]);
}
}, [whoAmI, realm]);
const hasAccess = (...types: AccessType[]) => {
return types.every((type) => type === "anyone" || access.includes(type));

View file

@ -16,13 +16,18 @@ export const Api = () => (
onCloseAlert={() => {}}
/>
);
export const AddAlert = () => {
const AlertButton = () => {
const { addAlert } = useAlerts();
return (
<Button onClick={() => addAlert("Hello", AlertVariant.default)}>Add</Button>
);
};
export const AddAlert = () => {
return (
<AlertProvider>
<Button onClick={() => addAlert("Hello", AlertVariant.default)}>
Add
</Button>
<AlertButton />
</AlertProvider>
);
};

View file

@ -27,7 +27,7 @@ export const loadPosts = () => {
return (
<PostLoader url="https://jsonplaceholder.typicode.com/posts">
{(posts: { data: Post[] }) => (
{(posts: Post[]) => (
<table>
<thead>
<tr>
@ -36,7 +36,7 @@ export const loadPosts = () => {
</tr>
</thead>
<tbody>
{posts.data.map((post, i) => (
{posts.map((post, i) => (
<tr key={i}>
<td>{post.title}</td>
<td>{post.body}</td>

View file

@ -1,13 +1,7 @@
import React from "react";
import React, { useState } from "react";
import { Meta } from "@storybook/react";
import serverInfo from "../context/server-info/__tests__/mock.json";
import { ServerInfoContext } from "../context/server-info/ServerInfoProvider";
import {
DownloadDialog,
useDownloadDialog,
} from "../components/download-dialog/DownloadDialog";
import { DownloadDialog } from "../components/download-dialog/DownloadDialog";
import { MockAdminClient } from "./MockAdminClient";
export default {
@ -16,20 +10,22 @@ export default {
} as Meta;
const Test = () => {
const [toggle, Dialog] = useDownloadDialog({
id: "58577281-7af7-410c-a085-61ff3040be6d",
});
const [open, setOpen] = useState(false);
const toggle = () => setOpen(!open);
return (
<ServerInfoContext.Provider value={serverInfo}>
<MockAdminClient
mock={{ clients: { getInstallationProviders: () => '{some: "json"}' } }}
>
<button id="show" onClick={toggle}>
Show
</button>
<Dialog />
<DownloadDialog
id="58577281-7af7-410c-a085-61ff3040be6d"
open={open}
toggleDialog={toggle}
/>
</MockAdminClient>
</ServerInfoContext.Provider>
);
};

View file

@ -5,9 +5,11 @@ import KeycloakAdminClient from "keycloak-admin";
import { AccessContextProvider } from "../context/access/Access";
import { WhoAmIContextProvider } from "../context/whoami/WhoAmI";
import { RealmContext } from "../context/realm-context/RealmContext";
import { AdminClient } from "../context/auth/AdminClient";
import { ServerInfoContext } from "../context/server-info/ServerInfoProvider";
import whoamiMock from "../context/whoami/__tests__/mock-whoami.json";
import { AdminClient } from "../context/auth/AdminClient";
import serverInfo from "../context/server-info/__tests__/mock.json";
/**
* This component provides some mocked default react context so that other components can work in a storybook.
@ -28,12 +30,13 @@ export const MockAdminClient = (props: {
}) => {
return (
<HashRouter>
<ServerInfoContext.Provider value={serverInfo}>
<AdminClient.Provider
value={
({
...props.mock,
keycloak: {},
whoAmI: { find: () => whoamiMock },
whoAmI: { find: () => Promise.resolve(whoamiMock) },
setConfig: () => {},
} as unknown) as KeycloakAdminClient
}
@ -46,6 +49,7 @@ export const MockAdminClient = (props: {
</RealmContext.Provider>
</WhoAmIContextProvider>
</AdminClient.Provider>
</ServerInfoContext.Provider>
</HashRouter>
);
};

View file

@ -1,7 +1,7 @@
import React from "react";
import { Meta, Story } from "@storybook/react";
import { action } from "@storybook/addon-actions";
import { useForm } from "react-hook-form";
import { FormProvider, useForm } from "react-hook-form";
import { Button } from "@patternfly/react-core";
import {
@ -16,14 +16,16 @@ export default {
} as Meta;
const Template: Story<MultiLineInputProps> = (args) => {
const form = useForm();
const form = useForm({ mode: "onChange" });
return (
<form
onSubmit={form.handleSubmit((data) => {
action("submit")(toValue(data.items));
})}
>
<MultiLineInput {...args} form={form} />
<FormProvider {...form}>
<MultiLineInput {...args} />
</FormProvider>
<br />
<br />
<Button type="submit">Submit</Button>

View file

@ -1,7 +1,6 @@
import React from "react";
import { Meta } from "@storybook/react";
import { MockAdminClient } from "./MockAdminClient";
import { MemoryRouter, Route } from "react-router-dom";
import rolesMock from "../realm-roles/__tests__/mock-roles.json";
import { RealmRoleTabs } from "../realm-roles/RealmRoleTabs";
@ -13,11 +12,7 @@ export default {
export const RolesTabsExample = () => {
return (
<MockAdminClient mock={{ roles: { findOneById: () => rolesMock[0] } }}>
<MemoryRouter initialEntries={["/roles/1"]}>
<Route path="/roles/:id">
<RealmRoleTabs />
</Route>
</MemoryRouter>
</MockAdminClient>
);
};

View file

@ -10,6 +10,7 @@ import { Meta } from "@storybook/react";
import { RealmSelector } from "../components/realm-selector/RealmSelector";
import { RealmContextProvider } from "../context/realm-context/RealmContext";
import { HashRouter } from "react-router-dom";
export default {
title: "Header",
@ -18,6 +19,7 @@ export default {
export const Header = () => {
return (
<HashRouter>
<RealmContextProvider>
<Page
sidebar={
@ -56,5 +58,6 @@ export const Header = () => {
}
/>
</RealmContextProvider>
</HashRouter>
);
};

View file

@ -59,14 +59,14 @@ export const convertToFormValues = (
setValue: (name: string, value: any) => void
) => {
return Object.keys(obj).map((key) => {
const newKey = key.replace(/\./g, "_");
const newKey = key.replace(/\./g, "-");
setValue(prefix + "." + newKey, obj[key]);
});
};
export const convertFormValuesToObject = (obj: any) => {
const keyValues = Object.keys(obj).map((key) => {
const newKey = key.replace(/_/g, ".");
const newKey = key.replace(/-/g, ".");
return { [newKey]: obj[key] };
});
return Object.assign({}, ...keyValues);

View file

@ -10319,11 +10319,16 @@ focus-trap@6.2.2:
dependencies:
tabbable "^5.1.4"
follow-redirects@^1.0.0, follow-redirects@^1.10.0:
follow-redirects@^1.0.0:
version "1.13.1"
resolved "https://registry.yarnpkg.com/follow-redirects/-/follow-redirects-1.13.1.tgz#5f69b813376cee4fd0474a3aba835df04ab763b7"
integrity sha512-SSG5xmZh1mkPGyKzjZP8zLjltIfpW32Y5QpdNJyjcfGxK3qo3NDDkZOZSFiGn1A6SclQxY9GzEwAHQ3dmYRWpg==
follow-redirects@^1.10.0:
version "1.13.2"
resolved "https://registry.yarnpkg.com/follow-redirects/-/follow-redirects-1.13.2.tgz#dd73c8effc12728ba5cf4259d760ea5fb83e3147"
integrity sha512-6mPTgLxYm3r6Bkkg0vNM0HTjfGrOEtsfbhagQvbxDEsEkpNhw582upBaoRZylzen6krEmxXJgt9Ju6HiI4O7BA==
for-in@^0.1.3:
version "0.1.8"
resolved "https://registry.yarnpkg.com/for-in/-/for-in-0.1.8.tgz#d8773908e31256109952b1fdb9b3fa867d2775e1"
@ -13472,10 +13477,10 @@ junk@^3.1.0:
resolved "https://registry.yarnpkg.com/junk/-/junk-3.1.0.tgz#31499098d902b7e98c5d9b9c80f43457a88abfa1"
integrity sha512-pBxcB3LFc8QVgdggvZWyeys+hnrNWg4OcZIU/1X59k5jQdLBlCsYGRQaz234SqoRLTCgMH00fY0xRJH+F9METQ==
keycloak-admin@1.14.6:
version "1.14.6"
resolved "https://registry.yarnpkg.com/keycloak-admin/-/keycloak-admin-1.14.6.tgz#22ae06bc0db7b041914bae6cf8fd5fd20d3efa9b"
integrity sha512-bjYwXzq5VRJh/uM/gfogf0SsPn53xb7cMd6R3qp5jY4VrdjnCYPvD1db8F+Cr6lxOxYsA3jVMziMLgGKKuGIdw==
keycloak-admin@1.14.7:
version "1.14.7"
resolved "https://registry.yarnpkg.com/keycloak-admin/-/keycloak-admin-1.14.7.tgz#fe86f296cc6774ec3256b3211d5cd3c76cf79b9c"
integrity sha512-PvDcs8E9VVlhe/1Te/TA5AP8gOkQqIxOmI08Eae3gOvxtt7Ucdd4hxa/ZUX5B7/PvOQH+mFqP5XHVovAZWtmFA==
dependencies:
axios "^0.21.0"
camelize "^1.0.0"
@ -16657,9 +16662,9 @@ query-string@^4.1.0:
strict-uri-encode "^1.0.0"
query-string@^6.13.7:
version "6.13.7"
resolved "https://registry.yarnpkg.com/query-string/-/query-string-6.13.7.tgz#af53802ff6ed56f3345f92d40a056f93681026ee"
integrity sha512-CsGs8ZYb39zu0WLkeOhe0NMePqgYdAuCqxOYKDR5LVCytDZYMGx3Bb+xypvQvPHVPijRXB0HZNFllCzHRe4gEA==
version "6.13.8"
resolved "https://registry.yarnpkg.com/query-string/-/query-string-6.13.8.tgz#8cf231759c85484da3cf05a851810d8e825c1159"
integrity sha512-jxJzQI2edQPE/NPUOusNjO/ZOGqr1o2OBa/3M00fU76FsLXDVbJDv/p7ng5OdQyorKrkRz1oqfwmbe5MAMePQg==
dependencies:
decode-uri-component "^0.2.0"
split-on-first "^1.0.0"