Initial version of the identity providers section (#537)

* initial version identity providers section

* added order change dialog

* added tests

* added missing brand icons

* removed need for providerCount

* fixed refresh

* back to list after create

* format merge

* fixed merge error
This commit is contained in:
Erik Jan de Wit 2021-04-21 15:18:45 +02:00 committed by GitHub
parent f3511d0be1
commit 6dd314c768
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
30 changed files with 941 additions and 29 deletions

View file

@ -0,0 +1,91 @@
import Masthead from "../support/pages/admin_console/Masthead";
import SidebarPage from "../support/pages/admin_console/SidebarPage";
import LoginPage from "../support/pages/LoginPage";
import { keycloakBefore } from "../support/util/keycloak_before";
import ListingPage from "../support/pages/admin_console/ListingPage";
import CreateProviderPage from "../support/pages/admin_console/manage/identity_providers/CreateProviderPage";
import ModalUtils from "../support/util/ModalUtils";
import OrderDialog from "../support/pages/admin_console/manage/identity_providers/OrderDialog";
describe("Identity provider test", () => {
const loginPage = new LoginPage();
const sidebarPage = new SidebarPage();
const masthead = new Masthead();
const listingPage = new ListingPage();
const createProviderPage = new CreateProviderPage();
describe("Identity provider creation", () => {
const identityProviderName = "github";
beforeEach(function () {
keycloakBefore();
loginPage.logIn();
sidebarPage.goToIdentityProviders();
});
it("should create provider", () => {
createProviderPage.checkGitHubCardVisible().clickGitHubCard();
createProviderPage.checkAddButtonDisabled();
createProviderPage
.fill(identityProviderName)
.clickAdd()
.checkClientIdRequiredMessage(true);
createProviderPage.fill(identityProviderName, "123").clickAdd();
masthead.checkNotificationMessage(
"Identity provider successfully created"
);
//TODO temporary refresh
sidebarPage.goToAuthentication().goToIdentityProviders();
listingPage.itemExist(identityProviderName);
});
it("should delete provider", () => {
const modalUtils = new ModalUtils();
listingPage.deleteItem(identityProviderName);
modalUtils.checkModalTitle("Delete provider?").confirmModal();
masthead.checkNotificationMessage("Provider successfully deleted");
createProviderPage.checkGitHubCardVisible();
});
it("should change order of providers", () => {
const orderDialog = new OrderDialog();
const providers = ["facebook", identityProviderName, "bitbucket"];
createProviderPage
.clickCard("facebook")
.fill("facebook", "123")
.clickAdd();
sidebarPage.goToIdentityProviders();
listingPage.itemExist("facebook");
createProviderPage
.clickCreateDropdown()
.clickItem(identityProviderName)
.fill(identityProviderName, "123")
.clickAdd();
sidebarPage.goToIdentityProviders();
createProviderPage
.clickCreateDropdown()
.clickItem("bitbucket")
.fill("bitbucket", "123")
.clickAdd();
sidebarPage.goToIdentityProviders();
orderDialog.openDialog().checkOrder(providers);
orderDialog.moveRowTo("facebook", identityProviderName);
orderDialog.checkOrder(["facebook", "bitbucket", identityProviderName]);
orderDialog.clickSave();
masthead.checkNotificationMessage(
"Successfully changed display order of identity providers"
);
});
});
});

View file

@ -0,0 +1,68 @@
export default class CreateProviderPage {
private github = "github";
private addProviderDropdown = "addProviderDropdown";
private clientIdField = "clientId";
private clientIdError = "#kc-client-secret-helper";
private clientSecretField = "clientSecret";
private addButton = "createProvider";
checkVisible(name: string) {
cy.getId(`${name}-card`).should("exist");
return this;
}
clickCard(name: string) {
cy.getId(`${name}-card`).click();
return this;
}
clickGitHubCard() {
this.clickCard(this.github);
return this;
}
checkGitHubCardVisible() {
this.checkVisible(this.github);
return this;
}
checkClientIdRequiredMessage(exist = true) {
cy.get(this.clientIdError).should((!exist ? "not." : "") + "exist");
return this;
}
checkAddButtonDisabled(disabled = true) {
cy.getId(this.addButton).should(!disabled ? "not." : "" + "be.disabled");
return this;
}
clickAdd() {
cy.getId(this.addButton).click();
return this;
}
clickCreateDropdown() {
cy.getId(this.addProviderDropdown).click();
return this;
}
clickItem(item: string) {
cy.getId(item).click();
return this;
}
fill(id: string, secret = "") {
cy.getId(this.clientIdField).clear();
if (id) {
cy.getId(this.clientIdField).type(id);
}
if (secret) {
cy.getId(this.clientSecretField).type(secret);
}
return this;
}
}

View file

@ -0,0 +1,37 @@
const expect = chai.expect;
export default class OrderDialog {
private manageDisplayOrder = "manageDisplayOrder";
private list = "manageOrderDataList";
openDialog() {
cy.getId(this.manageDisplayOrder).click({ force: true });
return this;
}
moveRowTo(from: string, to: string) {
cy.getId(from).trigger("dragstart").trigger("dragleave");
cy.getId(to)
.trigger("dragenter")
.trigger("dragover")
.trigger("drop")
.trigger("dragend");
return this;
}
clickSave() {
cy.get("#modal-confirm").click();
return this;
}
checkOrder(providerNames: string[]) {
cy.get(`[data-testid=${this.list}] li`).should((providers) => {
expect(providers).to.have.length(providerNames.length);
for (let index = 0; index < providerNames.length; index++) {
expect(providers.eq(index)).to.contain(providerNames[index]);
}
});
}
}

View file

@ -185,7 +185,7 @@ export const AuthenticationSection = () => {
}} }}
/> />
)} )}
<ViewHeader titleKey="authentication:title" subKey="" divider={false} /> <ViewHeader titleKey="authentication:title" divider={false} />
<PageSection variant="light" className="pf-u-p-0"> <PageSection variant="light" className="pf-u-p-0">
<KeycloakTabs isBox> <KeycloakTabs isBox>
<Tab <Tab

