add OIDC provider to the idp section (#553)

* initial version of oidc

* fixed fetch and added missing fields

* fixed file upload

* added scopse

* added details

* added disable action and save

* updated to use new design based on discovery response

* new design

* set default value

* added tests

* fixed tests

* fixed labels

* changed direction to up
This commit is contained in:
Erik Jan de Wit 2021-05-06 20:59:00 +02:00 committed by GitHub
parent 84d621f70a
commit c5ff588791
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
26 changed files with 1895 additions and 481 deletions

View file

@ -24,7 +24,7 @@ describe("Identity provider test", () => {
sidebarPage.goToIdentityProviders();
});
it("should create provider", () => {
/*it("should create provider", () => {
createProviderPage.checkGitHubCardVisible().clickGitHubCard();
createProviderPage.checkAddButtonDisabled();
@ -37,9 +37,7 @@ describe("Identity provider test", () => {
"Identity provider successfully created"
);
//TODO temporary refresh
sidebarPage.goToAuthentication().goToIdentityProviders();
sidebarPage.goToIdentityProviders();
listingPage.itemExist(identityProviderName);
});
@ -83,6 +81,27 @@ describe("Identity provider test", () => {
masthead.checkNotificationMessage(
"Successfully changed display order of identity providers"
);
});*/
it("should create a oidc provider using discovery url", () => {
const oidcProviderName = "oidc";
createProviderPage
.clickCreateDropdown()
.clickItem(oidcProviderName)
.fillDiscoveryUrl(
"http://localhost:8180/auth/realms/master/.well-known/openid-configuration"
)
.shouldBeSuccessful()
.fill("oidc", "123")
.clickAdd();
masthead.checkNotificationMessage(
"Identity provider successfully created"
);
createProviderPage.shouldHaveAuthorizationUrl(
"http://localhost:8180/auth/realms/master/protocol/openid-connect/auth"
);
});
it("clean up providers", () => {

View file

@ -90,7 +90,7 @@ export default class SidebarPage {
}
goToIdentityProviders() {
cy.get(this.identityProvidersBtn).click();
cy.get(this.identityProvidersBtn).scrollIntoView().click();
return this;
}

View file

@ -4,6 +4,8 @@ export default class CreateProviderPage {
private clientIdField = "clientId";
private clientIdError = "#kc-client-secret-helper";
private clientSecretField = "clientSecret";
private discoveryEndpoint = "discoveryEndpoint";
private authorizationUrl = "authorizationUrl";
private addButton = "createProvider";
checkVisible(name: string) {
@ -65,4 +67,19 @@ export default class CreateProviderPage {
return this;
}
fillDiscoveryUrl(value: string) {
cy.getId(this.discoveryEndpoint).type(value).blur();
return this;
}
shouldBeSuccessful() {
cy.getId(this.discoveryEndpoint).should("have.class", "pf-m-success");
return this;
}
shouldHaveAuthorizationUrl(value: string) {
cy.getId(this.authorizationUrl).should("have.value", value);
return this;
}
}

View file

@ -1,6 +1,7 @@
{
"common-help": {
"helpToggleInfo": "This toggle will enable / disable part of the help info in the console. Includes any help text, links and popovers.",
"showPassword": "Show password field in clear text"
"showPassword": "Show password field in clear text",
"helpFileUpload": "Upload a JSON file"
}
}

View file

@ -5,6 +5,7 @@ import {
Modal,
ModalVariant,
Button,
FileUploadProps,
} from "@patternfly/react-core";
import { useTranslation } from "react-i18next";
@ -20,18 +21,22 @@ export type JsonFileUploadEvent =
| React.ChangeEvent<HTMLTextAreaElement> // User typed in the TextArea
| React.MouseEvent<HTMLButtonElement, MouseEvent>; // User clicked Clear button
export type JsonFileUploadProps = {
export type JsonFileUploadProps = FileUploadProps & {
id: string;
onChange: (
value: string | File,
filename: string,
event: JsonFileUploadEvent
) => void;
helpText?: string;
unWrap?: boolean;
};
export const JsonFileUpload = ({
id,
onChange,
helpText = "common-help:helpFileUpload",
unWrap = false,
...rest
}: JsonFileUploadProps) => {
const { t } = useTranslation();
@ -66,6 +71,23 @@ export const JsonFileUpload = ({
}
};
const JsonFileUploadComp = () => (
<FileUpload
id={id}
{...rest}
type="text"
value={fileUpload.value}
filename={fileUpload.filename}
onChange={handleChange}
onReadStarted={() => setFileUpload({ ...fileUpload, isLoading: true })}
onReadFinished={() => setFileUpload({ ...fileUpload, isLoading: false })}
isLoading={fileUpload.isLoading}
dropzoneProps={{
accept: ".json",
}}
/>
);
return (
<>
{fileUpload.modal && (
@ -93,30 +115,16 @@ export const JsonFileUpload = ({
{t("clearFileExplain")}
</Modal>
)}
<FormGroup
label={t("resourceFile")}
fieldId={id}
helperText="Upload a JSON file"
>
<FileUpload
id={id}
{...rest}
type="text"
value={fileUpload.value}
filename={fileUpload.filename}
onChange={handleChange}
onReadStarted={() =>
setFileUpload({ ...fileUpload, isLoading: true })
}
onReadFinished={() =>
setFileUpload({ ...fileUpload, isLoading: false })
}
isLoading={fileUpload.isLoading}
dropzoneProps={{
accept: ".json",
}}
/>
</FormGroup>
{unWrap && <JsonFileUploadComp />}
{!unWrap && (
<FormGroup
label={t("resourceFile")}
fieldId={id}
helperText={t(helpText)}
>
<JsonFileUploadComp />
</FormGroup>
)}
</>
);
};

View file

@ -7,7 +7,7 @@ exports[`<JsonFileUpload /> render 1`] = `
>
<FormGroup
fieldId="test"
helperText="Upload a JSON file"
helperText="helpFileUpload"
label="resourceFile"
>
<div
@ -31,56 +31,42 @@ exports[`<JsonFileUpload /> render 1`] = `
<div
className="pf-c-form__group-control"
>
<FileUpload
dropzoneProps={
Object {
"accept": ".json",
<JsonFileUploadComp>
<FileUpload
dropzoneProps={
Object {
"accept": ".json",
}
}
}
filename=""
id="test"
isLoading={false}
onChange={[Function]}
onReadFinished={[Function]}
onReadStarted={[Function]}
type="text"
value=""
>
<t
accept=".json"
disabled={false}
getDataTransferItems={[Function]}
maxSize={Infinity}
minSize={0}
multiple={false}
onDropAccepted={[Function]}
onDropRejected={[Function]}
preventDropOnDocument={true}
filename=""
id="test"
isLoading={false}
onChange={[Function]}
onReadFinished={[Function]}
onReadStarted={[Function]}
type="text"
value=""
>
<FileUploadField
containerRef={[Function]}
filename=""
id="test"
isLoading={false}
onBlur={[Function]}
onBrowseButtonClick={[Function]}
onChange={[Function]}
onClearButtonClick={[Function]}
onClick={[Function]}
onDragEnter={[Function]}
onDragLeave={[Function]}
onDragOver={[Function]}
onDragStart={[Function]}
onDrop={[Function]}
onFocus={[Function]}
onKeyDown={[Function]}
tabIndex={null}
type="text"
value=""
<t
accept=".json"
disabled={false}
getDataTransferItems={[Function]}
maxSize={Infinity}
minSize={0}
multiple={false}
onDropAccepted={[Function]}
onDropRejected={[Function]}
preventDropOnDocument={true}
>
<div
className="pf-c-file-upload"
<FileUploadField
containerRef={[Function]}
filename=""
id="test"
isLoading={false}
onBlur={[Function]}
onBrowseButtonClick={[Function]}
onChange={[Function]}
onClearButtonClick={[Function]}
onClick={[Function]}
onDragEnter={[Function]}
onDragLeave={[Function]}
@ -90,131 +76,131 @@ exports[`<JsonFileUpload /> render 1`] = `
onFocus={[Function]}
onKeyDown={[Function]}
tabIndex={null}
type="text"
value=""
>
<div
className="pf-c-file-upload__file-select"
className="pf-c-file-upload"
onBlur={[Function]}
onClick={[Function]}
onDragEnter={[Function]}
onDragLeave={[Function]}
onDragOver={[Function]}
onDragStart={[Function]}
onDrop={[Function]}
onFocus={[Function]}
onKeyDown={[Function]}
tabIndex={null}
>
<InputGroup>
<div
className="pf-c-input-group"
>
<TextInput
aria-describedby="test-browse-button"
aria-label="Drag a file here or browse to upload"
id="test-filename"
isDisabled={false}
isReadOnly={true}
key=".0"
name="test-filename"
placeholder="Drag a file here or browse to upload"
value=""
<div
className="pf-c-file-upload__file-select"
>
<InputGroup>
<div
className="pf-c-input-group"
>
<TextInputBase
<TextInput
aria-describedby="test-browse-button"
aria-label="Drag a file here or browse to upload"
className=""
id="test-filename"
innerRef={null}
isDisabled={false}
isLeftTruncated={false}
isReadOnly={true}
isRequired={false}
key=".0"
name="test-filename"
onChange={[Function]}
placeholder="Drag a file here or browse to upload"
type="text"
validated="default"
value=""
>
<input
<TextInputBase
aria-describedby="test-browse-button"
aria-invalid={false}
aria-label="Drag a file here or browse to upload"
className="pf-c-form-control"
disabled={false}
className=""
id="test-filename"
innerRef={null}
isDisabled={false}
isLeftTruncated={false}
isReadOnly={true}
isRequired={false}
name="test-filename"
onBlur={[Function]}
onChange={[Function]}
onFocus={[Function]}
placeholder="Drag a file here or browse to upload"
readOnly={true}
required={false}
type="text"
validated="default"
value=""
/>
</TextInputBase>
</TextInput>
<Button
id="test-browse-button"
isDisabled={false}
key=".1"
onClick={[Function]}
variant="control"
>
<button
aria-disabled={false}
aria-label={null}
className="pf-c-button pf-m-control"
data-ouia-component-id="OUIA-Generated-Button-control-1"
data-ouia-component-type="PF4/Button"
data-ouia-safe={true}
disabled={false}
>
<input
aria-describedby="test-browse-button"
aria-invalid={false}
aria-label="Drag a file here or browse to upload"
className="pf-c-form-control"
disabled={false}
id="test-filename"
name="test-filename"
onBlur={[Function]}
onChange={[Function]}
onFocus={[Function]}
placeholder="Drag a file here or browse to upload"
readOnly={true}
required={false}
type="text"
value=""
/>
</TextInputBase>
</TextInput>
<Button
id="test-browse-button"
isDisabled={false}
key=".1"
onClick={[Function]}
role={null}
type="button"
variant="control"
>
Browse...
</button>
</Button>
<Button
isDisabled={true}
key=".2"
onClick={[Function]}
variant="control"
>
<button
aria-disabled={true}
aria-label={null}
className="pf-c-button pf-m-control pf-m-disabled"
data-ouia-component-id="OUIA-Generated-Button-control-2"
data-ouia-component-type="PF4/Button"
data-ouia-safe={true}
disabled={true}
<button
aria-disabled={false}
aria-label={null}
className="pf-c-button pf-m-control"
data-ouia-component-id="OUIA-Generated-Button-control-1"
data-ouia-component-type="PF4/Button"
data-ouia-safe={true}
disabled={false}
id="test-browse-button"
onClick={[Function]}
role={null}
type="button"
>
Browse...
</button>
</Button>
<Button
isDisabled={true}
key=".2"
onClick={[Function]}
role={null}
tabIndex={null}
type="button"
variant="control"
>
Clear
</button>
</Button>
</div>
</InputGroup>
</div>
<div
className="pf-c-file-upload__file-details"
>
<TextArea
aria-label="File upload"
disabled={false}
id="test"
isRequired={false}
name="test"
onChange={[Function]}
readOnly={false}
resizeOrientation="vertical"
validated="default"
value=""
<button
aria-disabled={true}
aria-label={null}
className="pf-c-button pf-m-control pf-m-disabled"
data-ouia-component-id="OUIA-Generated-Button-control-2"
data-ouia-component-type="PF4/Button"
data-ouia-safe={true}
disabled={true}
onClick={[Function]}
role={null}
tabIndex={null}
type="button"
>
Clear
</button>
</Button>
</div>
</InputGroup>
</div>
<div
className="pf-c-file-upload__file-details"
>
<TextArea
aria-label="File upload"
className=""
disabled={false}
id="test"
innerRef={null}
isDisabled={false}
isRequired={false}
name="test"
onChange={[Function]}
@ -223,45 +209,61 @@ exports[`<JsonFileUpload /> render 1`] = `
validated="default"
value=""
>
<textarea
aria-invalid={false}
<TextArea
aria-label="File upload"
className="pf-c-form-control pf-m-resize-vertical"
className=""
disabled={false}
id="test"
innerRef={null}
isDisabled={false}
isRequired={false}
name="test"
onChange={[Function]}
readOnly={false}
required={false}
resizeOrientation="vertical"
validated="default"
value=""
/>
>
<textarea
aria-invalid={false}
aria-label="File upload"
className="pf-c-form-control pf-m-resize-vertical"
disabled={false}
id="test"
name="test"
onChange={[Function]}
readOnly={false}
required={false}
value=""
/>
</TextArea>
</TextArea>
</TextArea>
</div>
<input
accept=".json"
autoComplete="off"
multiple={false}
onChange={[Function]}
onClick={[Function]}
style={
Object {
"display": "none",
</div>
<input
accept=".json"
autoComplete="off"
multiple={false}
onChange={[Function]}
onClick={[Function]}
style={
Object {
"display": "none",
}
}
}
tabIndex={-1}
type="file"
/>
</div>
</FileUploadField>
</t>
</FileUpload>
tabIndex={-1}
type="file"
/>
</div>
</FileUploadField>
</t>
</FileUpload>
</JsonFileUploadComp>
<div
aria-live="polite"
className="pf-c-form__helper-text"
id="test-helper"
>
Upload a JSON file
helpFileUpload
</div>
</div>
</div>
@ -276,7 +278,7 @@ exports[`<JsonFileUpload /> upload file 1`] = `
>
<FormGroup
fieldId="upload"
helperText="Upload a JSON file"
helperText="helpFileUpload"
label="resourceFile"
>
<div
@ -300,57 +302,43 @@ exports[`<JsonFileUpload /> upload file 1`] = `
<div
className="pf-c-form__group-control"
>
<FileUpload
dropzoneProps={
Object {
"accept": ".json",
<JsonFileUploadComp>
<FileUpload
dropzoneProps={
Object {
"accept": ".json",
}
}
}
filename=""
id="upload"
isLoading={false}
onChange={[Function]}
onReadFinished={[Function]}
onReadStarted={[Function]}
type="text"
value=""
>
<t
accept=".json"
disabled={false}
getDataTransferItems={[Function]}
maxSize={Infinity}
minSize={0}
multiple={false}
onDropAccepted={[Function]}
onDropRejected={[Function]}
preventDropOnDocument={true}
filename=""
id="upload"
isLoading={false}
onChange={[Function]}
onReadFinished={[Function]}
onReadStarted={[Function]}
type="text"
value=""
>
<FileUploadField
containerRef={[Function]}
filename=""
id="upload"
isDragActive={false}
isLoading={false}
onBlur={[Function]}
onBrowseButtonClick={[Function]}
onChange={[Function]}
onClearButtonClick={[Function]}
onClick={[Function]}
onDragEnter={[Function]}
onDragLeave={[Function]}
onDragOver={[Function]}
onDragStart={[Function]}
onDrop={[Function]}
onFocus={[Function]}
onKeyDown={[Function]}
tabIndex={null}
type="text"
value=""
<t
accept=".json"
disabled={false}
getDataTransferItems={[Function]}
maxSize={Infinity}
minSize={0}
multiple={false}
onDropAccepted={[Function]}
onDropRejected={[Function]}
preventDropOnDocument={true}
>
<div
className="pf-c-file-upload"
<FileUploadField
containerRef={[Function]}
filename=""
id="upload"
isDragActive={false}
isLoading={false}
onBlur={[Function]}
onBrowseButtonClick={[Function]}
onChange={[Function]}
onClearButtonClick={[Function]}
onClick={[Function]}
onDragEnter={[Function]}
onDragLeave={[Function]}
@ -360,131 +348,131 @@ exports[`<JsonFileUpload /> upload file 1`] = `
onFocus={[Function]}
onKeyDown={[Function]}
tabIndex={null}
type="text"
value=""
>
<div
className="pf-c-file-upload__file-select"
className="pf-c-file-upload"
onBlur={[Function]}
onClick={[Function]}
onDragEnter={[Function]}
onDragLeave={[Function]}
onDragOver={[Function]}
onDragStart={[Function]}
onDrop={[Function]}
onFocus={[Function]}
onKeyDown={[Function]}
tabIndex={null}
>
<InputGroup>
<div
className="pf-c-input-group"
>
<TextInput
aria-describedby="upload-browse-button"
aria-label="Drag a file here or browse to upload"
id="upload-filename"
isDisabled={false}
isReadOnly={true}
key=".0"
name="upload-filename"
placeholder="Drag a file here or browse to upload"
value=""
<div
className="pf-c-file-upload__file-select"
>
<InputGroup>
<div
className="pf-c-input-group"
>
<TextInputBase
<TextInput
aria-describedby="upload-browse-button"
aria-label="Drag a file here or browse to upload"
className=""
id="upload-filename"
innerRef={null}
isDisabled={false}
isLeftTruncated={false}
isReadOnly={true}
isRequired={false}
key=".0"
name="upload-filename"
onChange={[Function]}
placeholder="Drag a file here or browse to upload"
type="text"
validated="default"
value=""
>
<input
<TextInputBase
aria-describedby="upload-browse-button"
aria-invalid={false}
aria-label="Drag a file here or browse to upload"
className="pf-c-form-control"
disabled={false}
className=""
id="upload-filename"
innerRef={null}
isDisabled={false}
isLeftTruncated={false}
isReadOnly={true}
isRequired={false}
name="upload-filename"
onBlur={[Function]}
onChange={[Function]}
onFocus={[Function]}
placeholder="Drag a file here or browse to upload"
readOnly={true}
required={false}
type="text"
validated="default"
value=""
/>
</TextInputBase>
</TextInput>
<Button
id="upload-browse-button"
isDisabled={false}
key=".1"
onClick={[Function]}
variant="control"
>
<button
aria-disabled={false}
aria-label={null}
className="pf-c-button pf-m-control"
data-ouia-component-id="OUIA-Generated-Button-control-3"
data-ouia-component-type="PF4/Button"
data-ouia-safe={true}
disabled={false}
>
<input
aria-describedby="upload-browse-button"
aria-invalid={false}
aria-label="Drag a file here or browse to upload"
className="pf-c-form-control"
disabled={false}
id="upload-filename"
name="upload-filename"
onBlur={[Function]}
onChange={[Function]}
onFocus={[Function]}
placeholder="Drag a file here or browse to upload"
readOnly={true}
required={false}
type="text"
value=""
/>
</TextInputBase>
</TextInput>
<Button
id="upload-browse-button"
isDisabled={false}
key=".1"
onClick={[Function]}
role={null}
type="button"
variant="control"
>
Browse...
</button>
</Button>
<Button
isDisabled={true}
key=".2"
onClick={[Function]}
variant="control"
>
<button
aria-disabled={true}
aria-label={null}
className="pf-c-button pf-m-control pf-m-disabled"
data-ouia-component-id="OUIA-Generated-Button-control-4"
data-ouia-component-type="PF4/Button"
data-ouia-safe={true}
disabled={true}
<button
aria-disabled={false}
aria-label={null}
className="pf-c-button pf-m-control"
data-ouia-component-id="OUIA-Generated-Button-control-3"
data-ouia-component-type="PF4/Button"
data-ouia-safe={true}
disabled={false}
id="upload-browse-button"
onClick={[Function]}
role={null}
type="button"
>
Browse...
</button>
</Button>
<Button
isDisabled={true}
key=".2"
onClick={[Function]}
role={null}
tabIndex={null}
type="button"
variant="control"
>
Clear
</button>
</Button>
</div>
</InputGroup>
</div>
<div
className="pf-c-file-upload__file-details"
>
<TextArea
aria-label="File upload"
disabled={false}
id="upload"
isRequired={false}
name="upload"
onChange={[Function]}
readOnly={false}
resizeOrientation="vertical"
validated="default"
value=""
<button
aria-disabled={true}
aria-label={null}
className="pf-c-button pf-m-control pf-m-disabled"
data-ouia-component-id="OUIA-Generated-Button-control-4"
data-ouia-component-type="PF4/Button"
data-ouia-safe={true}
disabled={true}
onClick={[Function]}
role={null}
tabIndex={null}
type="button"
>
Clear
</button>
</Button>
</div>
</InputGroup>
</div>
<div
className="pf-c-file-upload__file-details"
>
<TextArea
aria-label="File upload"
className=""
disabled={false}
id="upload"
innerRef={null}
isDisabled={false}
isRequired={false}
name="upload"
onChange={[Function]}
@ -493,45 +481,61 @@ exports[`<JsonFileUpload /> upload file 1`] = `
validated="default"
value=""
>
<textarea
aria-invalid={false}
<TextArea
aria-label="File upload"
className="pf-c-form-control pf-m-resize-vertical"
className=""
disabled={false}
id="upload"
innerRef={null}
isDisabled={false}
isRequired={false}
name="upload"
onChange={[Function]}
readOnly={false}
required={false}
resizeOrientation="vertical"
validated="default"
value=""
/>
>
<textarea
aria-invalid={false}
aria-label="File upload"
className="pf-c-form-control pf-m-resize-vertical"
disabled={false}
id="upload"
name="upload"
onChange={[Function]}
readOnly={false}
required={false}
value=""
/>
</TextArea>
</TextArea>
</TextArea>
</div>
<input
accept=".json"
autoComplete="off"
multiple={false}
onChange={[Function]}
onClick={[Function]}
style={
Object {
"display": "none",
</div>
<input
accept=".json"
autoComplete="off"
multiple={false}
onChange={[Function]}
onClick={[Function]}
style={
Object {
"display": "none",
}
}
}
tabIndex={-1}
type="file"
/>
</div>
</FileUploadField>
</t>
</FileUpload>
tabIndex={-1}
type="file"
/>
</div>
</FileUploadField>
</t>
</FileUpload>
</JsonFileUploadComp>
<div
aria-live="polite"
className="pf-c-form__helper-text"
id="upload-helper"
>
Upload a JSON file
helpFileUpload
</div>
</div>
</div>

View file

@ -0,0 +1,39 @@
export interface OIDCConfigurationRepresentation {
issuer?: string;
authorization_endpoint?: string;
token_endpoint?: string;
introspection_endpoint?: string;
userinfo_endpoint?: string;
end_session_endpoint?: string;
jwks_uri?: string;
check_session_iframe?: string;
grant_types_supported?: string[];
response_types_supported?: string[];
subject_types_supported?: string[];
id_token_signing_alg_values_supported?: string[];
id_token_encryption_alg_values_supported?: string[];
id_token_encryption_enc_values_supported?: string[];
userinfo_signing_alg_values_supported?: string[];
request_object_signing_alg_values_supported?: string[];
response_modes_supported?: string[];
registration_endpoint?: string;
token_endpoint_auth_methods_supported?: string[];
token_endpoint_auth_signing_alg_values_supported?: string[];
introspection_endpoint_auth_methods_supported?: string[];
introspection_endpoint_auth_signing_alg_values_supported?: string[];
claims_supported?: string[];
claim_types_supported?: string[];
claims_parameter_supported?: boolean;
scopes_supported?: string[];
request_parameter_supported?: boolean;
request_uri_parameter_supported?: boolean;
require_request_uri_registration?: boolean;
code_challenge_methods_supported?: string[];
tls_client_certificate_bound_access_tokens?: boolean;
revocation_endpoint?: string;
revocation_endpoint_auth_methods_supported?: string[];
revocation_endpoint_auth_signing_alg_values_supported?: string[];
backchannel_logout_supported?: boolean;
backchannel_logout_session_supported?: boolean;
device_authorization_endpoint?: string;
}

View file

@ -1,47 +1,37 @@
import React from "react";
import { useHistory, useParams } from "react-router-dom";
import { useTranslation } from "react-i18next";
import { Controller, useForm } from "react-hook-form";
import { FormProvider, useForm } from "react-hook-form";
import {
ActionGroup,
AlertVariant,
Button,
ClipboardCopy,
FormGroup,
NumberInput,
PageSection,
TextInput,
ValidatedOptions,
} from "@patternfly/react-core";
import IdentityProviderRepresentation from "keycloak-admin/lib/defs/identityProviderRepresentation";
import { ViewHeader } from "../../components/view-header/ViewHeader";
import { getBaseUrl, toUpperCase } from "../../util";
import { toUpperCase } from "../../util";
import { FormAccess } from "../../components/form-access/FormAccess";
import { HelpItem } from "../../components/help-enabler/HelpItem";
import { useAdminClient } from "../../context/auth/AdminClient";
import { useRealm } from "../../context/realm-context/RealmContext";
import { useAlerts } from "../../components/alert/Alerts";
import { GeneralSettings } from "./GeneralSettings";
export const AddIdentityProvider = () => {
const { t } = useTranslation("identity-providers");
const { t: th } = useTranslation("identity-providers-help");
const { id } = useParams<{ id: string }>();
const form = useForm<IdentityProviderRepresentation>();
const {
handleSubmit,
register,
errors,
control,
formState: { isDirty },
} = useForm<IdentityProviderRepresentation>();
} = form;
const adminClient = useAdminClient();
const { addAlert } = useAlerts();
const history = useHistory();
const { realm } = useRealm();
const callbackUrl = `${getBaseUrl(adminClient)}/realms/${realm}/broker`;
const save = async (provider: IdentityProviderRepresentation) => {
try {
await adminClient.identityProviders.create({
@ -50,7 +40,7 @@ export const AddIdentityProvider = () => {
alias: id,
});
addAlert(t("createSuccess"), AlertVariant.success);
history.push(`/${realm}/identity-providers`);
history.push(`/${realm}/identity-providers/${id}/settings`);
} catch (error) {
addAlert(t("createError", { error }), AlertVariant.danger);
}
@ -67,105 +57,9 @@ export const AddIdentityProvider = () => {
isHorizontal
onSubmit={handleSubmit(save)}
>
<FormGroup
label={t("redirectURI")}
labelIcon={
<HelpItem
helpText={th("redirectURI")}
forLabel={t("redirectURI")}
forID="kc-redirect-uri"
/>
}
fieldId="kc-redirect-uri"
>
<ClipboardCopy
isReadOnly
>{`${callbackUrl}/${id}/endpoint`}</ClipboardCopy>
</FormGroup>
<FormGroup
label={t("clientId")}
labelIcon={
<HelpItem
helpText={th("clientId")}
forLabel={t("clientId")}
forID="kc-client-id"
/>
}
fieldId="kc-client-id"
isRequired
validated={
errors.config && errors.config.clientId
? ValidatedOptions.error
: ValidatedOptions.default
}
helperTextInvalid={t("common:required")}
>
<TextInput
isRequired
type="text"
id="kc-client-id"
data-testid="clientId"
name="config.clientId"
ref={register({ required: true })}
/>
</FormGroup>
<FormGroup
label={t("clientSecret")}
labelIcon={
<HelpItem
helpText={th("clientSecret")}
forLabel={t("clientSecret")}
forID="kc-client-secret"
/>
}
fieldId="kc-client-secret"
isRequired
validated={
errors.config && errors.config.clientSecret
? ValidatedOptions.error
: ValidatedOptions.default
}
helperTextInvalid={t("common:required")}
>
<TextInput
isRequired
type="password"
id="kc-client-secret"
data-testid="clientSecret"
name="config.clientSecret"
ref={register({ required: true })}
/>
</FormGroup>
<FormGroup
label={t("displayOrder")}
labelIcon={
<HelpItem
helpText={th("displayOrder")}
forLabel={t("displayOrder")}
forID="kc-display-order"
/>
}
fieldId="kc-display-order"
>
<Controller
name="config.guiOrder"
control={control}
defaultValue={0}
render={({ onChange, value }) => (
<NumberInput
value={value}
data-testid="displayOrder"
onMinus={() => onChange(value - 1)}
onChange={onChange}
onPlus={() => onChange(value + 1)}
inputName="input"
inputAriaLabel={t("displayOrder")}
minusBtnAriaLabel={t("common:minus")}
plusBtnAriaLabel={t("common:plus")}
/>
)}
/>
</FormGroup>
<FormProvider {...form}>
<GeneralSettings />
</FormProvider>
<ActionGroup>
<Button
isDisabled={!isDirty}

View file

@ -0,0 +1,87 @@
import React from "react";
import { useHistory } from "react-router-dom";
import { useTranslation } from "react-i18next";
import { FormProvider, useForm } from "react-hook-form";
import {
ActionGroup,
AlertVariant,
Button,
PageSection,
} from "@patternfly/react-core";
import IdentityProviderRepresentation from "keycloak-admin/lib/defs/identityProviderRepresentation";
import { FormAccess } from "../../components/form-access/FormAccess";
import { ViewHeader } from "../../components/view-header/ViewHeader";
import { useAdminClient } from "../../context/auth/AdminClient";
import { OIDCGeneralSettings } from "./OIDCGeneralSettings";
import { OpenIdConnectSettings } from "./OpenIdConnectSettings";
import { useRealm } from "../../context/realm-context/RealmContext";
import { OIDCAuthentication } from "./OIDCAuthentication";
import { useAlerts } from "../../components/alert/Alerts";
export const AddOpenIdConnect = () => {
const { t } = useTranslation("identity-providers");
const history = useHistory();
const id = "oidc";
const form = useForm<IdentityProviderRepresentation>({
defaultValues: { alias: id },
});
const {
handleSubmit,
formState: { isDirty },
} = form;
const adminClient = useAdminClient();
const { addAlert } = useAlerts();
const { realm } = useRealm();
const save = async (provider: IdentityProviderRepresentation) => {
try {
await adminClient.identityProviders.create({
...provider,
providerId: id,
});
addAlert(t("createSuccess"), AlertVariant.success);
history.push(`/${realm}/identity-providers/${id}/settings`);
} catch (error) {
addAlert(t("createError", { error }), AlertVariant.danger);
}
};
return (
<>
<ViewHeader titleKey={t("addOpenIdProvider")} />
<PageSection variant="light">
<FormProvider {...form}>
<FormAccess
role="manage-identity-providers"
isHorizontal
onSubmit={handleSubmit(save)}
>
<OIDCGeneralSettings />
<OpenIdConnectSettings />
<OIDCAuthentication />
<ActionGroup>
<Button
isDisabled={!isDirty}
variant="primary"
type="submit"
data-testid="createProvider"
>
{t("common:add")}
</Button>
<Button
variant="link"
data-testid="cancel"
onClick={() => history.push(`/${realm}/identity-providers`)}
>
{t("common:cancel")}
</Button>
</ActionGroup>
</FormAccess>
</FormProvider>
</PageSection>
</>
);
};

View file

@ -0,0 +1,186 @@
import React, { useEffect, useState } from "react";
import { useErrorHandler } from "react-error-boundary";
import { useTranslation } from "react-i18next";
import { Controller, useFormContext } from "react-hook-form";
import {
ActionGroup,
Button,
FormGroup,
Select,
SelectOption,
SelectVariant,
} from "@patternfly/react-core";
import AuthenticationFlowRepresentation from "keycloak-admin/lib/defs/authenticationFlowRepresentation";
import {
asyncStateFetch,
useAdminClient,
} from "../../context/auth/AdminClient";
import { SwitchField } from "../component/SwitchField";
import { TextField } from "../component/TextField";
import { HelpItem } from "../../components/help-enabler/HelpItem";
import { FieldProps } from "../component/FormGroupField";
const LoginFlow = ({
field,
label,
defaultValue,
}: FieldProps & { defaultValue: string }) => {
const { t } = useTranslation("identity-providers");
const { control } = useFormContext();
const adminClient = useAdminClient();
const errorHandler = useErrorHandler();
const [flows, setFlows] = useState<AuthenticationFlowRepresentation[]>();
const [open, setOpen] = useState(false);
useEffect(
() =>
asyncStateFetch(
() => adminClient.authenticationManagement.getFlows(),
setFlows,
errorHandler
),
[]
);
return (
<FormGroup
label={t(label)}
labelIcon={
<HelpItem
helpText={`identity-providers-help:${label}`}
forLabel={t(label)}
forID={label}
/>
}
fieldId={label}
>
<Controller
name={field}
defaultValue={defaultValue}
control={control}
render={({ onChange, value }) => (
<Select
toggleId={label}
required
onToggle={() => setOpen(!open)}
onSelect={(_, value) => {
onChange(value as string);
setOpen(false);
}}
selections={value}
variant={SelectVariant.single}
aria-label={t(label)}
isOpen={open}
>
{flows &&
flows.map((option) => (
<SelectOption
selected={option.alias === value}
key={option.id}
value={option.alias}
>
{option.alias}
</SelectOption>
))}
</Select>
)}
/>
</FormGroup>
);
};
const syncModes = ["import", "legacy", "force"];
export const AdvancedSettings = ({ isOIDC }: { isOIDC: boolean }) => {
const { t } = useTranslation("identity-providers");
const { control } = useFormContext();
const [syncModeOpen, setSyncModeOpen] = useState(false);
return (
<>
{!isOIDC && <TextField field="config.defaultScope" label="scopes" />}
<SwitchField field="storeToken" label="storeTokens" fieldType="boolean" />
{!isOIDC && (
<>
<SwitchField
field="config.acceptsPromptNoneForwardFromClient"
label="acceptsPromptNone"
/>
<SwitchField field="config.disableUserInfo" label="disableUserInfo" />
</>
)}
<SwitchField field="trustEmail" label="trustEmail" fieldType="boolean" />
<SwitchField
field="linkOnly"
label="accountLinkingOnly"
fieldType="boolean"
/>
<SwitchField field="config.hideOnLoginPage" label="hideOnLoginPage" />
<LoginFlow
field="firstBrokerLoginFlowAlias"
label="firstBrokerLoginFlowAlias"
defaultValue="fist broker login"
/>
<LoginFlow
field="postBrokerLoginFlowAlias"
label="postBrokerLoginFlowAlias"
defaultValue=""
/>
<FormGroup
label={t("syncMode")}
labelIcon={
<HelpItem
helpText="identity-providers-help:syncMode"
forLabel={t("syncMode")}
forID="syncMode"
/>
}
fieldId="syncMode"
>
<Controller
name="config.syncMode"
defaultValue={syncModes[0]}
control={control}
render={({ onChange, value }) => (
<Select
toggleId="syncMode"
required
direction="up"
onToggle={() => setSyncModeOpen(!syncModeOpen)}
onSelect={(_, value) => {
onChange(value as string);
setSyncModeOpen(false);
}}
selections={t(`syncModes.${value.toLowerCase()}`)}
variant={SelectVariant.single}
aria-label={t("syncMode")}
isOpen={syncModeOpen}
>
{syncModes.map((option) => (
<SelectOption
selected={option === value}
key={option}
value={option.toUpperCase()}
>
{t(`syncModes.${option}`)}
</SelectOption>
))}
</Select>
)}
/>
</FormGroup>
<ActionGroup className="keycloak__form_actions">
<Button data-testid={"save"} variant="tertiary" type="submit">
{t("common:save")}
</Button>
<Button data-testid={"revert"} variant="link" onClick={() => {}}>
{t("common:revert")}
</Button>
</ActionGroup>
</>
);
};

View file

@ -0,0 +1,207 @@
import React, { useEffect } from "react";
import { useHistory, useParams } from "react-router-dom";
import { useErrorHandler } from "react-error-boundary";
import { useTranslation } from "react-i18next";
import { Controller, FormProvider, useForm } from "react-hook-form";
import {
AlertVariant,
ButtonVariant,
Divider,
DropdownItem,
Form,
PageSection,
Tab,
TabTitleText,
} from "@patternfly/react-core";
import IdentityProviderRepresentation from "keycloak-admin/lib/defs/identityProviderRepresentation";
import { FormAccess } from "../../components/form-access/FormAccess";
import { ScrollForm } from "../../components/scroll-form/ScrollForm";
import { ViewHeader } from "../../components/view-header/ViewHeader";
import {
asyncStateFetch,
useAdminClient,
} from "../../context/auth/AdminClient";
import { toUpperCase } from "../../util";
import { GeneralSettings } from "./GeneralSettings";
import { AdvancedSettings } from "./AdvancedSettings";
import { useConfirmDialog } from "../../components/confirm-dialog/ConfirmDialog";
import { useAlerts } from "../../components/alert/Alerts";
import { useRealm } from "../../context/realm-context/RealmContext";
import { KeycloakTabs } from "../../components/keycloak-tabs/KeycloakTabs";
import { ExtendedNonDiscoverySettings } from "./ExtendedNonDiscoverySettings";
import { DiscoverySettings } from "./DiscoverySettings";
import { OIDCGeneralSettings } from "./OIDCGeneralSettings";
import { OIDCAuthentication } from "./OIDCAuthentication";
type HeaderProps = {
onChange: (value: boolean) => void;
value: boolean;
save: () => void;
toggleDeleteDialog: () => void;
};
const Header = ({ onChange, value, save, toggleDeleteDialog }: HeaderProps) => {
const { t } = useTranslation("identity-providers");
const { id } = useParams<{ id: string }>();
const [toggleDisableDialog, DisableConfirm] = useConfirmDialog({
titleKey: "identity-providers:disableProvider",
messageKey: t("disableConfirm", { provider: id }),
continueButtonLabel: "common:disable",
onConfirm: () => {
onChange(!value);
save();
},
});
return (
<>
<DisableConfirm />
<ViewHeader
titleKey={t("addIdentityProvider", { provider: toUpperCase(id) })}
divider={false}
dropdownItems={[
<DropdownItem key="delete" onClick={() => toggleDeleteDialog()}>
{t("common:delete")}
</DropdownItem>,
]}
isEnabled={value}
onToggle={(value) => {
if (!value) {
toggleDisableDialog();
} else {
onChange(value);
save();
}
}}
/>
</>
);
};
export const DetailSettings = () => {
const { t } = useTranslation("identity-providers");
const { id } = useParams<{ id: string }>();
const form = useForm<IdentityProviderRepresentation>();
const { handleSubmit, setValue, getValues } = form;
const adminClient = useAdminClient();
const { addAlert } = useAlerts();
const history = useHistory();
const { realm } = useRealm();
const errorHandler = useErrorHandler();
useEffect(
() =>
asyncStateFetch(
() => adminClient.identityProviders.findOne({ alias: id }),
(provider) => {
Object.entries(provider).map((entry) => setValue(entry[0], entry[1]));
},
errorHandler
),
[]
);
const save = async (provider?: IdentityProviderRepresentation) => {
const p = provider || getValues();
try {
await adminClient.identityProviders.update(
{ alias: id },
{ ...p, alias: id, providerId: id }
);
addAlert(t("updateSuccess"), AlertVariant.success);
} catch (error) {
addAlert(
t("updateError", {
error: error.response?.data?.errorMessage || error,
}),
AlertVariant.danger
);
}
};
const [toggleDeleteDialog, DeleteConfirm] = useConfirmDialog({
titleKey: "identity-providers:deleteProvider",
messageKey: t("identity-providers:deleteConfirm", { provider: id }),
continueButtonLabel: "common:delete",
continueButtonVariant: ButtonVariant.danger,
onConfirm: async () => {
try {
await adminClient.identityProviders.del({ alias: id });
addAlert(t("deletedSuccess"), AlertVariant.success);
history.push(`/${realm}/identity-providers`);
} catch (error) {
addAlert(t("deleteErrorError", { error }), AlertVariant.danger);
}
},
});
const sections = [t("generalSettings"), t("advancedSettings")];
const isOIDC = id === "oidc";
if (isOIDC) {
sections.splice(1, 0, t("oidcSettings"));
}
return (
<>
<DeleteConfirm />
<Controller
name="enabled"
control={form.control}
defaultValue={true}
render={({ onChange, value }) => (
<Header
value={value}
onChange={onChange}
save={save}
toggleDeleteDialog={toggleDeleteDialog}
/>
)}
/>
<PageSection variant="light" className="pf-u-p-0">
<FormProvider {...form}>
<KeycloakTabs isBox>
<Tab
id="settings"
eventKey="settings"
title={<TabTitleText>{t("common:settings")}</TabTitleText>}
>
<ScrollForm className="pf-u-px-lg" sections={sections}>
<FormAccess
role="manage-identity-providers"
isHorizontal
onSubmit={handleSubmit(save)}
>
{!isOIDC && <GeneralSettings />}
{isOIDC && <OIDCGeneralSettings />}
</FormAccess>
{isOIDC && (
<>
<DiscoverySettings readOnly={false} />
<Form isHorizontal className="pf-u-py-lg">
<Divider />
<OIDCAuthentication />
</Form>
<ExtendedNonDiscoverySettings />
</>
)}
<FormAccess
role="manage-identity-providers"
isHorizontal
onSubmit={handleSubmit(save)}
>
<AdvancedSettings isOIDC={isOIDC} />
</FormAccess>
</ScrollForm>
</Tab>
</KeycloakTabs>
</FormProvider>
</PageSection>
</>
);
};

View file

@ -0,0 +1,137 @@
import React, { useState } from "react";
import { useTranslation } from "react-i18next";
import { useFormContext, useWatch } from "react-hook-form";
import {
ExpandableSection,
FormGroup,
TextInput,
ValidatedOptions,
} from "@patternfly/react-core";
import { SwitchField } from "../component/SwitchField";
import { TextField } from "../component/TextField";
type DiscoverySettingsProps = {
readOnly: boolean;
};
const Fields = ({ readOnly }: DiscoverySettingsProps) => {
const { t } = useTranslation("identity-providers");
const { register, control, errors } = useFormContext();
const validateSignature = useWatch({
control: control,
name: "config.validateSignature",
});
const useJwks = useWatch({
control: control,
name: "config.useJwksUrl",
});
return (
<div className="pf-c-form pf-m-horizontal">
<FormGroup
label={t("authorizationUrl")}
fieldId="kc-authorization-url"
isRequired
validated={
errors.config && errors.config.authorizationUrl
? ValidatedOptions.error
: ValidatedOptions.default
}
helperTextInvalid={t("common:required")}
>
<TextInput
type="text"
data-testid="authorizationUrl"
id="kc-authorization-url"
name="config.authorizationUrl"
ref={register({ required: true })}
validated={
errors.config && errors.config.authorizationUrl
? ValidatedOptions.error
: ValidatedOptions.default
}
isReadOnly={readOnly}
/>
</FormGroup>
<FormGroup
label={t("tokenUrl")}
fieldId="tokenUrl"
isRequired
validated={
errors.config && errors.config.tokenUrl
? ValidatedOptions.error
: ValidatedOptions.default
}
helperTextInvalid={t("common:required")}
>
<TextInput
type="text"
id="tokenUrl"
name="config.tokenUrl"
ref={register({ required: true })}
validated={
errors.config && errors.config.tokenUrl
? ValidatedOptions.error
: ValidatedOptions.default
}
isReadOnly={readOnly}
/>
</FormGroup>
<TextField
field="config.logoutUrl"
label="logoutUrl"
isReadOnly={readOnly}
/>
<TextField
field="config.userInfoUrl"
label="userInfoUrl"
isReadOnly={readOnly}
/>
<TextField field="config.issuer" label="issuer" isReadOnly={readOnly} />
<SwitchField
field="config.validateSignature"
label="validateSignature"
isReadOnly={readOnly}
/>
{validateSignature === "true" && (
<>
<SwitchField
field="config.useJwksUrl"
label="useJwksUrl"
isReadOnly={readOnly}
/>
{useJwks === "true" && (
<TextField
field="config.jwksUrl"
label="jwksUrl"
isReadOnly={readOnly}
/>
)}
</>
)}
</div>
);
};
export const DiscoverySettings = ({ readOnly }: DiscoverySettingsProps) => {
const { t } = useTranslation("identity-providers");
const [isExpanded, setIsExpanded] = useState(false);
return (
<>
{readOnly && (
<ExpandableSection
toggleText={isExpanded ? t("hideMetaData") : t("showMetaData")}
onToggle={() => setIsExpanded(!isExpanded)}
isExpanded={isExpanded}
>
<Fields readOnly={readOnly} />
</ExpandableSection>
)}
{!readOnly && <Fields readOnly={readOnly} />}
</>
);
};

View file

@ -0,0 +1,126 @@
import React, { useState } from "react";
import { useTranslation } from "react-i18next";
import { Controller, useFormContext } from "react-hook-form";
import {
ExpandableSection,
Form,
FormGroup,
NumberInput,
Select,
SelectOption,
SelectVariant,
} from "@patternfly/react-core";
import { SwitchField } from "../component/SwitchField";
import { TextField } from "../component/TextField";
import { FormGroupField } from "../component/FormGroupField";
import { HelpItem } from "../../components/help-enabler/HelpItem";
const promptOptions = [
"unspecified",
"none",
"consent",
"login",
"select_account",
];
export const ExtendedNonDiscoverySettings = () => {
const { t } = useTranslation("identity-providers");
const { control } = useFormContext();
const [isExpanded, setIsExpanded] = useState(false);
const [promptOpen, setPromptOpen] = useState(false);
return (
<>
<ExpandableSection
toggleText={t("advanced")}
onToggle={() => setIsExpanded(!isExpanded)}
isExpanded={isExpanded}
>
<Form isHorizontal>
<SwitchField label="passLoginHint" field="config.loginHint" />
<SwitchField label="passCurrentLocale" field="config.uiLocales" />
<SwitchField
field="config.backchannelSupported"
label="backchannelLogout"
/>
<SwitchField field="config.disableUserInfo" label="disableUserInfo" />
<TextField field="config.defaultScope" label="scopes" />
<FormGroupField label="prompt">
<Controller
name="config.prompt"
defaultValue={promptOptions[0]}
control={control}
render={({ onChange, value }) => (
<Select
toggleId="prompt"
required
onToggle={() => setPromptOpen(!promptOpen)}
onSelect={(_, value) => {
onChange(value as string);
setPromptOpen(false);
}}
selections={value}
variant={SelectVariant.single}
aria-label={t("prompt")}
isOpen={promptOpen}
>
{promptOptions.map((option) => (
<SelectOption
selected={option === value}
key={option}
value={option}
>
{t(`prompts.${option}`)}
</SelectOption>
))}
</Select>
)}
/>
</FormGroupField>
<SwitchField
field="config.acceptsPromptNoneForwardFromClient"
label="acceptsPromptNone"
/>
<FormGroup
label={t("allowedClockSkew")}
labelIcon={
<HelpItem
helpText={"identity-providers-help:allowedClockSkew"}
forLabel={t("allowedClockSkew")}
forID="allowedClockSkew"
/>
}
fieldId="allowedClockSkew"
>
<Controller
name="config.allowedClockSkew"
control={control}
defaultValue={0}
render={({ onChange, value }) => (
<NumberInput
value={value}
data-testid="allowedClockSkew"
onMinus={() => onChange(value - 1)}
onChange={onChange}
onPlus={() => onChange(value + 1)}
inputName="input"
inputAriaLabel={t("allowedClockSkew")}
minusBtnAriaLabel={t("common:minus")}
plusBtnAriaLabel={t("common:plus")}
min={0}
unit={t("common:times.seconds")}
/>
)}
/>
</FormGroup>
<TextField
field="config.forwardParameters"
label="forwardParameters"
/>
</Form>
</ExpandableSection>
</>
);
};

View file

@ -0,0 +1,13 @@
import React from "react";
import { RedirectUrl } from "../component/RedirectUrl";
import { ClientIdSecret } from "../component/ClientIdSecret";
import { DisplayOrder } from "../component/DisplayOrder";
export const GeneralSettings = () => (
<>
<RedirectUrl />
<ClientIdSecret />
<DisplayOrder />
</>
);

View file

@ -0,0 +1,82 @@
import React, { useState } from "react";
import { useTranslation } from "react-i18next";
import { Controller, useFormContext, useWatch } from "react-hook-form";
import {
FormGroup,
Select,
SelectOption,
SelectVariant,
} from "@patternfly/react-core";
import { ClientIdSecret } from "../component/ClientIdSecret";
import { HelpItem } from "../../components/help-enabler/HelpItem";
const clientAuthenticationTypes = [
"clientAuth_post",
"clientAuth_basic",
"clientAuth_secret_jwt",
"clientAuth_privatekey_jwt",
];
export const OIDCAuthentication = () => {
const { t } = useTranslation("identity-providers");
const { t: th } = useTranslation("identity-providers-help");
const { control } = useFormContext();
const [openClientAuth, setOpenClientAuth] = useState(false);
const clientAuthMethod = useWatch({
control: control,
name: "config.clientAuthMethod",
});
return (
<>
<FormGroup
label={t("clientAuthentication")}
labelIcon={
<HelpItem
helpText={th("clientAuthentication")}
forLabel={t("clientAuthentication")}
forID="clientAuthentication"
/>
}
fieldId="clientAuthentication"
>
<Controller
name="config.clientAuthMethod"
defaultValue={clientAuthenticationTypes[0]}
control={control}
render={({ onChange, value }) => (
<Select
toggleId="clientAuthMethod"
required
onToggle={() => setOpenClientAuth(!openClientAuth)}
onSelect={(_, value) => {
onChange(value as string);
setOpenClientAuth(false);
}}
selections={value}
variant={SelectVariant.single}
aria-label={t("prompt")}
isOpen={openClientAuth}
>
{clientAuthenticationTypes.map((option) => (
<SelectOption
selected={option === value}
key={option}
value={option}
>
{t(`clientAuthentications.${option}`)}
</SelectOption>
))}
</Select>
)}
/>
</FormGroup>
<ClientIdSecret
secretRequired={clientAuthMethod !== "clientAuth_privatekey_jwt"}
/>
</>
);
};

View file

@ -0,0 +1,51 @@
import React from "react";
import { useTranslation } from "react-i18next";
import { useFormContext } from "react-hook-form";
import { FormGroup, TextInput, ValidatedOptions } from "@patternfly/react-core";
import { HelpItem } from "../../components/help-enabler/HelpItem";
import { RedirectUrl } from "../component/RedirectUrl";
import { TextField } from "../component/TextField";
import { DisplayOrder } from "../component/DisplayOrder";
export const OIDCGeneralSettings = () => {
const { t } = useTranslation("identity-providers");
const { t: th } = useTranslation("identity-providers-help");
const { register, errors } = useFormContext();
return (
<>
<RedirectUrl />
<FormGroup
label={t("alias")}
labelIcon={
<HelpItem
helpText={th("alias")}
forLabel={t("alias")}
forID="alias"
/>
}
fieldId="alias"
isRequired
validated={
errors.errors ? ValidatedOptions.error : ValidatedOptions.default
}
helperTextInvalid={t("common:required")}
>
<TextInput
isRequired
type="text"
id="alias"
data-testid="alias"
name="alias"
ref={register({ required: true })}
/>
</FormGroup>
<TextField field="displayName" label="displayName" />
<DisplayOrder />
</>
);
};

View file

@ -0,0 +1,186 @@
import React, { useEffect, useState } from "react";
import { useFormContext } from "react-hook-form";
import { FormGroup, Switch, TextInput, Title } from "@patternfly/react-core";
import { HelpItem } from "../../components/help-enabler/HelpItem";
import { useTranslation } from "react-i18next";
import { useAdminClient } from "../../context/auth/AdminClient";
import { OIDCConfigurationRepresentation } from "../OIDCConfigurationRepresentation";
import { JsonFileUpload } from "../../components/json-file-upload/JsonFileUpload";
import { useRealm } from "../../context/realm-context/RealmContext";
import { DiscoverySettings } from "./DiscoverySettings";
import { getBaseUrl } from "../../util";
type Result = OIDCConfigurationRepresentation & {
error: string;
};
export const OpenIdConnectSettings = () => {
const { t } = useTranslation("identity-providers");
const id = "oidc";
const adminClient = useAdminClient();
const { realm } = useRealm();
const { setValue } = useFormContext();
const [discovery, setDiscovery] = useState(true);
const [discoveryUrl, setDiscoveryUrl] = useState("");
const [discovering, setDiscovering] = useState(false);
const [discoveryResult, setDiscoveryResult] = useState<Result>();
const setupForm = (result: any) => {
Object.keys(result).map((k) => setValue(`config.${k}`, result[k]));
};
useEffect(() => {
if (discovering) {
setDiscovering(!!discoveryUrl);
if (discoveryUrl)
(async () => {
let result;
try {
result = await adminClient.identityProviders.importFromUrl({
providerId: id,
fromUrl: discoveryUrl,
});
} catch (error) {
result = { error };
}
setDiscoveryResult(result as Result);
setupForm(result);
setDiscovering(false);
})();
}
}, [discovering]);
const fileUpload = async (value: string) => {
if (value !== "") {
const formData = new FormData();
formData.append("providerId", id);
formData.append("file", new Blob([value]));
try {
const response = await fetch(
`${getBaseUrl(
adminClient
)}/admin/realms/${realm}/identity-provider/import-config`,
{
method: "POST",
body: formData,
headers: {
Authorization: `bearer ${await adminClient.getAccessToken()}`,
},
}
);
const result = await response.json();
setupForm(result);
} catch (error) {
setDiscoveryResult({ error });
}
}
};
return (
<>
<Title headingLevel="h4" size="xl" className="kc-form-panel__title">
{t("OpenID Connect settings")}
</Title>
<FormGroup
label={t("useDiscoveryEndpoint")}
fieldId="kc-discovery-endpoint-switch"
labelIcon={
<HelpItem
helpText="identity-providers-help:useDiscoveryEndpoint"
forLabel={t("useDiscoveryEndpoint")}
forID="kc-discovery-endpoint-switch"
/>
}
>
<Switch
id="kc-discovery-endpoint-switch"
label={t("common:on")}
labelOff={t("common:off")}
isChecked={discovery}
onChange={setDiscovery}
/>
</FormGroup>
{discovery && (
<FormGroup
label={t("discoveryEndpoint")}
fieldId="kc-discovery-endpoint"
labelIcon={
<HelpItem
helpText="identity-providers-help:discoveryEndpoint"
forLabel={t("discoveryEndpoint")}
forID="kc-discovery-endpoint"
/>
}
validated={
discoveryResult && discoveryResult.error
? "error"
: !discoveryResult
? "default"
: "success"
}
helperTextInvalid={t("noValidMetaDataFound")}
isRequired
>
<TextInput
type="text"
data-testid="discoveryEndpoint"
id="kc-discovery-endpoint"
placeholder="https://hostname/.well-known/openid-configuration"
value={discoveryUrl}
onChange={setDiscoveryUrl}
onBlur={() => setDiscovering(!discovering)}
validated={
discoveryResult && discoveryResult.error
? "error"
: !discoveryResult
? "default"
: "success"
}
customIconUrl={
discovering
? 'data:image/svg+xml;charset=utf8,%3Csvg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" viewBox="0 0 100 100" preserveAspectRatio="xMidYMid"%3E%3Ccircle cx="50" cy="50" fill="none" stroke="%230066cc" stroke-width="10" r="35" stroke-dasharray="164.93361431346415 56.97787143782138"%3E%3CanimateTransform attributeName="transform" type="rotate" repeatCount="indefinite" dur="1s" values="0 50 50;360 50 50" keyTimes="0;1"%3E%3C/animateTransform%3E%3C/circle%3E%3C/svg%3E'
: ""
}
/>
</FormGroup>
)}
{!discovery && (
<FormGroup
label={t("importConfig")}
fieldId="kc-import-config"
labelIcon={
<HelpItem
helpText="identity-providers-help:importConfig"
forLabel={t("importConfig")}
forID="kc-import-config"
/>
}
validated={
discoveryResult && discoveryResult.error ? "error" : "default"
}
helperTextInvalid={discoveryResult?.error?.toString()}
>
<JsonFileUpload
id="kc-import-config"
helpText="identity=providers-help:jsonFileUpload"
hideDefaultPreview
unWrap
validated={
discoveryResult && discoveryResult.error ? "error" : "default"
}
onChange={(value) => fileUpload(value as string)}
/>
</FormGroup>
)}
{discovery && discoveryResult && !discoveryResult.error && (
<DiscoverySettings readOnly={true} />
)}
{!discovery && <DiscoverySettings readOnly={false} />}
</>
);
};

View file

@ -0,0 +1,76 @@
import React from "react";
import { useTranslation } from "react-i18next";
import { useFormContext } from "react-hook-form";
import { FormGroup, TextInput, ValidatedOptions } from "@patternfly/react-core";
import { HelpItem } from "../../components/help-enabler/HelpItem";
export const ClientIdSecret = ({
secretRequired = true,
}: {
secretRequired?: boolean;
}) => {
const { t } = useTranslation("identity-providers");
const { t: th } = useTranslation("identity-providers-help");
const { register, errors } = useFormContext();
return (
<>
<FormGroup
label={t("clientId")}
labelIcon={
<HelpItem
helpText={th("clientId")}
forLabel={t("clientId")}
forID="kc-client-id"
/>
}
fieldId="kc-client-id"
isRequired
validated={
errors.config && errors.config.clientId
? ValidatedOptions.error
: ValidatedOptions.default
}
helperTextInvalid={t("common:required")}
>
<TextInput
isRequired
type="text"
id="kc-client-id"
data-testid="clientId"
name="config.clientId"
ref={register({ required: true })}
/>
</FormGroup>
<FormGroup
label={t("clientSecret")}
labelIcon={
<HelpItem
helpText={th("clientSecret")}
forLabel={t("clientSecret")}
forID="kc-client-secret"
/>
}
fieldId="kc-client-secret"
isRequired={secretRequired}
validated={
errors.config && errors.config.clientSecret
? ValidatedOptions.error
: ValidatedOptions.default
}
helperTextInvalid={t("common:required")}
>
<TextInput
isRequired={secretRequired}
type="password"
id="kc-client-secret"
data-testid="clientSecret"
name="config.clientSecret"
ref={register({ required: secretRequired })}
/>
</FormGroup>
</>
);
};

View file

@ -0,0 +1,46 @@
import React from "react";
import { useTranslation } from "react-i18next";
import { Controller, useFormContext } from "react-hook-form";
import { FormGroup, NumberInput } from "@patternfly/react-core";
import { HelpItem } from "../../components/help-enabler/HelpItem";
export const DisplayOrder = () => {
const { t } = useTranslation("identity-providers");
const { t: th } = useTranslation("identity-providers-help");
const { control } = useFormContext();
return (
<FormGroup
label={t("displayOrder")}
labelIcon={
<HelpItem
helpText={th("displayOrder")}
forLabel={t("displayOrder")}
forID="kc-display-order"
/>
}
fieldId="kc-display-order"
>
<Controller
name="config.guiOrder"
control={control}
defaultValue={0}
render={({ onChange, value }) => (
<NumberInput
value={value}
data-testid="displayOrder"
onMinus={() => onChange(value - 1)}
onChange={onChange}
onPlus={() => onChange(value + 1)}
inputName="input"
inputAriaLabel={t("displayOrder")}
minusBtnAriaLabel={t("common:minus")}
plusBtnAriaLabel={t("common:plus")}
/>
)}
/>
</FormGroup>
);
};

View file

@ -0,0 +1,27 @@
import React, { ReactNode } from "react";
import { useTranslation } from "react-i18next";
import { FormGroup } from "@patternfly/react-core";
import { HelpItem } from "../../components/help-enabler/HelpItem";
export type FieldProps = { label: string; field: string; isReadOnly?: boolean };
export type FormGroupFieldProps = { label: string; children: ReactNode };
export const FormGroupField = ({ label, children }: FormGroupFieldProps) => {
const { t } = useTranslation("identity-providers");
return (
<FormGroup
label={t(label)}
fieldId={label}
labelIcon={
<HelpItem
helpText={`identity-providers-help:${label}`}
forLabel={t(label)}
forID={label}
/>
}
>
{children}
</FormGroup>
);
};

View file

@ -0,0 +1,37 @@
import React from "react";
import { useParams } from "react-router-dom";
import { useTranslation } from "react-i18next";
import { ClipboardCopy, FormGroup } from "@patternfly/react-core";
import { getBaseUrl } from "../../util";
import { HelpItem } from "../../components/help-enabler/HelpItem";
import { useRealm } from "../../context/realm-context/RealmContext";
import { useAdminClient } from "../../context/auth/AdminClient";
export const RedirectUrl = () => {
const { t } = useTranslation("identity-providers");
const { t: th } = useTranslation("identity-providers-help");
const { id } = useParams<{ id: string }>();
const adminClient = useAdminClient();
const { realm } = useRealm();
const callbackUrl = `${getBaseUrl(adminClient)}/realms/${realm}/broker`;
return (
<FormGroup
label={t("redirectURI")}
labelIcon={
<HelpItem
helpText={th("redirectURI")}
forLabel={t("redirectURI")}
forID="kc-redirect-uri"
/>
}
fieldId="kc-redirect-uri"
>
<ClipboardCopy
isReadOnly
>{`${callbackUrl}/${id}/endpoint`}</ClipboardCopy>
</FormGroup>
);
};

View file

@ -0,0 +1,45 @@
import React from "react";
import { useTranslation } from "react-i18next";
import { Controller, useFormContext } from "react-hook-form";
import { Switch } from "@patternfly/react-core";
import { FieldProps, FormGroupField } from "./FormGroupField";
type FieldType = "boolean" | "string";
type SwitchFieldProps = FieldProps & {
fieldType?: FieldType;
};
export const SwitchField = ({
label,
field,
fieldType = "string",
isReadOnly = false,
}: SwitchFieldProps) => {
const { t } = useTranslation("identity-providers");
const { control } = useFormContext();
return (
<FormGroupField label={label}>
<Controller
name={field}
defaultValue={fieldType === "string" ? "false" : false}
control={control}
render={({ onChange, value }) => (
<Switch
id={label}
label={t("common:on")}
labelOff={t("common:off")}
isChecked={
fieldType === "string" ? value === "true" : (value as boolean)
}
onChange={(value) =>
onChange(fieldType === "string" ? "" + value : value)
}
readOnly={isReadOnly}
/>
)}
/>
</FormGroupField>
);
};

View file

@ -0,0 +1,21 @@
import React from "react";
import { useFormContext } from "react-hook-form";
import { TextInput } from "@patternfly/react-core";
import { FieldProps, FormGroupField } from "./FormGroupField";
export const TextField = ({ label, field, isReadOnly = false }: FieldProps) => {
const { register } = useFormContext();
return (
<FormGroupField label={label}>
<TextInput
type="text"
id={label}
data-testid={label}
name={field}
ref={register}
isReadOnly={isReadOnly}
/>
</FormGroupField>
);
};

View file

@ -1,8 +1,36 @@
{
"identity-providers-help": {
"redirectURI": "The redirect uri to use when configuring the identity provider.",
"alias": "The alias uniquely identifies an identity provider and it is also used to build the redirect uri.",
"displayName": "Friendly name for Identity Providers.",
"clientId": "The client identifier registered with the identity provider.",
"clientSecret": "The client secret registered with the identity provider. This field is able to obtain its value from vault, use ${vault.ID} format.",
"displayOrder": "Number defining order of the provider in GUI (for example, on Login page)."
"displayOrder": "Number defining order of the provider in GUI (for example, on Login page).",
"useDiscoveryEndpoint": "If this setting is enabled, the discovery endpoint will be used to fetch the provider config. Keycloak can load the config from the endpoint and automatically update the config if the source has any updates",
"discoveryEndpoint": "Import metadata from a remote IDP discovery descriptor.",
"importConfig": "Import metadata from a downloaded IDP discovery descriptor.",
"passLoginHint": "Pass login_hint to identity provider.",
"passCurrentLocale": "Pass the current locale to the identity provider as a ui_locales parameter.",
"logoutUrl": "End session endpoint to use to logout user from external IDP.",
"backchannelLogout": "Does the external IDP support backchannel logout?",
"disableUserInfo": "Disable usage of User Info service to obtain additional user information? Default is to use this OIDC service.",
"userInfoUrl": "The User Info Url. This is optional.",
"issuer": "The issuer identifier for the issuer of the response. If not provided, no validation will be performed.",
"scopes": "The scopes to be sent when asking for authorization. It can be a space-separated list of scopes. Defaults to 'openid'.",
"prompt": "Specifies whether the Authorization Server prompts the End-User for reauthentication and consent.",
"acceptsPromptNone": "This is just used together with Identity Provider Authenticator or when kc_idp_hint points to this identity provider. In case that client sends a request with prompt=none and user is not yet authenticated, the error will not be directly returned to client, but the request with prompt=none will be forwarded to this identity provider.",
"validateSignature": "Enable/disable signature validation of external IDP signatures.",
"useJwksUrl": "If the switch is on, identity provider public keys will be downloaded from given JWKS URL. This allows great flexibility because new keys will be always re-downloaded again when identity provider generates new keypair. If the switch is off, public key (or certificate) from the Keycloak DB is used, so when the identity provider keypair changes, you always need to import the new key to the Keycloak DB as well.",
"jwksUrl": "URL where identity provider keys in JWK format are stored. See JWK specification for more details. If you use external Keycloak identity provider, you can use URL like 'http://broker-keycloak:8180/auth/realms/test/protocol/openid-connect/certs' assuming your brokered Keycloak is running on 'http://broker-keycloak:8180' and its realm is 'test' .",
"allowedClockSkew": "Clock skew in seconds that is tolerated when validating identity provider tokens. Default value is zero.",
"forwardParameters": "Non OpenID Connect/OAuth standard query parameters to be forwarded to external IDP from the initial application request to Authorization Endpoint. Multiple parameters can be entered, separated by comma (,).",
"clientAuthentication": "The client authentication method (cfr. https://openid.net/specs/openid-connect-core-1_0.html#ClientAuthentication). In case of JWT signed with private key, the realm private key is used.",
"storeTokens": "Enable/disable if tokens must be stored after authenticating users.",
"trustEmail": "If enabled, email provided by this provider is not verified even if verification is enabled for the realm.",
"accountLinkingOnly": "If true, users cannot log in through this provider. They can only link to this provider. This is useful if you don't want to allow login from the provider, but want to integrate with a provider",
"hideOnLoginPage": "If hidden, login with this provider is possible only if requested explicitly, for example using the 'kc_idp_hint' parameter.",
"firstBrokerLoginFlowAlias": "Alias of authentication flow, which is triggered after first login with this identity provider. Term 'First Login' means that no Keycloak account is currently linked to the authenticated identity provider account.",
"postBrokerLoginFlowAlias": "Alias of authentication flow, which is triggered after each login with this identity provider. Useful if you want additional verification of each user authenticated with this identity provider (for example OTP). Leave this empty if you need no any additional authenticators to be triggered after login with this identity provider. Also note that authenticator implementations must assume that user is already set in ClientSession as identity provider already set it.",
"syncMode": "Default sync mode for all mappers. The sync mode determines when user data will be synced using the mappers. Possible values are: 'legacy' to keep the behaviour before this option was introduced, 'import' to only import the user once during first login of the user with this identity provider, 'force' to always update the user during every login with this identity provider."
}
}

View file

@ -4,11 +4,18 @@
"searchForProvider": "Search for provider",
"provider": "Provider",
"addProvider": "Add provider",
"addOpenIdProvider": "Add OpenId Connect provider",
"manageDisplayOrder": "Manage display order",
"deleteProvider": "Delete provider?",
"deleteConfirm": "Are you sure you want to permanently delete the provider '{{provider}}'",
"deletedSuccess": "Provider successfully deleted",
"deleteError": "Could not delete the provider {{error}}",
"disableProvider": "Disable provider?",
"disableConfirm": "Are you sure you want to disable the provider '{{provider}}'",
"disableSuccess": "Provider successfully disabled",
"disableError": "Could not disable the provider {{error}}",
"updateSuccess": "Provider successfully updated",
"updateError": "Could not update the provider {{error}}",
"getStarted": "To get started, select a provider from the list below.",
"addIdentityProvider": "Add {{provider}} provider",
"redirectURI": "Redirect URI",
@ -25,6 +32,62 @@
"onDragCancel": "Dragging cancelled. List is unchanged.",
"onDragFinish": "Dragging finished {{list}}",
"orderChangeSuccess": "Successfully changed display order of identity providers",
"orderChangeError": "Could not change display order of identity providers {{error}}"
"orderChangeError": "Could not change display order of identity providers {{error}}",
"alias": "Alias",
"displayName": "Display name",
"useDiscoveryEndpoint": "Use discovery endpoint",
"discoveryEndpoint": "Discovery endpoint",
"importConfig": "Import config from file",
"showMetaData": "Show metadata",
"hideMetaData": "Hide metadata",
"noValidMetaDataFound": "No valid metadata was found at this URL",
"advanced": "Advanced",
"metadataOfDiscoveryEndpoint": "Metadata of the discovery endpoint",
"authorizationUrl": "Authorization URL",
"passLoginHint": "Pass login_hint",
"passCurrentLocale": "Pass current locale",
"tokenUrl": "Token URL",
"logoutUrl": "Logout URL",
"backchannelLogout": "Backchannel logout",
"disableUserInfo": "Disable user info",
"userInfoUrl": "User Info URL",
"issuer": "Issuer",
"scopes": "Default scopes",
"prompt": "Prompt",
"prompts": {
"unspecified": "Unspecified",
"none": "None",
"consent": "Consent",
"login": "Login",
"select_account": "Select account"
},
"clientAuthentication" : "Client authentication",
"clientAuthentications": {
"clientAuth_post": "Client secret sent as post",
"clientAuth_basic" : "Client secret sent as basic auth",
"clientAuth_secret_jwt" : "Client secret as jwt",
"clientAuth_privatekey_jwt" : "JWT signed with private key"
},
"acceptsPromptNone": "Accepts prompt=none forward from client",
"validateSignature": "Validate Signatures",
"useJwksUrl": "Use JWKS URL",
"jwksUrl": "JWKS URL",
"allowedClockSkew": "Allowed clock skew",
"forwardParameters": "Forwarded query parameters",
"generalSettings": "General settings",
"oidcSettings": "OpenId Connect settings",
"advancedSettings": "Advanced settings",
"storeTokens": "Store tokens",
"trustEmail": "Trust Email",
"accountLinkingOnly": "Account linking only",
"hideOnLoginPage": "Hide on login page",
"firstBrokerLoginFlowAlias": "First login flow",
"postBrokerLoginFlowAlias": "Post login flow",
"syncMode": "Sync mode",
"syncModes": {
"import": "Import",
"legacy": "Legacy",
"force": "Force"
}
}
}

View file

@ -31,6 +31,8 @@ import { SearchGroups } from "./groups/SearchGroups";
import { CreateInitialAccessToken } from "./clients/initial-access/CreateInitialAccessToken";
import { LdapMapperDetails } from "./user-federation/ldap/mappers/LdapMapperDetails";
import { AddIdentityProvider } from "./identity-providers/add/AddIdentityProvider";
import { AddOpenIdConnect } from "./identity-providers/add/AddOpenIdConnect";
import { DetailSettings } from "./identity-providers/add/DetailSettings";
export type RouteDef = BreadcrumbsRoute & {
access: AccessType;
@ -190,12 +192,24 @@ export const routes: RoutesFn = (t: TFunction) => [
breadcrumb: t("identityProviders"),
access: "view-identity-providers",
},
{
path: "/:realm/identity-providers/oidc",
component: AddOpenIdConnect,
breadcrumb: t("identity-providers:addOpenIdProvider"),
access: "manage-identity-providers",
},
{
path: "/:realm/identity-providers/:id",
component: AddIdentityProvider,
breadcrumb: t("identity-providers:provider"),
access: "manage-identity-providers",
},
{
path: "/:realm/identity-providers/:id/settings",
component: DetailSettings,
breadcrumb: null,
access: "manage-identity-providers",
},
{
path: "/:realm/user-federation",
component: UserFederationSection,