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:
parent
0cfa5c2c80
commit
cc31f0853c
46 changed files with 2781 additions and 105 deletions
59
cypress/integration/authentication_test.spec.ts
Normal file
59
cypress/integration/authentication_test.spec.ts
Normal 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");
|
||||
});
|
||||
});
|
|
@ -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;
|
||||
}
|
||||
}
|
|
@ -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
883
package-lock.json
generated
File diff suppressed because it is too large
Load diff
|
@ -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",
|
||||
|
|
|
@ -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 = [
|
||||
{
|
||||
|
|
|
@ -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>,
|
||||
]}
|
||||
>
|
||||
<FormProvider {...form}>
|
||||
<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>
|
||||
<NameDescription />
|
||||
</Form>
|
||||
</FormProvider>
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
|
|
45
src/authentication/EmptyExecutionState.tsx
Normal file
45
src/authentication/EmptyExecutionState.tsx
Normal 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>
|
||||
</>
|
||||
);
|
||||
};
|
242
src/authentication/FlowDetails.tsx
Normal file
242
src/authentication/FlowDetails.tsx
Normal 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>
|
||||
</>
|
||||
);
|
||||
};
|
150
src/authentication/__tests__/ExecutionList.test.tsx
Normal file
150
src/authentication/__tests__/ExecutionList.test.tsx
Normal 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();
|
||||
});
|
||||
});
|
|
@ -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);
|
||||
}
|
264
src/authentication/components/FlowDiagram.tsx
Normal file
264
src/authentication/components/FlowDiagram.tsx
Normal 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>
|
||||
);
|
||||
};
|
33
src/authentication/components/FlowHeader.tsx
Normal file
33
src/authentication/components/FlowHeader.tsx
Normal 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>
|
||||
);
|
||||
};
|
48
src/authentication/components/FlowRequirementDropdown.tsx
Normal file
48
src/authentication/components/FlowRequirementDropdown.tsx
Normal 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}`)}</>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
102
src/authentication/components/FlowRow.tsx
Normal file
102
src/authentication/components/FlowRow.tsx
Normal 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}
|
||||
/>
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
20
src/authentication/components/FlowTitle.tsx
Normal file
20
src/authentication/components/FlowTitle.tsx
Normal 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>
|
||||
);
|
||||
};
|
91
src/authentication/components/diagram/ButtonEdge.tsx
Normal file
91
src/authentication/components/diagram/ButtonEdge.tsx
Normal 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>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
26
src/authentication/components/diagram/ConditionalNode.tsx
Normal file
26
src/authentication/components/diagram/ConditionalNode.tsx
Normal 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" />
|
||||
</>
|
||||
);
|
||||
});
|
40
src/authentication/components/diagram/SubFlowNode.tsx
Normal file
40
src/authentication/components/diagram/SubFlowNode.tsx
Normal 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" />
|
||||
);
|
41
src/authentication/components/diagram/auto-layout.ts
Normal file
41
src/authentication/components/diagram/auto-layout.ts
Normal 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;
|
||||
});
|
||||
};
|
52
src/authentication/components/flow-diagram.css
Normal file
52
src/authentication/components/flow-diagram.css
Normal 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;
|
||||
}
|
7
src/authentication/components/flow-header.css
Normal file
7
src/authentication/components/flow-header.css
Normal 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;
|
||||
}
|
55
src/authentication/components/flow-row.css
Normal file
55
src/authentication/components/flow-row.css
Normal 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
|
||||
);
|
||||
}
|
9
src/authentication/components/flow-title.css
Normal file
9
src/authentication/components/flow-title.css
Normal 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);
|
||||
}
|
11
src/authentication/empty-execution-state.css
Normal file
11
src/authentication/empty-execution-state.css
Normal 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);
|
||||
}
|
143
src/authentication/execution-model.ts
Normal file
143
src/authentication/execution-model.ts
Normal 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;
|
||||
}
|
||||
}
|
81
src/authentication/form/CreateFlow.tsx
Normal file
81
src/authentication/form/CreateFlow.tsx
Normal 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>
|
||||
</>
|
||||
);
|
||||
};
|
62
src/authentication/form/FlowType.tsx
Normal file
62
src/authentication/form/FlowType.tsx
Normal 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>
|
||||
);
|
||||
};
|
81
src/authentication/form/NameDescription.tsx
Normal file
81
src/authentication/form/NameDescription.tsx
Normal 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>
|
||||
</>
|
||||
);
|
||||
};
|
13
src/authentication/help.ts
Normal file
13
src/authentication/help.ts
Normal 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.",
|
||||
},
|
||||
};
|
|
@ -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",
|
||||
},
|
||||
},
|
||||
};
|
||||
|
|
|
@ -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;
|
||||
|
|
19
src/authentication/routes/CreateFlow.ts
Normal file
19
src/authentication/routes/CreateFlow.ts
Normal 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),
|
||||
});
|
22
src/authentication/routes/Flow.ts
Normal file
22
src/authentication/routes/Flow.ts
Normal 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),
|
||||
});
|
|
@ -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)
|
||||
? [
|
||||
|
|
|
@ -203,7 +203,7 @@ export const ClientScopeForm = () => {
|
|||
]
|
||||
: undefined
|
||||
}
|
||||
badge={clientScope ? clientScope.protocol : undefined}
|
||||
badges={[{ text: clientScope ? clientScope.protocol : undefined }]}
|
||||
divider={!id}
|
||||
/>
|
||||
|
||||
|
|
|
@ -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={[
|
||||
|
|
|
@ -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.",
|
||||
},
|
||||
};
|
||||
|
|
|
@ -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}}",
|
||||
},
|
||||
};
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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);
|
||||
};
|
||||
|
||||
|
|
|
@ -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:
|
||||
|
|
|
@ -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}
|
||||
|
|
|
@ -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 = () => {
|
||||
|
|
|
@ -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}}",
|
||||
};
|
||||
|
|
Loading…
Reference in a new issue