View file

@ -19,7 +19,7 @@ import { ViewHeader } from "../components/view-header/ViewHeader";
import { useAlerts } from "../components/alert/Alerts"; import { useAlerts } from "../components/alert/Alerts";
import { KeycloakDataTable } from "../components/table-toolbar/KeycloakDataTable"; import { KeycloakDataTable } from "../components/table-toolbar/KeycloakDataTable";
import { useConfirmDialog } from "../components/confirm-dialog/ConfirmDialog"; import { useConfirmDialog } from "../components/confirm-dialog/ConfirmDialog";
import { boolFormatter, emptyFormatter } from "../util"; import { upperCaseFormatter, emptyFormatter } from "../util";
import { import {
CellDropdown, CellDropdown,
ClientScope, ClientScope,
@ -268,7 +268,7 @@ export const ClientScopesSection = () => {
{ {
name: "protocol", name: "protocol",
displayKey: "client-scopes:protocol", displayKey: "client-scopes:protocol",
cellFormatters: [boolFormatter()], cellFormatters: [upperCaseFormatter()],
transforms: [cellWidth(15)], transforms: [cellWidth(15)],
}, },
{ {

View file

@ -75,7 +75,7 @@ export const ClientsSection = () => {
{client.clientId} {client.clientId}
{!client.enabled && ( {!client.enabled && (
<Badge key={`${client.id}-disabled`} isRead className="pf-u-ml-sm"> <Badge key={`${client.id}-disabled`} isRead className="pf-u-ml-sm">
Disabled {t("common:disabled")}
</Badge> </Badge>
)} )}
</Link> </Link>

View file

@ -54,6 +54,8 @@
"priority": "Priority", "priority": "Priority",
"unexpectedError": "An unexpected error occurred: '{{error}}'", "unexpectedError": "An unexpected error occurred: '{{error}}'",
"retry": "Retry", "retry": "Retry",
"plus": "Plus",
"minus": "Minus",
"clientScope": { "clientScope": {
"default": "Default", "default": "Default",

View file

@ -27,7 +27,7 @@ export type ViewHeaderProps = {
badge?: string; badge?: string;
badgeId?: string; badgeId?: string;
badgeIsRead?: boolean; badgeIsRead?: boolean;
subKey: string | ReactNode; subKey?: string | ReactNode;
actionsDropdownId?: string; actionsDropdownId?: string;
subKeyLinkProps?: FormattedLinkProps; subKeyLinkProps?: FormattedLinkProps;
dropdownItems?: ReactElement[]; dropdownItems?: ReactElement[];
@ -133,7 +133,11 @@ export const ViewHeader = ({
{enabled && ( {enabled && (
<TextContent id="view-header-subkey"> <TextContent id="view-header-subkey">
<Text> <Text>
{React.isValidElement(subKey) ? subKey : t(subKey as string)} {React.isValidElement(subKey)
? subKey
: subKey
? t(subKey as string)
: ""}
{subKeyLinkProps && ( {subKeyLinkProps && (
<FormattedLink <FormattedLink
{...subKeyLinkProps} {...subKeyLinkProps}

View file

@ -21,6 +21,8 @@ import authentication from "./authentication/messages.json";
import storybook from "./stories/messages.json"; import storybook from "./stories/messages.json";
import userFederation from "./user-federation/messages.json"; import userFederation from "./user-federation/messages.json";
import userFederationHelp from "./user-federation/help.json"; import userFederationHelp from "./user-federation/help.json";
import identityProviders from "./identity-providers/messages.json";
import identityProvidersHelp from "./identity-providers/help.json";
const initOptions = { const initOptions = {
defaultNS: "common", defaultNS: "common",
@ -44,9 +46,11 @@ const initOptions = {
...realmSettings, ...realmSettings,
...realmSettingsHelp, ...realmSettingsHelp,
...authentication, ...authentication,
...storybook, ...identityProviders,
...identityProvidersHelp,
...userFederation, ...userFederation,
...userFederationHelp, ...userFederationHelp,
...storybook,
}, },
}, },
lng: "en", lng: "en",

View file

@ -1,6 +1,257 @@
import React from "react"; import React, { Fragment, useEffect, useState } from "react";
import { WorkInProgress } from "../components/work-in-progress/WorkInProgress"; import { Link, useHistory, useRouteMatch } from "react-router-dom";
import { useErrorHandler } from "react-error-boundary";
import { useTranslation } from "react-i18next";
import _ from "lodash";
import {
AlertVariant,
Badge,
Button,
ButtonVariant,
Card,
CardTitle,
Dropdown,
DropdownGroup,
DropdownItem,
DropdownToggle,
Gallery,
PageSection,
Split,
SplitItem,
Text,
TextContent,
TextVariants,
ToolbarItem,
} from "@patternfly/react-core";
export const IdentityProvidersSection = () => ( import IdentityProviderRepresentation from "keycloak-admin/lib/defs/identityProviderRepresentation";
<WorkInProgress marvelLink="https://marvelapp.com/prototype/55c2d8f/screen/75697040" /> import { ViewHeader } from "../components/view-header/ViewHeader";
); import { asyncStateFetch, useAdminClient } from "../context/auth/AdminClient";
import { KeycloakDataTable } from "../components/table-toolbar/KeycloakDataTable";
import { useRealm } from "../context/realm-context/RealmContext";
import { useAlerts } from "../components/alert/Alerts";
import { useServerInfo } from "../context/server-info/ServerInfoProvider";
import { upperCaseFormatter } from "../util";
import { useConfirmDialog } from "../components/confirm-dialog/ConfirmDialog";
import { ProviderIconMapper } from "./ProviderIconMapper";
import { ManageOderDialog } from "./ManageOrderDialog";
export const IdentityProvidersSection = () => {
const { t } = useTranslation("identity-providers");
const identityProviders = _.groupBy(
useServerInfo().identityProviders,
"groupName"
);
const { realm } = useRealm();
const { url } = useRouteMatch();
const history = useHistory();
const [key, setKey] = useState(0);
const refresh = () => setKey(new Date().getTime());
const [addProviderOpen, setAddProviderOpen] = useState(false);
const [manageDisplayDialog, setManageDisplayDialog] = useState(false);
const [providers, setProviders] = useState<IdentityProviderRepresentation[]>(
[]
);
const [selectedProvider, setSelectedProvider] = useState<
IdentityProviderRepresentation
>();
const adminClient = useAdminClient();
const errorHandler = useErrorHandler();
const { addAlert } = useAlerts();
useEffect(
() =>
asyncStateFetch(
async () =>
(await adminClient.realms.findOne({ realm })).identityProviders!,
(providers) => {
setProviders(providers);
},
errorHandler
),
[]
);
const loader = () => Promise.resolve(_.sortBy(providers, "alias"));
const DetailLink = (identityProvider: IdentityProviderRepresentation) => (
<>
<Link
key={identityProvider.providerId}
to={`/${realm}/identity-providers/${identityProvider.providerId}/settings`}
>
{identityProvider.alias}
{!identityProvider.enabled && (
<Badge
key={`${identityProvider.providerId}-disabled`}
isRead
className="pf-u-ml-sm"
>
{t("common:disabled")}
</Badge>
)}
</Link>
</>
);
const navigateToCreate = (providerId: string) =>
history.push(`${url}/${providerId}`);
const identityProviderOptions = () =>
Object.keys(identityProviders).map((group) => (
<DropdownGroup key={group} label={group}>
{_.sortBy(identityProviders[group], "name").map((provider) => (
<DropdownItem
key={provider.id}
value={provider.id}
data-testid={provider.id}
onClick={() => navigateToCreate(provider.id)}
>
{provider.name}
</DropdownItem>
))}
</DropdownGroup>
));
const [toggleDeleteDialog, DeleteConfirm] = useConfirmDialog({
titleKey: "identity-providers:deleteProvider",
messageKey: t("deleteConfirm", { provider: selectedProvider?.alias }),
continueButtonLabel: "common:delete",
continueButtonVariant: ButtonVariant.danger,
onConfirm: async () => {
try {
await adminClient.identityProviders.del({
alias: selectedProvider!.alias!,
});
setProviders([
...providers.filter((p) => p.alias !== selectedProvider?.alias),
]);
refresh();
addAlert(t("deletedSuccess"), AlertVariant.success);
} catch (error) {
addAlert(t("deleteError", { error }), AlertVariant.danger);
}
},
});
return (
<>
<DeleteConfirm />
{manageDisplayDialog && (
<ManageOderDialog
onClose={() => setManageDisplayDialog(false)}
providers={providers!}
/>
)}
<ViewHeader
titleKey="common:identityProviders"
subKey="identity-providers:listExplain"
divider={false}
/>
<PageSection
variant={providers.length === 0 ? "default" : "light"}
className={providers.length === 0 ? "" : "pf-u-p-0"}
>
{providers.length === 0 && (
<>
<TextContent>
<Text component={TextVariants.p}>{t("getStarted")}</Text>
</TextContent>
{Object.keys(identityProviders).map((group) => (
<Fragment key={group}>
<TextContent>
<Text className="pf-u-mt-lg" component={TextVariants.h2}>
{group}:
</Text>
</TextContent>
<hr className="pf-u-mb-lg" />
<Gallery hasGutter>
{_.sortBy(identityProviders[group], "name").map(
(provider) => (
<Card
key={provider.id}
isHoverable
data-testid={`${provider.id}-card`}
onClick={() => navigateToCreate(provider.id)}
>
<CardTitle>
<Split hasGutter>
<SplitItem>
<ProviderIconMapper provider={provider} />
</SplitItem>
<SplitItem isFilled>{provider.name}</SplitItem>
</Split>
</CardTitle>
</Card>
)
)}
</Gallery>
</Fragment>
))}
</>
)}
{providers.length !== 0 && (
<KeycloakDataTable
key={key}
loader={loader}
ariaLabelKey="common:identityProviders"
searchPlaceholderKey="identity-providers:searchForProvider"
toolbarItem={
<>
<ToolbarItem>
<Dropdown
data-testid="addProviderDropdown"
onSelect={() => {}}
toggle={
<DropdownToggle
onToggle={() => setAddProviderOpen(!addProviderOpen)}
isPrimary
>
{t("addProvider")}
</DropdownToggle>
}
isOpen={addProviderOpen}
dropdownItems={identityProviderOptions()}
/>
</ToolbarItem>
<ToolbarItem>
<Button
data-testid="manageDisplayOrder"
variant="link"
onClick={() => setManageDisplayDialog(true)}
>
{t("manageDisplayOrder")}
</Button>
</ToolbarItem>
</>
}
actions={[
{
title: t("common:delete"),
onRowClick: (provider) => {
setSelectedProvider(provider);
toggleDeleteDialog();
},
},
]}
columns={[
{
name: "alias",
displayKey: "common:name",
cellRenderer: DetailLink,
},
{
name: "providerId",
displayKey: "identity-providers:provider",
cellFormatters: [upperCaseFormatter()],
},
]}
/>
)}
</PageSection>
</>
);
};

View file

@ -0,0 +1,146 @@
import React, { useState } from "react";
import { useTranslation } from "react-i18next";
import _ from "lodash";
import {
AlertVariant,
Button,
ButtonVariant,
DataList,
DataListCell,
DataListControl,
DataListDragButton,
DataListItem,
DataListItemCells,
DataListItemRow,
Modal,
ModalVariant,
TextContent,
Text,
} from "@patternfly/react-core";
import IdentityProviderRepresentation from "keycloak-admin/lib/defs/identityProviderRepresentation";
import { useAdminClient } from "../context/auth/AdminClient";
import { useAlerts } from "../components/alert/Alerts";
type ManageOderDialogProps = {
providers: IdentityProviderRepresentation[];
onClose: () => void;
};
export const ManageOderDialog = ({
providers,
onClose,
}: ManageOderDialogProps) => {
const { t } = useTranslation("identity-providers");
const adminClient = useAdminClient();
const { addAlert } = useAlerts();
const [alias, setAlias] = useState("");
const [liveText, setLiveText] = useState("");
const [order, setOrder] = useState(
providers.map((provider) => provider.alias!)
);
const onDragStart = (id: string) => {
setAlias(id);
setLiveText(t("onDragStart", { id }));
};
const onDragMove = () => {
setLiveText(t("onDragMove", { alias }));
};
const onDragCancel = () => {
setLiveText(t("onDragCancel"));
};
const onDragFinish = (providerOrder: string[]) => {
setLiveText(t("onDragFinish", { list: providerOrder }));
setOrder(providerOrder);
};
return (
<Modal
variant={ModalVariant.small}
title={t("manageDisplayOrder")}
isOpen={true}
onClose={onClose}
actions={[
<Button
id="modal-confirm"
key="confirm"
onClick={() => {
order.map(async (alias, index) => {
const provider = providers.find((p) => p.alias === alias)!;
provider.config!.guiOrder = index;
try {
await adminClient.identityProviders.update({ alias }, provider);
addAlert(t("orderChangeSuccess"), AlertVariant.success);
} catch (error) {
addAlert(t("orderChangeError", { error }), AlertVariant.danger);
}
});
onClose();
}}
>
{t("common:save")}
</Button>,
<Button
id="modal-cancel"
key="cancel"
variant={ButtonVariant.link}
onClick={onClose}
>
{t("common:cancel")}
</Button>,
]}
>
<TextContent className="pf-u-pb-lg">
<Text>{t("oderDialogIntro")}</Text>
</TextContent>
<DataList
aria-label={t("manageOrderTableAria")}
data-testid="manageOrderDataList"
isCompact
onDragFinish={onDragFinish}
onDragStart={onDragStart}
onDragMove={onDragMove}
onDragCancel={onDragCancel}
itemOrder={order}
>
{_.sortBy(providers, "config.guiOrder").map((provider) => (
<DataListItem
aria-labelledby={provider.alias}
id={provider.alias}
key={provider.alias}
>
<DataListItemRow>
<DataListControl>
<DataListDragButton
aria-label="Reorder"
aria-labelledby={provider.alias}
aria-describedby={t("manageOrderItemAria")}
aria-pressed="false"
/>
</DataListControl>
<DataListItemCells
dataListCells={[
<DataListCell
key={`${provider.alias}-cell`}
data-testid={provider.alias}
>
<span id={provider.alias}>{provider.alias}</span>
</DataListCell>,
]}
/>
</DataListItemRow>
</DataListItem>
))}
</DataList>
<div className="pf-screen-reader" aria-live="assertive">
{liveText}
</div>
</Modal>
);
};

View file

@ -0,0 +1,50 @@
import React from "react";
import {
CubeIcon,
FacebookSquareIcon,
GithubIcon,
GitlabIcon,
GoogleIcon,
LinkedinIcon,
OpenshiftIcon,
StackOverflowIcon,
TwitterIcon,
} from "@patternfly/react-icons";
import { SVGIconProps } from "@patternfly/react-icons/dist/js/createIcon";
import { FontAwesomeIcon } from "./icons/FontAwesomeIcon";
type ProviderIconMapperProps = {
provider: { [index: string]: string };
};
export const ProviderIconMapper = ({ provider }: ProviderIconMapperProps) => {
const defaultProps: SVGIconProps = { size: "lg" };
switch (provider.id) {
case "github":
return <GithubIcon {...defaultProps} />;
case "facebook":
return <FacebookSquareIcon {...defaultProps} />;
case "gitlab":
return <GitlabIcon {...defaultProps} />;
case "google":
return <GoogleIcon {...defaultProps} />;
case "linkedin":
return <LinkedinIcon {...defaultProps} />;
case "openshift-v3":
case "openshift-v4":
return <OpenshiftIcon {...defaultProps} />;
case "stackoverflow":
return <StackOverflowIcon {...defaultProps} />;
case "twitter":
return <TwitterIcon {...defaultProps} />;
case "microsoft":
case "bitbucket":
case "instagram":
case "paypal":
return <FontAwesomeIcon icon={provider.id} />;
default:
return <CubeIcon {...defaultProps} />;
}
};

View file

@ -0,0 +1,190 @@
import React from "react";
import { useHistory, useParams } from "react-router-dom";
import { useTranslation } from "react-i18next";
import { Controller, 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 { 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";
export const AddIdentityProvider = () => {
const { t } = useTranslation("identity-providers");
const { t: th } = useTranslation("identity-providers-help");
const { id } = useParams<{ id: string }>();
const {
handleSubmit,
register,
errors,
control,
formState: { isDirty },
} = useForm<IdentityProviderRepresentation>();
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({
...provider,
providerId: id,
alias: id,
});
addAlert(t("createSuccess"), AlertVariant.success);
history.push(`/${realm}/identity-providers`);
} catch (error) {
addAlert(t("createError", { error }), AlertVariant.danger);
}
};
return (
<>
<ViewHeader
titleKey={t("addIdentityProvider", { provider: toUpperCase(id) })}
/>
<PageSection variant="light">
<FormAccess
role="manage-identity-providers"
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>
<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>
</PageSection>
</>
);
};

View file

@ -0,0 +1,8 @@
{
"identity-providers-help": {
"redirectURI": "The redirect uri to use when configuring the identity provider.",
"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)."
}
}

View file

@ -0,0 +1,24 @@
import React from "react";
import bitbucketIcon from "./bitbucket-brands.svg";
import microsoftIcon from "./microsoft-brands.svg";
import instagramIcon from "./instagram-brands.svg";
import paypalIcon from "./paypal-brands.svg";
type FontAwesomeIconProps = {
icon: "bitbucket" | "microsoft" | "instagram" | "paypal";
};
export const FontAwesomeIcon = ({ icon }: FontAwesomeIconProps) => {
const styles = { style: { height: "2em", width: "2em" } };
switch (icon) {
case "bitbucket":
return <img src={bitbucketIcon} {...styles} />;
case "microsoft":
return <img src={microsoftIcon} {...styles} />;
case "instagram":
return <img src={instagramIcon} {...styles} />;
case "paypal":
return <img src={paypalIcon} {...styles} />;
default:
return <></>;
}
};

View file

@ -0,0 +1 @@
<svg aria-hidden="true" focusable="false" data-prefix="fab" data-icon="bitbucket" class="svg-inline--fa fa-bitbucket fa-w-16" role="img" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512"><path fill="currentColor" d="M22.2 32A16 16 0 0 0 6 47.8a26.35 26.35 0 0 0 .2 2.8l67.9 412.1a21.77 21.77 0 0 0 21.3 18.2h325.7a16 16 0 0 0 16-13.4L505 50.7a16 16 0 0 0-13.2-18.3 24.58 24.58 0 0 0-2.8-.2L22.2 32zm285.9 297.8h-104l-28.1-147h157.3l-25.2 147z"></path></svg>

After

Width:  |  Height:  |  Size: 464 B

View file

@ -0,0 +1 @@
<svg aria-hidden="true" focusable="false" data-prefix="fab" data-icon="instagram" class="svg-inline--fa fa-instagram fa-w-14" role="img" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 448 512"><path fill="currentColor" d="M224.1 141c-63.6 0-114.9 51.3-114.9 114.9s51.3 114.9 114.9 114.9S339 319.5 339 255.9 287.7 141 224.1 141zm0 189.6c-41.1 0-74.7-33.5-74.7-74.7s33.5-74.7 74.7-74.7 74.7 33.5 74.7 74.7-33.6 74.7-74.7 74.7zm146.4-194.3c0 14.9-12 26.8-26.8 26.8-14.9 0-26.8-12-26.8-26.8s12-26.8 26.8-26.8 26.8 12 26.8 26.8zm76.1 27.2c-1.7-35.9-9.9-67.7-36.2-93.9-26.2-26.2-58-34.4-93.9-36.2-37-2.1-147.9-2.1-184.9 0-35.8 1.7-67.6 9.9-93.9 36.1s-34.4 58-36.2 93.9c-2.1 37-2.1 147.9 0 184.9 1.7 35.9 9.9 67.7 36.2 93.9s58 34.4 93.9 36.2c37 2.1 147.9 2.1 184.9 0 35.9-1.7 67.7-9.9 93.9-36.2 26.2-26.2 34.4-58 36.2-93.9 2.1-37 2.1-147.8 0-184.8zM398.8 388c-7.8 19.6-22.9 34.7-42.6 42.6-29.5 11.7-99.5 9-132.1 9s-102.7 2.6-132.1-9c-19.6-7.8-34.7-22.9-42.6-42.6-11.7-29.5-9-99.5-9-132.1s-2.6-102.7 9-132.1c7.8-19.6 22.9-34.7 42.6-42.6 29.5-11.7 99.5-9 132.1-9s102.7-2.6 132.1 9c19.6 7.8 34.7 22.9 42.6 42.6 11.7 29.5 9 99.5 9 132.1s2.7 102.7-9 132.1z"></path></svg>

After

Width:  |  Height:  |  Size: 1.1 KiB

View file

@ -0,0 +1 @@
<svg aria-hidden="true" focusable="false" data-prefix="fab" data-icon="windows" class="svg-inline--fa fa-windows fa-w-14" role="img" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 448 512"><path fill="currentColor" d="M0 93.7l183.6-25.3v177.4H0V93.7zm0 324.6l183.6 25.3V268.4H0v149.9zm203.8 28L448 480V268.4H203.8v177.9zm0-380.6v180.1H448V32L203.8 65.7z"></path></svg>

After

Width:  |  Height:  |  Size: 369 B

View file

@ -0,0 +1 @@
<svg aria-hidden="true" focusable="false" data-prefix="fab" data-icon="paypal" class="svg-inline--fa fa-paypal fa-w-12" role="img" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 384 512"><path fill="currentColor" d="M111.4 295.9c-3.5 19.2-17.4 108.7-21.5 134-.3 1.8-1 2.5-3 2.5H12.3c-7.6 0-13.1-6.6-12.1-13.9L58.8 46.6c1.5-9.6 10.1-16.9 20-16.9 152.3 0 165.1-3.7 204 11.4 60.1 23.3 65.6 79.5 44 140.3-21.5 62.6-72.5 89.5-140.1 90.3-43.4.7-69.5-7-75.3 24.2zM357.1 152c-1.8-1.3-2.5-1.8-3 1.3-2 11.4-5.1 22.5-8.8 33.6-39.9 113.8-150.5 103.9-204.5 103.9-6.1 0-10.1 3.3-10.9 9.4-22.6 140.4-27.1 169.7-27.1 169.7-1 7.1 3.5 12.9 10.6 12.9h63.5c8.6 0 15.7-6.3 17.4-14.9.7-5.4-1.1 6.1 14.4-91.3 4.6-22 14.3-19.7 29.3-19.7 71 0 126.4-28.8 142.9-112.3 6.5-34.8 4.6-71.4-23.8-92.6z"></path></svg>

After

Width:  |  Height:  |  Size: 785 B

View file

@ -0,0 +1,30 @@
{
"identity-providers": {
"listExplain": "Through Identity Brokering it's easy to allow users to authenticate to Keycloak using external Identity Provider or Social Networks.",
"searchForProvider": "Search for provider",
"provider": "Provider",
"addProvider": "Add 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}}",
"getStarted": "To get started, select a provider from the list below.",
"addIdentityProvider": "Add {{provider}} provider",
"redirectURI": "Redirect URI",
"clientId": "Client ID",
"clientSecret": "Client Secret",
"displayOrder": "Display order",
"createSuccess": "Identity provider successfully created",
"createError": "Could not create the identity provider provider {{error}}",
"oderDialogIntro": "The order that the providers are listed in the login page or the account console. You can drag the row handles to change the order.",
"manageOrderTableAria": "List of identity providers in the order listed on the login page",
"manageOrderItemAria": "Press space or enter to begin dragging, and use the arrow keys to navigate up or down. Press enter to confirm the drag, or any other key to cancel the drag operation.",
"onDragStart": "Dragging started for item {{id}}",
"onDragMove": "Dragging item {{id}}",
"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}}"
}
}

View file

@ -9,7 +9,7 @@ import { ListEmptyState } from "../components/list-empty-state/ListEmptyState";
import { KeycloakDataTable } from "../components/table-toolbar/KeycloakDataTable"; import { KeycloakDataTable } from "../components/table-toolbar/KeycloakDataTable";
import { useAlerts } from "../components/alert/Alerts"; import { useAlerts } from "../components/alert/Alerts";
import { useConfirmDialog } from "../components/confirm-dialog/ConfirmDialog"; import { useConfirmDialog } from "../components/confirm-dialog/ConfirmDialog";
import { emptyFormatter, boolFormatter } from "../util"; import { emptyFormatter, upperCaseFormatter } from "../util";
type RolesListProps = { type RolesListProps = {
paginated?: boolean; paginated?: boolean;
@ -111,7 +111,7 @@ export const RolesList = ({
{ {
name: "composite", name: "composite",
displayKey: "roles:composite", displayKey: "roles:composite",
cellFormatters: [boolFormatter(), emptyFormatter()], cellFormatters: [upperCaseFormatter(), emptyFormatter()],
}, },
{ {
name: "description", name: "description",

View file

@ -4,7 +4,7 @@ import { useTranslation } from "react-i18next";
import { Button, PageSection, Popover } from "@patternfly/react-core"; import { Button, PageSection, Popover } from "@patternfly/react-core";
import { ListEmptyState } from "../components/list-empty-state/ListEmptyState"; import { ListEmptyState } from "../components/list-empty-state/ListEmptyState";
import { KeycloakDataTable } from "../components/table-toolbar/KeycloakDataTable"; import { KeycloakDataTable } from "../components/table-toolbar/KeycloakDataTable";
import { boolFormatter, emptyFormatter } from "../util"; import { upperCaseFormatter, emptyFormatter } from "../util";
import { useAdminClient } from "../context/auth/AdminClient"; import { useAdminClient } from "../context/auth/AdminClient";
import { QuestionCircleIcon } from "@patternfly/react-icons"; import { QuestionCircleIcon } from "@patternfly/react-icons";
import { useRealm } from "../context/realm-context/RealmContext"; import { useRealm } from "../context/realm-context/RealmContext";
@ -124,7 +124,7 @@ export const UsersInRoleTab = () => {
{ {
name: "firstName", name: "firstName",
displayKey: "roles:firstName", displayKey: "roles:firstName",
cellFormatters: [boolFormatter(), emptyFormatter()], cellFormatters: [upperCaseFormatter(), emptyFormatter()],
}, },
]} ]}
/> />

View file

@ -82,7 +82,6 @@ const RealmSettingsHeader = ({
/> />
<ViewHeader <ViewHeader
titleKey={toUpperCase(realmName)} titleKey={toUpperCase(realmName)}
subKey=""
divider={false} divider={false}
dropdownItems={[ dropdownItems={[
<DropdownItem <DropdownItem

View file

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

View file

@ -51,11 +51,10 @@ const KerberosSettingsHeader = ({
<> <>
<DisableConfirm /> <DisableConfirm />
{id === "new" ? ( {id === "new" ? (
<ViewHeader titleKey="Kerberos" subKey="" /> <ViewHeader titleKey="Kerberos" />
) : ( ) : (
<ViewHeader <ViewHeader
titleKey="Kerberos" titleKey="Kerberos"
subKey=""
dropdownItems={[ dropdownItems={[
<DropdownItem <DropdownItem
key="delete" key="delete"

View file

@ -124,11 +124,10 @@ const LdapSettingsHeader = ({
<> <>
<DisableConfirm /> <DisableConfirm />
{!id ? ( {!id ? (
<ViewHeader titleKey="LDAP" subKey="" /> <ViewHeader titleKey="LDAP" />
) : ( ) : (
<ViewHeader <ViewHeader
titleKey="LDAP" titleKey="LDAP"
subKey=""
dropdownItems={[ dropdownItems={[
<DropdownItem key="sync" onClick={syncChangedUsers}> <DropdownItem key="sync" onClick={syncChangedUsers}>
{t("syncChangedUsers")} {t("syncChangedUsers")}

View file

@ -124,7 +124,6 @@ export const LdapMapperDetails = () => {
<> <>
<ViewHeader <ViewHeader
titleKey={mapping ? mapping.name! : t("common:createNewMapper")} titleKey={mapping ? mapping.name! : t("common:createNewMapper")}
subKey=""
/> />
<PageSection variant="light" isFilled> <PageSection variant="light" isFilled>
<FormAccess role="manage-realm" isHorizontal> <FormAccess role="manage-realm" isHorizontal>

View file

@ -175,7 +175,7 @@ export const UsersSection = () => {
return ( return (
<> <>
<DeleteConfirm /> <DeleteConfirm />
<ViewHeader titleKey="users:title" subKey="" /> <ViewHeader titleKey="users:title" />
<PageSection <PageSection
data-testid="users-page" data-testid="users-page"
variant="light" variant="light"

View file

@ -61,7 +61,7 @@ export const UsersTabs = () => {
return ( return (
<> <>
<ViewHeader titleKey={user! || t("users:createUser")} subKey="" /> <ViewHeader titleKey={user! || t("users:createUser")} />
<PageSection variant="light"> <PageSection variant="light">
{id && ( {id && (
<KeycloakTabs isBox> <KeycloakTabs isBox>

View file

@ -78,12 +78,12 @@ export const emptyFormatter = (): IFormatter => (
return data ? data : "—"; return data ? data : "—";
}; };
export const boolFormatter = (): IFormatter => (data?: IFormatterValueType) => { export const upperCaseFormatter = (): IFormatter => (
const boolVal = data?.toString(); data?: IFormatterValueType
) => {
const value = data?.toString();
return (boolVal return (value ? toUpperCase(value) : undefined) as string;
? boolVal.charAt(0).toUpperCase() + boolVal.slice(1)
: undefined) as string;
}; };
export const getBaseUrl = (adminClient: KeycloakAdminClient) => { export const getBaseUrl = (adminClient: KeycloakAdminClient) => {