Initial version of the authentication section (#887)

* initial version of create authentication screen

* initial version of authentication details

* added flow details labels to view header

* not in use fix

* create execution tree

* fixed collapsable row layout

* fix drag and drop expand

* fix merge error

* move to modal

* diff and post drag and drop changes

* fixed locating the parent row

* move "live text" for d&d to common messages

* firefox fix

* initial version of the diagram

* use dagre to layout automatically

* moved to sperate file

* conditional node

* now renders subflows sequential

* changed to render sequential or parallel flows

* fixed render of sub flows

* added button edge, drawer and selectable nodes

* add requirement dropdown

* also do move so we can merge

* also do move so we can merge

* fixed merge

* added refresh

* change requirement

* fixed merge error

* now uses the new routes

* Split out routes into multiple files

* Update src/authentication/AuthenticationSection.tsx

Co-authored-by: Jon Koops <jonkoops@gmail.com>

* Update src/authentication/FlowDetails.tsx

Co-authored-by: Jon Koops <jonkoops@gmail.com>

* Update src/authentication/FlowDetails.tsx

Co-authored-by: Jon Koops <jonkoops@gmail.com>

* Update src/authentication/FlowDetails.tsx

Co-authored-by: Jon Koops <jonkoops@gmail.com>

* Update src/authentication/FlowDetails.tsx

Co-authored-by: Jon Koops <jonkoops@gmail.com>

* fixed labels

* merge fix

* make execution of these parrallel

* added some tests

* Update src/authentication/components/FlowRequirementDropdown.tsx

Co-authored-by: Jon Koops <jonkoops@gmail.com>

* more review changes

* fixed merge error

Co-authored-by: Jon Koops <jonkoops@gmail.com>
This commit is contained in:
Erik Jan de Wit 2021-08-09 10:47:34 +02:00 committed by GitHub
parent 0cfa5c2c80
commit cc31f0853c
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
46 changed files with 2781 additions and 105 deletions

View file

@ -0,0 +1,59 @@
import { keycloakBefore } from "../support/util/keycloak_before";
import LoginPage from "../support/pages/LoginPage";
import SidebarPage from "../support/pages/admin_console/SidebarPage";
import Masthead from "../support/pages/admin_console/Masthead";
import ListingPage from "../support/pages/admin_console/ListingPage";
import DuplicateFlowModal from "../support/pages/admin_console/manage/authentication/DuplicateFlowModal";
import FlowDetails from "../support/pages/admin_console/manage/authentication/FlowDetail";
describe("Authentication test", () => {
const loginPage = new LoginPage();
const masthead = new Masthead();
const sidebarPage = new SidebarPage();
const listingPage = new ListingPage();
const detailPage = new FlowDetails();
beforeEach(function () {
keycloakBefore();
loginPage.logIn();
sidebarPage.goToAuthentication();
});
it("should create duplicate of existing flow", () => {
const modalDialog = new DuplicateFlowModal();
listingPage.clickRowDetails("Browser").clickDetailMenu("Duplicate");
modalDialog.fill("Copy of browser");
masthead.checkNotificationMessage("Flow successfully duplicated");
listingPage.itemExist("Copy of browser");
});
it("should show the details of a flow as a table", () => {
listingPage.goToItemDetails("Copy of browser");
detailPage.executionExists("Cookie");
});
it("should move kerberos down", () => {
listingPage.goToItemDetails("Copy of browser");
detailPage.moveRowTo("Kerberos", "Identity Provider Redirector");
});
it("should change requirement of cookie", () => {
listingPage.goToItemDetails("Copy of browser");
detailPage.changeRequirement("Cookie", "Required");
masthead.checkNotificationMessage("Flow successfully updated");
});
it("should switch to diagram mode", () => {
listingPage.goToItemDetails("Copy of browser");
detailPage.goToDiagram();
cy.get(".react-flow").should("exist");
});
});

View file

@ -0,0 +1,15 @@
export default class DuplicateFlowModal {
private aliasInput = "alias";
private descriptionInput = "description";
private confirmButton = "confirm";
fill(name?: string, description?: string) {
if (name) {
cy.getId(this.aliasInput).type(name);
if (description) cy.get(this.descriptionInput).type(description);
}
cy.getId(this.confirmButton).click();
return this;
}
}

View file

@ -0,0 +1,39 @@
type RequirementType = "Required" | "Alternative" | "Disabled" | "Conditional";
export default class FlowDetails {
executionExists(name: string) {
this.getExecution(name).should("exist");
return this;
}
private getExecution(name: string) {
return cy.getId(name);
}
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;
}
changeRequirement(execution: string, requirement: RequirementType) {
this.getExecution(execution)
.parentsUntil(".keycloak__authentication__flow-row")
.find(".keycloak__authentication__requirement-dropdown")
.click()
.contains(requirement)
.click();
return this;
}
goToDiagram() {
cy.get("#diagramView").click();
return this;
}
}

883
package-lock.json generated

File diff suppressed because it is too large Load diff

View file

@ -28,6 +28,7 @@
"@patternfly/react-core": "4.147.0",
"@patternfly/react-icons": "4.11.8",
"@patternfly/react-table": "4.29.37",
"dagre": "^0.8.5",
"file-saver": "^2.0.5",
"i18next": "^20.3.5",
"keycloak-admin": "^1.14.20",
@ -36,6 +37,7 @@
"react": "^17.0.2",
"react-dom": "^17.0.2",
"react-error-boundary": "^3.1.3",
"react-flow-renderer": "^9.6.4",
"react-hook-form": "^6.15.8",
"react-i18next": "^11.11.4",
"react-router-dom": "^5.2.0",
@ -49,6 +51,7 @@
"@snowpack/plugin-typescript": "^1.2.1",
"@testing-library/jest-dom": "^5.14.1",
"@testing-library/react": "^12.0.0",
"@types/dagre": "^0.7.45",
"@types/file-saver": "^2.0.3",
"@types/jest": "^26.0.24",
"@types/lodash": "^4.14.172",

View file

@ -1,5 +1,5 @@
import React, { useState } from "react";
import { Link, useRouteMatch } from "react-router-dom";
import { Link } from "react-router-dom";
import { Trans, useTranslation } from "react-i18next";
import {
AlertVariant,
@ -10,6 +10,7 @@ import {
Popover,
Tab,
TabTitleText,
ToolbarItem,
} from "@patternfly/react-core";
import { CheckCircleIcon } from "@patternfly/react-icons";
@ -24,10 +25,12 @@ import { useConfirmDialog } from "../components/confirm-dialog/ConfirmDialog";
import { useAlerts } from "../components/alert/Alerts";
import { toUpperCase } from "../util";
import { DuplicateFlowModal } from "./DuplicateFlowModal";
import { toCreateFlow } from "./routes/CreateFlow";
import { toFlow } from "./routes/Flow";
import "./authentication-section.css";
type UsedBy = "client" | "default" | "idp";
type UsedBy = "specificClients" | "default" | "specificProviders";
type AuthenticationType = AuthenticationFlowRepresentation & {
usedBy: { type?: UsedBy; values: string[] };
@ -49,7 +52,6 @@ export const AuthenticationSection = () => {
const [key, setKey] = useState(0);
const refresh = () => setKey(new Date().getTime());
const { addAlert, addError } = useAlerts();
const { url } = useRouteMatch();
const [selectedFlow, setSelectedFlow] = useState<AuthenticationType>();
const [open, setOpen] = useState(false);
@ -74,7 +76,7 @@ export const AuthenticationSection = () => {
client.authenticationFlowBindingOverrides["browser"] === flow.id)
);
if (client) {
flow.usedBy.type = "client";
flow.usedBy.type = "specificClients";
flow.usedBy.values.push(client.clientId!);
}
@ -84,7 +86,7 @@ export const AuthenticationSection = () => {
idp.postBrokerLoginFlowAlias === flow.alias
);
if (idp) {
flow.usedBy.type = "idp";
flow.usedBy.type = "specificProviders";
flow.usedBy.values.push(idp.alias!);
}
@ -121,13 +123,16 @@ export const AuthenticationSection = () => {
const UsedBy = ({ id, usedBy: { type, values } }: AuthenticationType) => (
<>
{(type === "idp" || type === "client") && (
{(type === "specificProviders" || type === "specificClients") && (
<Popover
key={id}
aria-label="Basic popover"
aria-label={t("usedBy")}
bodyContent={
<div key={`usedBy-${id}-${values}`}>
{t("appliedBy" + (type === "client" ? "Clients" : "Providers"))}{" "}
{t(
"appliedBy" +
(type === "specificClients" ? "Clients" : "Providers")
)}{" "}
{values.map((used, index) => (
<>
<strong>{used}</strong>
@ -142,7 +147,7 @@ export const AuthenticationSection = () => {
className="keycloak_authentication-section__usedby"
key={`icon-${id}`}
/>{" "}
{t("specific" + (type === "client" ? "Clients" : "Providers"))}
{t(type)}
</Button>
</Popover>
)}
@ -163,9 +168,22 @@ export const AuthenticationSection = () => {
</>
);
const AliasRenderer = ({ id, alias, builtIn }: AuthenticationType) => (
const AliasRenderer = ({
id,
alias,
usedBy,
builtIn,
}: AuthenticationType) => (
<>
<Link to={`${url}/${id}`} key={`link-{id}`}>
<Link
to={toFlow({
realm,
id: id!,
usedBy: usedBy.type || "notInUse",
builtIn: builtIn ? "builtIn" : undefined,
})}
key={`link-${id}`}
>
{toUpperCase(alias!)}
</Link>{" "}
{builtIn && <Label key={`label-${id}`}>{t("buildIn")}</Label>}
@ -198,6 +216,17 @@ export const AuthenticationSection = () => {
loader={loader}
ariaLabelKey="authentication:title"
searchPlaceholderKey="authentication:searchForEvent"
toolbarItem={
<ToolbarItem>
<Button
component={Link}
// @ts-ignore
to={toCreateFlow({ realm })}
>
{t("createFlow")}
</Button>
</ToolbarItem>
}
actionResolver={({ data }) => {
const defaultActions = [
{

View file

@ -1,20 +1,18 @@
import React, { useEffect } from "react";
import { useTranslation } from "react-i18next";
import { useForm } from "react-hook-form";
import { FormProvider, useForm } from "react-hook-form";
import {
AlertVariant,
Button,
ButtonVariant,
Form,
FormGroup,
Modal,
ModalVariant,
TextInput,
ValidatedOptions,
} from "@patternfly/react-core";
import { useAdminClient } from "../context/auth/AdminClient";
import { useAlerts } from "../components/alert/Alerts";
import { NameDescription } from "./form/NameDescription";
type DuplicateFlowModalProps = {
name: string;
@ -30,9 +28,10 @@ export const DuplicateFlowModal = ({
onComplete,
}: DuplicateFlowModalProps) => {
const { t } = useTranslation("authentication");
const { register, errors, setValue, trigger, getValues } = useForm({
const form = useForm({
shouldUnregister: false,
});
const { setValue, trigger, getValues } = form;
const adminClient = useAdminClient();
const { addAlert, addError } = useAlerts();
@ -74,10 +73,16 @@ export const DuplicateFlowModal = ({
onClose={toggleDialog}
variant={ModalVariant.small}
actions={[
<Button id="modal-confirm" key="confirm" onClick={save}>
{t("common:save")}
<Button
id="modal-confirm"
key="confirm"
onClick={save}
data-testid="confirm"
>
{t("duplicate")}
</Button>,
<Button
data-testid="cancel"
id="modal-cancel"
key="cancel"
variant={ButtonVariant.link}
@ -89,35 +94,11 @@ export const DuplicateFlowModal = ({
</Button>,
]}
>
<Form isHorizontal>
<FormGroup
label={t("common:name")}
fieldId="kc-name"
helperTextInvalid={t("common:required")}
validated={
errors.name ? ValidatedOptions.error : ValidatedOptions.default
}
isRequired
>
<TextInput
type="text"
id="kc-name"
name="name"
ref={register({ required: true })}
validated={
errors.name ? ValidatedOptions.error : ValidatedOptions.default
}
/>
</FormGroup>
<FormGroup label={t("common:description")} fieldId="kc-description">
<TextInput
type="text"
id="kc-description"
name="description"
ref={register()}
/>
</FormGroup>
</Form>
<FormProvider {...form}>
<Form isHorizontal>
<NameDescription />
</Form>
</FormProvider>
</Modal>
);
};

View file

@ -0,0 +1,45 @@
import React from "react";
import { useTranslation } from "react-i18next";
import {
Button,
Flex,
FlexItem,
Title,
TitleSizes,
} from "@patternfly/react-core";
import { ListEmptyState } from "../components/list-empty-state/ListEmptyState";
import "./empty-execution-state.css";
const sections = ["addExecution", "addSubFlow"];
export const EmptyExecutionState = () => {
const { t } = useTranslation("authentication");
return (
<>
<ListEmptyState
message={t("emptyExecution")}
instructions={t("emptyExecutionInstructions")}
/>
<div className="keycloak__empty-execution-state__block">
{sections.map((section) => (
<Flex key={section} className="keycloak__empty-execution-state__help">
<FlexItem flex={{ default: "flex_1" }}>
<Title headingLevel="h3" size={TitleSizes.md}>
{t(`${section}Title`)}
</Title>
<p>{t(`authentication-help:${section}`)}</p>
</FlexItem>
<Flex alignSelf={{ default: "alignSelfCenter" }}>
<FlexItem>
<Button variant="tertiary">{t(section)}</Button>
</FlexItem>
</Flex>
</Flex>
))}
</div>
</>
);
};

View file

@ -0,0 +1,242 @@
import React, { useState } from "react";
import { useParams } from "react-router-dom";
import { useTranslation } from "react-i18next";
import {
DataList,
Label,
PageSection,
Toolbar,
ToolbarContent,
ToggleGroup,
ToggleGroupItem,
AlertVariant,
} from "@patternfly/react-core";
import { CheckCircleIcon, TableIcon } from "@patternfly/react-icons";
import type AuthenticationExecutionInfoRepresentation from "keycloak-admin/lib/defs/authenticationExecutionInfoRepresentation";
import type AuthenticationFlowRepresentation from "keycloak-admin/lib/defs/authenticationFlowRepresentation";
import type { FlowParams } from "./routes/Flow";
import { ViewHeader } from "../components/view-header/ViewHeader";
import { useAdminClient, useFetch } from "../context/auth/AdminClient";
import { EmptyExecutionState } from "./EmptyExecutionState";
import { toUpperCase } from "../util";
import { FlowHeader } from "./components/FlowHeader";
import { FlowRow } from "./components/FlowRow";
import { ExecutionList, IndexChange, LevelChange } from "./execution-model";
import { FlowDiagram } from "./components/FlowDiagram";
import { useAlerts } from "../components/alert/Alerts";
export type ExpandableExecution = AuthenticationExecutionInfoRepresentation & {
executionList: ExpandableExecution[];
isCollapsed: boolean;
};
export const FlowDetails = () => {
const { t } = useTranslation("authentication");
const adminClient = useAdminClient();
const { addAlert } = useAlerts();
const { id, usedBy, builtIn } = useParams<FlowParams>();
const [key, setKey] = useState(0);
const refresh = () => setKey(new Date().getTime());
const [tableView, setTableView] = useState(true);
const [flow, setFlow] = useState<AuthenticationFlowRepresentation>();
const [executionList, setExecutionList] = useState<ExecutionList>();
const [dragged, setDragged] =
useState<AuthenticationExecutionInfoRepresentation>();
const [liveText, setLiveText] = useState("");
useFetch(
async () => {
const flows = await adminClient.authenticationManagement.getFlows();
const flow = flows.find((f) => f.id === id);
const executions =
await adminClient.authenticationManagement.getExecutions({
flow: flow?.alias!,
});
return { flow, executions };
},
({ flow, executions }) => {
setFlow(flow);
setExecutionList(new ExecutionList(executions));
},
[key]
);
const executeChange = async (
ex: AuthenticationFlowRepresentation,
change: LevelChange | IndexChange
) => {
try {
let id = ex.id!;
if ("parent" in change) {
await adminClient.authenticationManagement.delExecution({ id });
const result =
await adminClient.authenticationManagement.addExecutionToFlow({
flow: change.parent?.displayName! || flow?.alias!,
provider: ex.providerId!,
});
id = result.id!;
}
const times = change.newIndex - change.oldIndex;
const requests = [];
for (let index = 0; index < Math.abs(times); index++) {
if (times > 0) {
requests.push(
adminClient.authenticationManagement.lowerPriorityExecution({
id,
})
);
} else {
requests.push(
adminClient.authenticationManagement.raisePriorityExecution({
id,
})
);
}
}
await Promise.all(requests);
refresh();
addAlert(t("updateFlowSuccess"), AlertVariant.success);
} catch (error) {
addAlert(
t("updateFlowError", {
error: error.response?.data?.errorMessage || error,
}),
AlertVariant.danger
);
}
};
const update = async (
execution: AuthenticationExecutionInfoRepresentation
) => {
try {
await adminClient.authenticationManagement.updateExecution(
{ flow: flow?.alias! },
execution
);
refresh();
addAlert(t("updateFlowSuccess"), AlertVariant.success);
} catch (error) {
addAlert(
t("updateFlowError", {
error: error.response?.data?.errorMessage || error,
}),
AlertVariant.danger
);
}
};
return (
<>
<ViewHeader
titleKey={toUpperCase(flow?.alias || "")}
badges={[
{ text: <Label>{t(usedBy)}</Label> },
builtIn
? {
text: (
<Label
className="keycloak_authentication-section__usedby_label"
icon={<CheckCircleIcon />}
>
{t("buildIn")}
</Label>
),
id: "builtIn",
}
: {},
]}
/>
<PageSection variant="light">
{executionList?.expandableList?.length && (
<Toolbar id="toolbar">
<ToolbarContent>
<ToggleGroup>
<ToggleGroupItem
icon={<TableIcon />}
aria-label={t("tableView")}
buttonId="tableView"
isSelected={tableView}
onChange={() => setTableView(true)}
/>
<ToggleGroupItem
icon={<i className="fas fa-project-diagram"></i>}
aria-label={t("diagramView")}
buttonId="diagramView"
isSelected={!tableView}
onChange={() => setTableView(false)}
/>
</ToggleGroup>
</ToolbarContent>
</Toolbar>
)}
{tableView && executionList?.expandableList?.length && (
<>
<DataList
aria-label="flows"
onDragFinish={(order) => {
const withoutHeaderId = order.slice(1);
setLiveText(
t("common:onDragFinish", { list: dragged?.displayName })
);
const change = executionList.getChange(
dragged!,
withoutHeaderId
);
executeChange(dragged!, change);
}}
onDragStart={(id) => {
const item = executionList.findExecution(id)!;
setLiveText(
t("common:onDragStart", { item: item.displayName })
);
setDragged(item);
if (item.executionList && !item.isCollapsed) {
item.isCollapsed = true;
setExecutionList(executionList.clone());
}
}}
onDragMove={() =>
setLiveText(
t("common:onDragMove", { item: dragged?.displayName })
)
}
onDragCancel={() => setLiveText(t("common:onDragCancel"))}
itemOrder={[
"header",
...executionList.order().map((ex) => ex.id!),
]}
>
<FlowHeader />
<>
{executionList.expandableList.map((execution) => (
<FlowRow
key={execution.id}
execution={execution}
onRowClick={(execution) => {
execution.isCollapsed = !execution.isCollapsed;
setExecutionList(executionList.clone());
}}
onRowChange={update}
/>
))}
</>
</DataList>
<div className="pf-screen-reader" aria-live="assertive">
{liveText}
</div>
</>
)}
{!tableView && executionList?.expandableList && (
<FlowDiagram executionList={executionList} />
)}
{!executionList?.expandableList ||
(executionList.expandableList.length === 0 && (
<EmptyExecutionState />
))}
</PageSection>
</>
);
};

View file

@ -0,0 +1,150 @@
import { ExecutionList, IndexChange, LevelChange } from "../execution-model";
describe("ExecutionList", () => {
const list2 = new ExecutionList([
{ id: "1", index: 0, level: 0 },
{ id: "2", index: 1, level: 0 },
{ id: "3", index: 0, level: 1 },
{ id: "4", index: 1, level: 1 },
{ id: "5", index: 0, level: 2 },
{ id: "6", index: 1, level: 2 },
{ id: "7", index: 2, level: 0 },
]);
it("Move 1 down to the end", () => {
const diff = list2.getChange({ id: "1" }, [
"2",
"3",
"4",
"5",
"1",
"6",
"7",
]);
expect(diff).toBeInstanceOf(LevelChange);
expect((diff as LevelChange).parent?.id).toBe("4");
});
it("Index change", () => {
const diff = list2.getChange({ id: "5" }, [
"1",
"2",
"3",
"4",
"6",
"5",
"7",
]);
expect(diff).toBeInstanceOf(IndexChange);
expect((diff as IndexChange).newIndex).toBe(1);
expect((diff as IndexChange).oldIndex).toBe(0);
});
it("Move 7 down to the top", () => {
const diff = list2.getChange({ id: "7" }, [
"7",
"1",
"2",
"3",
"4",
"5",
"6",
]);
expect(diff).toBeInstanceOf(IndexChange);
expect((diff as IndexChange).newIndex).toBe(0);
expect((diff as IndexChange).oldIndex).toBe(2);
});
it("Move 5 to the top level", () => {
const diff = list2.getChange({ id: "5" }, [
"1",
"5",
"2",
"3",
"4",
"6",
"7",
]);
expect(diff).toBeInstanceOf(LevelChange);
expect((diff as LevelChange).parent).toBeUndefined();
});
it("Move 5 to the top level, begin of the list", () => {
const diff = list2.getChange({ id: "5" }, [
"5",
"1",
"2",
"3",
"4",
"6",
"7",
]);
expect(diff).toBeInstanceOf(LevelChange);
expect((diff as LevelChange).parent).toBeUndefined();
});
it("Move 6 one level up", () => {
const diff = list2.getChange({ id: "6" }, [
"1",
"2",
"6",
"3",
"4",
"5",
"7",
]);
expect(diff).toBeInstanceOf(LevelChange);
expect((diff as LevelChange).parent?.id).toBe("2");
});
it("Move a parent to the top", () => {
const diff = list2.getChange({ id: "4" }, [
"4",
"5",
"6",
"1",
"2",
"3",
"7",
]);
expect(diff).toBeInstanceOf(LevelChange);
expect((diff as LevelChange).parent?.id).toBeUndefined();
});
it("Move a parent same level", () => {
const diff = list2.getChange({ id: "4" }, [
"1",
"2",
"4",
"5",
"6",
"3",
"7",
]);
expect(diff).toBeInstanceOf(IndexChange);
expect((diff as IndexChange).newIndex).toBe(0);
});
it("Move 5 to the bottom", () => {
const diff = list2.getChange({ id: "5" }, [
"1",
"2",
"3",
"4",
"6",
"7",
"5",
]);
expect(diff).toBeInstanceOf(LevelChange);
expect((diff as LevelChange).parent).toBeUndefined();
});
});

View file

@ -1,3 +1,7 @@
.keycloak_authentication-section__usedby {
color: var(--pf-global--success-color--100);
}
.keycloak_authentication-section__usedby_label .pf-c-label__icon {
color: var(--pf-global--success-color--100);
}

View file

@ -0,0 +1,264 @@
import React, { useState, MouseEvent } from "react";
import {
Drawer,
DrawerActions,
DrawerCloseButton,
DrawerContent,
DrawerContentBody,
DrawerHead,
DrawerPanelContent,
} from "@patternfly/react-core";
import ReactFlow, {
Node,
Edge,
Elements,
Position,
removeElements,
MiniMap,
Controls,
Background,
isNode,
} from "react-flow-renderer";
import type AuthenticationExecutionInfoRepresentation from "keycloak-admin/lib/defs/authenticationExecutionInfoRepresentation";
import type { ExecutionList, ExpandableExecution } from "../execution-model";
import { EndSubFlowNode, StartSubFlowNode } from "./diagram/SubFlowNode";
import { ConditionalNode } from "./diagram/ConditionalNode";
import { ButtonEdge } from "./diagram/ButtonEdge";
import { getLayoutedElements } from "./diagram/auto-layout";
import "./flow-diagram.css";
type FlowDiagramProps = {
executionList: ExecutionList;
};
const createEdge = (fromNode: string, toNode: string) => ({
id: `edge-${fromNode}-to-${toNode}`,
type: "buttonEdge",
source: fromNode,
target: toNode,
data: {
onEdgeClick: (
evt: React.MouseEvent<HTMLButtonElement, MouseEvent>,
id: string
) => {
evt.stopPropagation();
alert(`hello ${id}`);
},
},
});
const createNode = (ex: ExpandableExecution) => {
let nodeType: string | undefined = undefined;
if (ex.executionList) {
nodeType = "startSubFlow";
}
if (ex.displayName?.startsWith("Condition")) {
nodeType = "conditional";
}
return {
id: ex.id!,
type: nodeType,
sourcePosition: Position.Right,
targetPosition: Position.Left,
data: { label: ex.displayName! },
position: { x: 0, y: 0 },
};
};
const renderParallelNodes = (
start: AuthenticationExecutionInfoRepresentation,
execution: ExpandableExecution,
end: AuthenticationExecutionInfoRepresentation
) => {
const elements: Elements = [];
elements.push(createNode(execution));
elements.push(createEdge(start.id!, execution.id!));
elements.push(createEdge(execution.id!, end.id!));
return elements;
};
const renderSequentialNodes = (
start: AuthenticationExecutionInfoRepresentation,
execution: ExpandableExecution,
end: AuthenticationExecutionInfoRepresentation,
prefExecution: ExpandableExecution,
isFirst: boolean,
isLast: boolean
) => {
const elements: Elements = [];
elements.push(createNode(execution));
if (isFirst) {
elements.push(createEdge(start.id!, execution.id!));
} else {
elements.push(createEdge(prefExecution.id!, execution.id!));
}
if (isLast) {
elements.push(createEdge(execution.id!, end.id!));
}
return elements;
};
const renderSubFlow = (
execution: ExpandableExecution,
start: AuthenticationExecutionInfoRepresentation,
end: AuthenticationExecutionInfoRepresentation,
prefExecution?: ExpandableExecution
) => {
const elements: Elements = [];
elements.push({
id: execution.id!,
type: "startSubFlow",
sourcePosition: Position.Right,
targetPosition: Position.Left,
data: { label: execution.displayName! },
position: { x: 0, y: 0 },
});
const endSubFlowId = `flow-end-${execution.id}`;
elements.push({
id: endSubFlowId,
type: "endSubFlow",
sourcePosition: Position.Right,
targetPosition: Position.Left,
data: { label: execution.displayName! },
position: { x: 0, y: 0 },
});
elements.push(
createEdge(
prefExecution && prefExecution.requirement !== "ALTERNATIVE"
? prefExecution.id!
: start.id!,
execution.id!
)
);
elements.push(createEdge(endSubFlowId, end.id!));
return elements.concat(
renderFlow(execution, execution.executionList, {
...execution,
id: endSubFlowId,
})
);
};
const renderFlow = (
start: AuthenticationExecutionInfoRepresentation,
executionList: ExpandableExecution[],
end: AuthenticationExecutionInfoRepresentation
) => {
let elements: Elements = [];
for (let index = 0; index < executionList.length; index++) {
const execution = executionList[index];
if (execution.executionList) {
elements = elements.concat(
renderSubFlow(execution, start, end, executionList[index - 1])
);
} else {
if (
execution.requirement === "ALTERNATIVE" ||
execution.requirement === "DISABLED"
) {
elements = elements.concat(renderParallelNodes(start, execution, end));
} else {
elements = elements.concat(
renderSequentialNodes(
start,
execution,
end,
executionList[index - 1],
index === 0,
index === executionList.length - 1
)
);
}
}
}
return elements;
};
export const FlowDiagram = ({
executionList: { expandableList },
}: FlowDiagramProps) => {
let elements: Elements = [
{
id: "start",
sourcePosition: Position.Right,
type: "input",
data: { label: "Start" },
position: { x: 0, y: 0 },
className: "keycloak__authentication__input_node",
},
{
id: "end",
targetPosition: Position.Left,
type: "output",
data: { label: "End" },
position: { x: 0, y: 0 },
className: "keycloak__authentication__output_node",
},
];
elements = elements.concat(
renderFlow({ id: "start" }, expandableList, { id: "end" })
);
const onLoad = (reactFlowInstance: { fitView: () => void }) =>
reactFlowInstance.fitView();
const [layoutedElements, setElements] = useState(
getLayoutedElements(elements)
);
const [expandDrawer, setExpandDrawer] = useState(false);
const onElementClick = (_event: MouseEvent, element: Node | Edge) => {
if (isNode(element)) setExpandDrawer(!expandDrawer);
};
const onElementsRemove = (elementsToRemove: Elements) =>
setElements((els) => removeElements(elementsToRemove, els));
return (
<Drawer isExpanded={expandDrawer} onExpand={() => setExpandDrawer(true)}>
<DrawerContent
panelContent={
<DrawerPanelContent>
<DrawerHead>
<span tabIndex={expandDrawer ? 0 : -1}>drawer-panel</span>
<DrawerActions>
<DrawerCloseButton onClick={() => setExpandDrawer(false)} />
</DrawerActions>
</DrawerHead>
</DrawerPanelContent>
}
>
<DrawerContentBody>
<ReactFlow
nodeTypes={{
conditional: ConditionalNode,
startSubFlow: StartSubFlowNode,
endSubFlow: EndSubFlowNode,
}}
edgeTypes={{
buttonEdge: ButtonEdge,
}}
onElementClick={onElementClick}
onElementsRemove={onElementsRemove}
onLoad={onLoad}
elements={layoutedElements}
nodesConnectable={false}
>
<MiniMap />
<Controls />
<Background />
</ReactFlow>
</DrawerContentBody>
</DrawerContent>
</Drawer>
);
};

View file

@ -0,0 +1,33 @@
import React from "react";
import { useTranslation } from "react-i18next";
import {
DataListItem,
DataListItemRow,
DataListDragButton,
DataListItemCells,
DataListCell,
} from "@patternfly/react-core";
import "./flow-header.css";
export const FlowHeader = () => {
const { t } = useTranslation("authentication");
return (
<DataListItem aria-labelledby="headerName" id="header">
<DataListItemRow>
<DataListDragButton className="keycloak__authentication__header-drag-button" />
<DataListItemCells
className="keycloak__authentication__header"
dataListCells={[
<DataListCell key="step" id="headerName">
<>{t("steps")}</>
</DataListCell>,
<DataListCell key="requirement">
<>{t("requirement")}</>
</DataListCell>,
]}
/>
</DataListItemRow>
</DataListItem>
);
};

View file

@ -0,0 +1,48 @@
import React, { useState } from "react";
import { useTranslation } from "react-i18next";
import { Select, SelectOption, SelectVariant } from "@patternfly/react-core";
import type AuthenticationExecutionInfoRepresentation from "keycloak-admin/lib/defs/authenticationExecutionInfoRepresentation";
type FlowRequirementDropdownProps = {
flow: AuthenticationExecutionInfoRepresentation;
onChange: (flow: AuthenticationExecutionInfoRepresentation) => void;
};
export const FlowRequirementDropdown = ({
flow,
onChange,
}: FlowRequirementDropdownProps) => {
const { t } = useTranslation("authentication");
const [open, setOpen] = useState(false);
const options = flow.requirementChoices!.map((option, index) => (
<SelectOption key={index} value={option}>
{t(`requirements.${option}`)}
</SelectOption>
));
return (
<>
{flow.requirementChoices && flow.requirementChoices.length > 1 && (
<Select
className="keycloak__authentication__requirement-dropdown"
variant={SelectVariant.single}
onToggle={() => setOpen(!open)}
onSelect={(_event, value) => {
flow.requirement = value.toString();
onChange(flow);
setOpen(false);
}}
selections={[flow.requirement]}
isOpen={open}
>
{options}
</Select>
)}
{(!flow.requirementChoices || flow.requirementChoices.length <= 1) && (
<>{t(`requirements.${flow.requirement}`)}</>
)}
</>
);
};

View file

@ -0,0 +1,102 @@
import React from "react";
import { useTranslation } from "react-i18next";
import {
DataListItemRow,
DataListControl,
DataListDragButton,
DataListItemCells,
DataListCell,
DataListItem,
DataListToggle,
Text,
TextVariants,
} from "@patternfly/react-core";
import type AuthenticationExecutionInfoRepresentation from "keycloak-admin/lib/defs/authenticationExecutionInfoRepresentation";
import type { ExpandableExecution } from "../FlowDetails";
import { FlowTitle } from "./FlowTitle";
import { FlowRequirementDropdown } from "./FlowRequirementDropdown";
import "./flow-row.css";
type FlowRowProps = {
execution: ExpandableExecution;
onRowClick: (execution: ExpandableExecution) => void;
onRowChange: (execution: AuthenticationExecutionInfoRepresentation) => void;
};
export const FlowRow = ({
execution,
onRowClick,
onRowChange,
}: FlowRowProps) => {
const { t } = useTranslation("authentication");
const hasSubList = !!execution.executionList?.length;
return (
<>
<DataListItem
className="keycloak__authentication__flow-item"
id={execution.id}
isExpanded={!execution.isCollapsed}
>
<DataListItemRow
className="keycloak__authentication__flow-row"
aria-level={execution.level}
>
<DataListControl>
<DataListDragButton
aria-labelledby={execution.displayName}
aria-describedby={t("common-help:dragHelp")}
/>
</DataListControl>
{hasSubList && (
<DataListToggle
onClick={() => onRowClick(execution)}
isExpanded={!execution.isCollapsed}
id={`toggle1-${execution.id}`}
aria-controls={`expand-${execution.id}`}
/>
)}
<DataListItemCells
dataListCells={[
<DataListCell key={`${execution.id}-name`}>
{!hasSubList && (
<FlowTitle
key={execution.id}
title={execution.displayName!}
/>
)}
{hasSubList && (
<>
{execution.displayName} <br />{" "}
<Text component={TextVariants.small}>
{execution.description}
</Text>
</>
)}
</DataListCell>,
<DataListCell key={`${execution.id}-requirement`}>
<FlowRequirementDropdown
flow={execution}
onChange={onRowChange}
/>
</DataListCell>,
]}
/>
</DataListItemRow>
</DataListItem>
{!execution.isCollapsed && hasSubList && (
<>
{execution.executionList.map((execution) => (
<FlowRow
key={execution.id}
execution={execution}
onRowClick={onRowClick}
onRowChange={onRowChange}
/>
))}
</>
)}
</>
);
};

View file

@ -0,0 +1,20 @@
import React from "react";
import { Card, CardBody } from "@patternfly/react-core";
import "./flow-title.css";
type FlowTitleProps = {
title: string;
};
export const FlowTitle = ({ title }: FlowTitleProps) => {
return (
<Card
data-testid={title}
className="keycloak__authentication__title"
isFlat
>
<CardBody>{title}</CardBody>
</Card>
);
};

View file

@ -0,0 +1,91 @@
import React, { CSSProperties } from "react";
import { PlusIcon } from "@patternfly/react-icons";
import {
ArrowHeadType,
getBezierPath,
getEdgeCenter,
getMarkerEnd,
Position,
} from "react-flow-renderer";
type ButtonEdgeProps = {
id: string;
sourceX: number;
sourceY: number;
sourcePosition?: Position;
targetX: number;
targetY: number;
targetPosition?: Position;
style: CSSProperties;
arrowHeadType?: ArrowHeadType;
markerEndId: string;
selected: boolean;
data: {
onEdgeClick: (
evt: React.MouseEvent<HTMLButtonElement, MouseEvent>,
id: string
) => void;
};
};
const foreignObjectSize = 33;
export const ButtonEdge = ({
id,
sourceX,
sourceY,
targetX,
targetY,
sourcePosition,
targetPosition,
style = {},
arrowHeadType,
markerEndId,
selected,
data: { onEdgeClick },
}: ButtonEdgeProps) => {
const edgePath = getBezierPath({
sourceX,
sourceY,
sourcePosition,
targetX,
targetY,
targetPosition,
});
const markerEnd = getMarkerEnd(arrowHeadType, markerEndId);
const [edgeCenterX, edgeCenterY] = getEdgeCenter({
sourceX,
sourceY,
targetX,
targetY,
});
return (
<>
<path
id={id}
style={style}
className="react-flow__edge-path"
d={edgePath}
markerEnd={markerEnd}
/>
{selected && (
<foreignObject
width={foreignObjectSize}
height={foreignObjectSize}
x={edgeCenterX - foreignObjectSize / 2}
y={edgeCenterY - foreignObjectSize / 2}
className="edgebutton-foreignobject"
requiredExtensions="http://www.w3.org/1999/xhtml"
>
<button
className="edgebutton"
onClick={(event) => onEdgeClick(event, id)}
>
<PlusIcon />
</button>
</foreignObject>
)}
</>
);
};

View file

@ -0,0 +1,26 @@
import React, { memo } from "react";
import { Handle, Position } from "react-flow-renderer";
type ConditionalNodeProps = {
data: { label: string };
selected: boolean;
};
export const ConditionalNode = memo(function TheNode({
data,
selected,
}: ConditionalNodeProps) {
return (
<>
<Handle position={Position.Right} type="source" />
<div
className={`react-flow__node-default keycloak__authentication__conditional_node ${
selected ? "selected" : ""
}`}
>
<div>{data.label}</div>
</div>
<Handle position={Position.Left} type="target" />
</>
);
});

View file

@ -0,0 +1,40 @@
import React, { memo } from "react";
import { Handle, Position } from "react-flow-renderer";
type NodeProps = {
data: { label: string };
selected: boolean;
};
type SubFlowNodeProps = NodeProps & {
prefix: string;
};
export const SubFlowNode = memo(function TheNode({
data: { label },
prefix,
selected,
}: SubFlowNodeProps) {
return (
<>
<Handle position={Position.Right} type="source" />
<div
className={`react-flow__node-default keycloak__authentication__subflow_node ${
selected ? "selected" : ""
}`}
>
<div>
{prefix} {label}
</div>
</div>
<Handle position={Position.Left} type="target" />
</>
);
});
export const StartSubFlowNode = ({ ...props }: NodeProps) => (
<SubFlowNode {...props} prefix="Start" />
);
export const EndSubFlowNode = ({ ...props }: NodeProps) => (
<SubFlowNode {...props} prefix="End" />
);

View file

@ -0,0 +1,41 @@
import { Elements, Position, isNode } from "react-flow-renderer";
import dagre from "dagre";
const dagreGraph = new dagre.graphlib.Graph();
dagreGraph.setDefaultEdgeLabel(() => ({}));
const nodeWidth = 130;
const nodeHeight = 28;
export const getLayoutedElements = (elements: Elements, direction = "LR") => {
const isHorizontal = direction === "LR";
dagreGraph.setGraph({ rankdir: direction });
elements.forEach((element) => {
if (isNode(element)) {
dagreGraph.setNode(element.id, {
width: nodeWidth,
height: nodeHeight,
});
} else {
dagreGraph.setEdge(element.source, element.target);
}
});
dagre.layout(dagreGraph);
return elements.map((element) => {
if (isNode(element)) {
const nodeWithPosition = dagreGraph.node(element.id);
element.targetPosition = isHorizontal ? Position.Left : Position.Top;
element.sourcePosition = isHorizontal ? Position.Right : Position.Bottom;
element.position = {
x: nodeWithPosition.x - nodeWidth / 2 + Math.random() / 1000,
y: nodeWithPosition.y - nodeHeight / 2,
};
}
return element;
});
};

View file

@ -0,0 +1,52 @@
.keycloak__authentication__input_node,
.keycloak__authentication__output_node {
width: 50px;
height: 50px;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
background-color: var(--pf-global--BackgroundColor--200);
border: 0;
}
.keycloak__authentication__conditional_node {
border: 1px solid #777;
width: 80px;
height: 80px;
transform: rotate(45deg);
}
.keycloak__authentication__conditional_node div {
transform: rotate(-45deg);
}
.keycloak__authentication__subflow_node {
width: 80px;
height: 80px;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
background-color: var(--pf-global--BackgroundColor--200);
}
.keycloak__authentication__input_node.selected,
.keycloak__authentication__input_node:hover,
.keycloak__authentication__output_node.selected,
.keycloak__authentication__output_node:hover,
.keycloak__authentication__subflow_node.selected,
.keycloak__authentication__subflow_node:hover,
.keycloak__authentication__conditional_node.selected,
.keycloak__authentication__conditional_node:hover {
box-shadow: 0 0 0 0.5px #1a192b;
}
.edgebutton {
background-color: var(--pf-global--BackgroundColor--200);
border: 1px solid var(--pf-global--BackgroundColor--100);
border-radius: 50%;
cursor: pointer;
height: 33px;
width: 33px;
}

View file

@ -0,0 +1,7 @@
.keycloak__authentication__header-drag-button svg {
fill: var(--pf-global--BackgroundColor--100);
}
.keycloak__authentication__header .pf-c-data-list__cell {
font-weight: 700;
}

View file

@ -0,0 +1,55 @@
.keycloak__authentication__flow-item:before {
width: 0;
}
.keycloak__authentication__flow-row[aria-level="1"] {
padding-left: calc(
var(--pf-global--spacer--lg) * 2
);
}
.keycloak__authentication__flow-row[aria-level="2"] {
padding-left: calc(
var(--pf-global--spacer--lg) * 3
);
}
.keycloak__authentication__flow-row[aria-level="3"] {
padding-left: calc(
var(--pf-global--spacer--lg) * 4
);
}
.keycloak__authentication__flow-row[aria-level="4"] {
padding-left: calc(
var(--pf-global--spacer--lg) * 5
);
}
.keycloak__authentication__flow-row[aria-level="5"] {
padding-left: calc(
var(--pf-global--spacer--lg) * 6
);
}
.keycloak__authentication__flow-row[aria-level="6"] {
padding-left: calc(
var(--pf-global--spacer--lg) * 7
);
}
.keycloak__authentication__flow-row[aria-level="7"] {
padding-left: calc(
var(--pf-global--spacer--lg) * 8
);
}
.keycloak__authentication__flow-row[aria-level="8"] {
padding-left: calc(
var(--pf-global--spacer--lg) * 9
);
}
.keycloak__authentication__flow-row[aria-level="9"] {
padding-left: calc(
var(--pf-global--spacer--lg) * 10
);
}
.keycloak__authentication__flow-row[aria-level="10"] {
padding-left: calc(
var(--pf-global--spacer--lg) * 11
);
}

View file

@ -0,0 +1,9 @@
.keycloak__authentication__title {
width: fit-content;
width: -moz-fit-content;
}
.keycloak__authentication__title .pf-c-card__body {
padding-bottom: var(--pf-global--spacer--sm);
padding-top: var(--pf-global--spacer--sm);
}

View file

@ -0,0 +1,11 @@
.keycloak__empty-execution-state__block {
padding-top: var(--pf-global--spacer--sm);
}
.keycloak__empty-execution-state__help {
max-width: 36rem;
margin: 0 auto var(--pf-global--spacer--2xl);
}
.keycloak__empty-execution-state__help p {
color: var(--pf-global--Color--200);
}

View file

@ -0,0 +1,143 @@
import type AuthenticationExecutionInfoRepresentation from "keycloak-admin/lib/defs/authenticationExecutionInfoRepresentation";
export type ExpandableExecution = AuthenticationExecutionInfoRepresentation & {
executionList: ExpandableExecution[];
isCollapsed: boolean;
};
export class IndexChange {
oldIndex: number;
newIndex: number;
constructor(oldIndex: number, newIndex: number) {
this.oldIndex = oldIndex;
this.newIndex = newIndex;
}
}
export class LevelChange extends IndexChange {
parent?: ExpandableExecution;
constructor(
oldIndex: number,
newIndex: number,
parent?: ExpandableExecution
) {
super(oldIndex, newIndex);
this.parent = parent;
}
}
export class ExecutionList {
private list: ExpandableExecution[];
expandableList: ExpandableExecution[];
constructor(list: AuthenticationExecutionInfoRepresentation[]) {
this.list = list as ExpandableExecution[];
this.expandableList = this.transformToExpandableList(0, 0, {
executionList: [],
isCollapsed: false,
}).executionList;
}
private transformToExpandableList(
level: number,
currIndex: number,
execution: ExpandableExecution
) {
for (let index = currIndex; index < this.list.length; index++) {
const ex = this.list[index];
const nextRowLevel = this.list[index + 1]?.level || 0;
if (ex.level === level && nextRowLevel <= level) {
execution.executionList.push(ex);
} else if (ex.level === level && nextRowLevel > level) {
const expandable = this.transformToExpandableList(
nextRowLevel,
index + 1,
{
...ex,
executionList: [],
isCollapsed: false,
}
);
execution.executionList.push(expandable);
}
}
return execution;
}
order(list?: ExpandableExecution[]) {
let result: ExpandableExecution[] = [];
for (const row of list || this.expandableList) {
result.push(row);
if (row.executionList && !row.isCollapsed) {
result = result.concat(this.order(row.executionList));
}
}
return result;
}
findExecution(
id: string,
list?: ExpandableExecution[]
): ExpandableExecution | undefined {
let found = (list || this.expandableList).find((ex) => ex.id === id);
if (!found) {
for (const ex of list || this.expandableList) {
if (ex.executionList) {
found = this.findExecution(id, ex.executionList);
if (found) {
return found;
}
}
}
}
return found;
}
private getParentNodes(level?: number) {
for (let index = 0; index < this.list.length; index++) {
const ex = this.list[index];
if (
index + 1 < this.list.length &&
this.list[index + 1].level! > ex.level! &&
ex.level! + 1 === level
) {
return ex;
}
}
}
getChange(
changed: AuthenticationExecutionInfoRepresentation,
order: string[]
) {
const currentOrder = this.order();
const newLocIndex = order.findIndex((id) => id === changed.id);
const oldLocation =
currentOrder[currentOrder.findIndex((ex) => ex.id === changed.id)];
const newLocation = currentOrder[newLocIndex];
if (newLocation.level !== oldLocation.level) {
if (newLocation.level! > 0) {
const parent = this.getParentNodes(newLocation.level);
return new LevelChange(
parent?.executionList?.length || 0,
newLocation.index!,
parent
);
}
return new LevelChange(this.expandableList.length, newLocation.index!);
}
return new IndexChange(oldLocation.index!, newLocation.index!);
}
clone() {
const newList = new ExecutionList([]);
newList.list = this.list;
newList.expandableList = this.expandableList;
return newList;
}
}

View file

@ -0,0 +1,81 @@
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 type AuthenticationFlowRepresentation from "keycloak-admin/lib/defs/authenticationFlowRepresentation";
import { ViewHeader } from "../../components/view-header/ViewHeader";
import { FormAccess } from "../../components/form-access/FormAccess";
import { useRealm } from "../../context/realm-context/RealmContext";
import { useAdminClient } from "../../context/auth/AdminClient";
import { useAlerts } from "../../components/alert/Alerts";
import { NameDescription } from "./NameDescription";
import { FlowType } from "./FlowType";
export const CreateFlow = () => {
const { t } = useTranslation("authentication");
const history = useHistory();
const { realm } = useRealm();
const form = useForm<AuthenticationFlowRepresentation>({
defaultValues: { builtIn: false, topLevel: true },
});
const { handleSubmit, register } = form;
const adminClient = useAdminClient();
const { addAlert } = useAlerts();
const save = async (flow: AuthenticationFlowRepresentation) => {
try {
await adminClient.authenticationManagement.createFlow(flow);
addAlert(t("flowCreatedSuccess"), AlertVariant.success);
} catch (error) {
addAlert(
t("flowCreateError", {
error: error.response?.data?.errorMessage || error,
}),
AlertVariant.danger
);
}
};
return (
<>
<ViewHeader
titleKey="authentication:createFlow"
subKey="authentication-help:createFlow"
/>
<PageSection variant="light">
<FormProvider {...form}>
<FormAccess
isHorizontal
role="manage-authorization"
onSubmit={handleSubmit(save)}
>
<input name="builtIn" type="hidden" ref={register} />
<input name="topLevel" type="hidden" ref={register} />
<NameDescription />
<FlowType />
<ActionGroup>
<Button data-testid="create" type="submit">
{t("common:create")}
</Button>
<Button
data-testid="cancel"
variant="link"
onClick={() => history.push(`/${realm}/authentication`)}
>
{t("common:cancel")}
</Button>
</ActionGroup>
</FormAccess>
</FormProvider>
</PageSection>
</>
);
};

View file

@ -0,0 +1,62 @@
import React, { useState } from "react";
import { useTranslation } from "react-i18next";
import { Controller, useFormContext } from "react-hook-form";
import {
FormGroup,
Select,
SelectOption,
SelectVariant,
} from "@patternfly/react-core";
import { HelpItem } from "../../components/help-enabler/HelpItem";
const types = ["basic-flow", "client-flow"];
export const FlowType = () => {
const { t } = useTranslation("authentication");
const { control } = useFormContext();
const [open, setOpen] = useState(false);
return (
<FormGroup
label={t("flowType")}
labelIcon={
<HelpItem
helpText="authentication-help:flowType"
forLabel={t("flowType")}
forID="flowType"
/>
}
fieldId="flowType"
>
<Controller
name="providerId"
defaultValue={types[0]}
control={control}
render={({ onChange, value }) => (
<Select
toggleId="flowType"
onToggle={() => setOpen(!open)}
onSelect={(_, value) => {
onChange(value as string);
setOpen(false);
}}
selections={t(`flow-type.${value}`)}
variant={SelectVariant.single}
aria-label={t("flowType")}
isOpen={open}
>
{types.map((type) => (
<SelectOption
selected={type === value}
key={type}
value={t(`flow-type.${type}`)}
/>
))}
</Select>
)}
/>
</FormGroup>
);
};

View file

@ -0,0 +1,81 @@
import React from "react";
import { useTranslation } from "react-i18next";
import { useFormContext } from "react-hook-form";
import {
FormGroup,
TextArea,
TextInput,
ValidatedOptions,
} from "@patternfly/react-core";
import { HelpItem } from "../../components/help-enabler/HelpItem";
export const NameDescription = () => {
const { t } = useTranslation("authentication");
const { register, errors } = useFormContext();
return (
<>
<FormGroup
label={t("common:name")}
fieldId="kc-name"
helperTextInvalid={t("common:required")}
validated={
errors.alias ? ValidatedOptions.error : ValidatedOptions.default
}
isRequired
labelIcon={
<HelpItem
helpText="authentication-help:name"
forLabel={t("common:name")}
forID="kc-name"
/>
}
>
<TextInput
type="text"
id="kc-name"
name="alias"
data-testid="alias"
ref={register({ required: true })}
validated={
errors.alias ? ValidatedOptions.error : ValidatedOptions.default
}
/>
</FormGroup>
<FormGroup
label={t("common:description")}
fieldId="kc-description"
labelIcon={
<HelpItem
helpText="authentication-help:description"
forLabel={t("common:description")}
forID="kc-description"
/>
}
validated={
errors.description ? ValidatedOptions.error : ValidatedOptions.default
}
helperTextInvalid={errors.description?.message}
>
<TextArea
ref={register({
maxLength: {
value: 255,
message: t("common:maxLength", { length: 255 }),
},
})}
type="text"
id="kc-description"
name="description"
data-testid="description"
validated={
errors.description
? ValidatedOptions.error
: ValidatedOptions.default
}
/>
</FormGroup>
</>
);
};

View file

@ -0,0 +1,13 @@
export default {
"authentication-help": {
name: "Help text for the name of the new flow",
description: "Help text for the description of the new flow",
createFlow: "You can create a top level flow within this from",
flowType:
"What kind of top level flow is it? Type 'client' is used for authentication of clients (applications) when generic is for users and everything else",
addExecution:
"Execution can have a wide range of actions, from sending a reset email to validating an OTP",
addSubFlow:
"Sub-Flows can be either generic or form. The form type is used to construct a sub-flow that generates a single flow for the user. Sub-flows are a special type of execution that evaluate as successful depending on how the executions they contain evaluate.",
},
};

View file

@ -18,8 +18,36 @@ export default {
deleteFlowSuccess: "Flow successfully deleted",
deleteFlowError: "Could not delete flow: {{error}}",
duplicateFlow: "Duplicate flow",
updateFlowSuccess: "Flow successfully updated",
updateFlowError: "Could not update flow: {{error}}",
copyOf: "Copy of {{name}}",
copyFlowSuccess: "Flow successfully duplicated",
copyFlowError: "Could not duplicate flow: {{error}}",
createFlow: "Create flow",
flowType: "Flow type",
"flow-type": {
"basic-flow": "Basic flow",
"client-flow": "Client flow",
},
flowCreatedSuccess: "Flow created",
flowCreateError: "Could not create flow: {{error}}",
flowDetails: "Flow details",
tableView: "Table view",
diagramView: "Diagram view",
emptyExecution: "No steps",
emptyExecutionInstructions:
"You can start defining this flow by adding a sub-flow or an execution",
addExecutionTitle: "Add an execution",
addExecution: "Add execution",
addSubFlowTitle: "Add a sub-flow",
addSubFlow: "Add sub-flow",
steps: "Steps",
requirement: "Requirement",
requirements: {
REQUIRED: "Required",
ALTERNATIVE: "Alternative",
DISABLED: "Disabled",
CONDITIONAL: "Conditional",
},
},
};

View file

@ -1,6 +1,8 @@
import type { RouteDef } from "../route-config";
import { AuthenticationRoute } from "./routes/Authentication";
import { CreateFlowRoute } from "./routes/CreateFlow";
import { FlowRoute } from "./routes/Flow";
const routes: RouteDef[] = [AuthenticationRoute];
const routes: RouteDef[] = [AuthenticationRoute, CreateFlowRoute, FlowRoute];
export default routes;

View file

@ -0,0 +1,19 @@
import type { LocationDescriptorObject } from "history";
import { generatePath } from "react-router-dom";
import type { RouteDef } from "../../route-config";
import { CreateFlow } from "../form/CreateFlow";
export type CreateFlowParams = { realm: string };
export const CreateFlowRoute: RouteDef = {
path: "/:realm/authentication/create",
component: CreateFlow,
breadcrumb: (t) => t("authentication:createFlow"),
access: "manage-authorization",
};
export const toCreateFlow = (
params: CreateFlowParams
): LocationDescriptorObject => ({
pathname: generatePath(CreateFlowRoute.path, params),
});

View file

@ -0,0 +1,22 @@
import type { LocationDescriptorObject } from "history";
import { generatePath } from "react-router-dom";
import type { RouteDef } from "../../route-config";
import { FlowDetails } from "../FlowDetails";
export type FlowParams = {
realm: string;
id: string;
usedBy: string;
builtIn?: string;
};
export const FlowRoute: RouteDef = {
path: "/:realm/authentication/:id/:usedBy/:builtIn?",
component: FlowDetails,
breadcrumb: (t) => t("authentication:flowDetails"),
access: "manage-authorization",
};
export const toFlow = (params: FlowParams): LocationDescriptorObject => ({
pathname: generatePath(FlowRoute.path, params),
});

View file

@ -143,7 +143,7 @@ export const MappingDetails = () => {
<ViewHeader
titleKey={mapping ? mapping.name! : t("common:addMapper")}
subKey={mapperId.match(isGuid) ? mapperId : ""}
badge={mapping?.protocol}
badges={[{ text: mapping?.protocol }]}
dropdownItems={
mapperId.match(isGuid)
? [

View file

@ -203,7 +203,7 @@ export const ClientScopeForm = () => {
]
: undefined
}
badge={clientScope ? clientScope.protocol : undefined}
badges={[{ text: clientScope ? clientScope.protocol : undefined }]}
divider={!id}
/>

View file

@ -80,7 +80,7 @@ const ClientDetailHeader = ({
<ViewHeader
titleKey={client ? client.clientId! : ""}
subKey="clients:clientsExplain"
badge={client.protocol}
badges={[{ text: client.protocol }]}
divider={false}
helpTextKey="clients-help:enableDisable"
dropdownItems={[

View file

@ -4,5 +4,7 @@ export default {
"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",
helpFileUpload: "Upload a JSON file",
dragHelp:
"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.",
},
};

View file

@ -136,5 +136,11 @@ export default {
leaveDirtyConfirm:
"Do you want to leave this page without saving? Any unsaved changes will be lost.",
leave: "Leave",
reorder: "Reorder",
onDragStart: "Dragging started for item {{item}}",
onDragMove: "Dragging item {{item}}",
onDragCancel: "Dragging cancelled. List is unchanged.",
onDragFinish: "Dragging finished {{list}}",
},
};

View file

@ -14,7 +14,13 @@ import {
ToolbarContent,
ToolbarItem,
} from "@patternfly/react-core";
import React, { ReactElement, ReactNode, useState } from "react";
import React, {
ReactElement,
ReactNode,
useState,
isValidElement,
Fragment,
} from "react";
import { useTranslation } from "react-i18next";
import {
FormattedLink,
@ -25,9 +31,7 @@ import { HelpItem } from "../help-enabler/HelpItem";
export type ViewHeaderProps = {
titleKey: string;
badge?: string;
badgeId?: string;
badgeIsRead?: boolean;
badges?: ViewHeaderBadge[];
subKey?: string | ReactNode;
actionsDropdownId?: string;
subKeyLinkProps?: FormattedLinkProps;
@ -40,11 +44,16 @@ export type ViewHeaderProps = {
helpTextKey?: string;
};
export type ViewHeaderBadge = {
id?: string;
text?: string | ReactNode;
readonly?: boolean;
};
export const ViewHeader = ({
actionsDropdownId,
titleKey,
badge,
badgeIsRead,
badges,
subKey,
subKeyLinkProps,
dropdownItems,
@ -79,19 +88,24 @@ export const ViewHeader = ({
<Text component="h1">{t(titleKey)}</Text>
</TextContent>
</LevelItem>
{badge && (
{badges && (
<LevelItem>
<Badge
data-testid="composite-role-badge"
isRead={badgeIsRead}
>
{badge}
</Badge>
{badges.map((badge, index) => (
<Fragment key={index}>
{!isValidElement(badge.text) && (
<Fragment key={badge.text as string}>
<Badge data-testid={badge.id} isRead={badge.readonly}>
{badge.text}
</Badge>{" "}
</Fragment>
)}
{isValidElement(badge.text) && <>{badge.text}</>}{" "}
</Fragment>
))}
</LevelItem>
)}
</Level>
</LevelItem>
<LevelItem></LevelItem>
<LevelItem>
<Toolbar className="pf-u-p-0">
<ToolbarContent>

View file

@ -19,6 +19,7 @@ import events from "./events/messages";
import realmSettings from "./realm-settings/messages";
import realmSettingsHelp from "./realm-settings/help";
import authentication from "./authentication/messages";
import authenticationHelp from "./authentication/help";
import userFederation from "./user-federation/messages";
import userFederationHelp from "./user-federation/help";
import identityProviders from "./identity-providers/messages";
@ -47,6 +48,7 @@ const initOptions = {
...realmSettings,
...realmSettingsHelp,
...authentication,
...authenticationHelp,
...identityProviders,
...identityProvidersHelp,
...userFederation,

View file

@ -42,19 +42,19 @@ export const ManageOderDialog = ({
const onDragStart = (id: string) => {
setAlias(id);
setLiveText(t("onDragStart", { id }));
setLiveText(t("common:onDragStart", { item: id }));
};
const onDragMove = () => {
setLiveText(t("onDragMove", { alias }));
setLiveText(t("common:onDragMove", { item: alias }));
};
const onDragCancel = () => {
setLiveText(t("onDragCancel"));
setLiveText(t("common:onDragCancel"));
};
const onDragFinish = (providerOrder: string[]) => {
setLiveText(t("onDragFinish", { list: providerOrder }));
setLiveText(t("common:onDragFinish", { list: providerOrder }));
setOrder(providerOrder);
};

View file

@ -34,10 +34,6 @@ export default {
"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:

View file

@ -314,8 +314,12 @@ export const RealmRoleTabs = () => {
/>
<ViewHeader
titleKey={role?.name || t("createRole")}
badge={additionalRoles.length > 0 ? t("composite") : ""}
badgeIsRead={true}
badges={[
{
text: additionalRoles.length > 0 ? t("composite") : "",
readonly: true,
},
]}
subKey={id ? "" : "roles:roleCreateExplain"}
actionsDropdownId="roles-actions-dropdown"
dropdownItems={dropdownItems}

View file

@ -118,21 +118,21 @@ export const KeysTabInner = ({ components, refresh }: KeysTabInnerProps) => {
});
const onDragStart = (id: string) => {
setLiveText(t("onDragStart", { id }));
setLiveText(t("common:onDragStart", { item: id }));
setId(id);
};
const onDragMove = () => {
setLiveText(t("onDragMove", { id }));
setLiveText(t("common:onDragMove", { item: id }));
};
const onDragCancel = () => {
setLiveText(t("onDragCancel"));
setLiveText(t("common:onDragCancel"));
};
const onDragFinish = (itemOrder: string[]) => {
setItemOrder(["data", ...itemOrder.filter((i) => i !== "data")]);
setLiveText(t("onDragCancel"));
setLiveText(t("common:onDragFinish"));
};
const onSearch = () => {

View file

@ -567,8 +567,4 @@ export default {
SKIP: "Skip",
OVERWRITE: "Overwrite",
},
onDragStart: "Dragging started for item {{id}}",
onDragMove: "Dragging item {{id}}",
onDragCancel: "Dragging cancelled. List is unchanged.",
onDragFinish: "Dragging finished {{list}}",
};