From f39735da065f1b507d95ef720cba4dd4e58e7e06 Mon Sep 17 00:00:00 2001 From: Stan Silvert Date: Fri, 26 Sep 2025 09:55:22 -0400 Subject: [PATCH] Initial impl of workflows in admin UI Closes #42834 Signed-off-by: Stan Silvert --- .../admin/messages/messages_en.properties | 13 ++ js/apps/admin-ui/src/PageNav.tsx | 3 + js/apps/admin-ui/src/help-urls.ts | 1 + js/apps/admin-ui/src/routes.tsx | 2 + .../admin-ui/src/utils/useIsFeatureEnabled.ts | 1 + .../admin-ui/src/workflows/CreateWorkflow.tsx | 121 ++++++++++++++++ .../src/workflows/WorkflowsSection.tsx | 134 ++++++++++++++++++ js/apps/admin-ui/src/workflows/routes.ts | 7 + .../src/workflows/routes/AddWorkflow.tsx | 21 +++ .../src/workflows/routes/Workflows.tsx | 21 +++ .../keycloak-server/scripts/start-server.js | 2 +- js/libs/keycloak-admin-client/src/client.ts | 3 + .../src/defs/workflowRepresentation.ts | 5 + .../src/resources/workflows.ts | 31 ++++ 14 files changed, 364 insertions(+), 1 deletion(-) create mode 100644 js/apps/admin-ui/src/workflows/CreateWorkflow.tsx create mode 100644 js/apps/admin-ui/src/workflows/WorkflowsSection.tsx create mode 100644 js/apps/admin-ui/src/workflows/routes.ts create mode 100644 js/apps/admin-ui/src/workflows/routes/AddWorkflow.tsx create mode 100644 js/apps/admin-ui/src/workflows/routes/Workflows.tsx create mode 100644 js/libs/keycloak-admin-client/src/defs/workflowRepresentation.ts create mode 100644 js/libs/keycloak-admin-client/src/resources/workflows.ts diff --git a/js/apps/admin-ui/maven-resources/theme/keycloak.v2/admin/messages/messages_en.properties b/js/apps/admin-ui/maven-resources/theme/keycloak.v2/admin/messages/messages_en.properties index 1fcbfa88974..1cc0df55f15 100644 --- a/js/apps/admin-ui/maven-resources/theme/keycloak.v2/admin/messages/messages_en.properties +++ b/js/apps/admin-ui/maven-resources/theme/keycloak.v2/admin/messages/messages_en.properties @@ -3567,3 +3567,16 @@ oid4vciNonceLifetimeHelp=The lifetime of the OID4VCI nonce in seconds. preAuthorizedCodeLifespan=Pre-Authorized Code Lifespan preAuthorizedCodeLifespanHelp=The lifespan of the pre-authorized code in seconds. oid4vciFormValidationError=Please ensure the OID4VCI attribute fields are filled with values 30 seconds or greater. +workflows=Workflows +titleWorkflows=Workflows +workflowsExplain=Workflows empower administrators to automate the management of realm resources through time-based or event-based policies. +createWorkflow=Create workflow +workflowJSON=Workflow JSON +workflowJsonHelp=The JSON representation of the workflow. +emptyWorkflows=No workflows +emptyWorkflowsInstructions=There are no workflows in this realm. Please create a workflow to get started. +workflowCreated=The workflow has been created. +workflowCreateError=Could not create the workflow\: {{error}} +workflowDeleteConfirm=Delete workflow? +workflowDeleteConfirmDialog=This action will permanently delete the workflow. This cannot be undone. +workflowNameRequired=Workflow name is required. \ No newline at end of file diff --git a/js/apps/admin-ui/src/PageNav.tsx b/js/apps/admin-ui/src/PageNav.tsx index ba783f273a1..365ed8885ff 100644 --- a/js/apps/admin-ui/src/PageNav.tsx +++ b/js/apps/admin-ui/src/PageNav.tsx @@ -145,6 +145,9 @@ export const PageNav = () => { )} + {isFeatureEnabled(Feature.Workflows) && ( + + )} {isFeatureEnabled(Feature.DeclarativeUI) && pages?.map((p) => ( ({ mode: "onChange" }); + const { control, handleSubmit } = form; + const navigate = useNavigate(); + const { realm } = useRealm(); + const { addAlert, addError } = useAlerts(); + const [workflowJSON, setWorkflowJSON] = useState(""); + + const onSubmit: SubmitHandler = async () => { + try { + const json = JSON.parse(workflowJSON); + if (!json.name) { + throw new Error(t("workflowNameRequired")); + } + + const payload = { + realm, + ...json, + }; + await adminClient.workflows.create(payload); + + addAlert(t("workflowCreated"), AlertVariant.success); + navigate(toWorkflows({ realm })); + } catch (error) { + addError("workflowCreateError", error); + } + }; + + return ( + + + + + } + fieldId="code" + isRequired + > + ( + setWorkflowJSON(value ?? "")} + language="json" + height={600} + /> + )} + /> + + + + {t("save")} + + + + + + + ); +} diff --git a/js/apps/admin-ui/src/workflows/WorkflowsSection.tsx b/js/apps/admin-ui/src/workflows/WorkflowsSection.tsx new file mode 100644 index 00000000000..26390c72a37 --- /dev/null +++ b/js/apps/admin-ui/src/workflows/WorkflowsSection.tsx @@ -0,0 +1,134 @@ +import { + AlertVariant, + Button, + ButtonVariant, + PageSection, +} from "@patternfly/react-core"; +import { + Action, + KeycloakDataTable, + ListEmptyState, + useAlerts, +} from "@keycloak/keycloak-ui-shared"; +import WorkflowRepresentation from "@keycloak/keycloak-admin-client/lib/defs/workflowRepresentation"; +import { useState } from "react"; +import { useTranslation } from "react-i18next"; +import { Link, useNavigate } from "react-router-dom"; +import { useAdminClient } from "../admin-client"; +import { ViewHeader } from "../components/view-header/ViewHeader"; +//import { useAccess } from "../context/access/Access"; +import { useRealm } from "../context/realm-context/RealmContext"; +import helpUrls from "../help-urls"; +import { toAddWorkflow } from "./routes/AddWorkflow"; +import { useConfirmDialog } from "../components/confirm-dialog/ConfirmDialog"; + +export default function WorkflowsSection() { + const { adminClient } = useAdminClient(); + + const { realm } = useRealm(); + const { t } = useTranslation(); + const navigate = useNavigate(); + const { addAlert, addError } = useAlerts(); + + // TODO: handle role-based access + //const { hasAccess } = useAccess(); + //const isManager = hasAccess("manage-realm"); + + const [key, setKey] = useState(0); + const refresh = () => setKey(key + 1); + + const [selectedWorkflow, setSelectedWorkflow] = + useState(); + + const loader = async () => { + const workflows = await adminClient.workflows.find(); + return workflows.sort( + (a: WorkflowRepresentation, b: WorkflowRepresentation) => { + const nameA = a.name ?? ""; + const nameB = b.name ?? ""; + return nameA.localeCompare(nameB); + }, + ); + }; + + const [toggleDeleteDialog, DeleteConfirm] = useConfirmDialog({ + titleKey: "workflowDeleteConfirm", + messageKey: t("workflowDeleteConfirmDialog", { + selectedRoleName: selectedWorkflow ? selectedWorkflow!.name : "", + }), + continueButtonLabel: "delete", + continueButtonVariant: ButtonVariant.danger, + onConfirm: async () => { + try { + await adminClient.workflows.delById({ id: selectedWorkflow!.id! }); + setSelectedWorkflow(undefined); + addAlert(t("roleDeletedSuccess"), AlertVariant.success); + refresh(); + } catch (error) { + addError("roleDeleteError", error); + } + }, + }); + + return ( + <> + + + + ( + + )} + > + {t("createWorkflow")} + + } + columns={[ + { + name: "name", + displayKey: "name", + }, + { + name: "id", + displayKey: "id", + }, + { + name: "enabled", + displayKey: "enabled", + cellRenderer: (row: WorkflowRepresentation) => { + return (row.enabled ?? true) ? t("enabled") : t("disabled"); + }, + }, + ]} + actions={[ + { + title: t("delete"), + onRowClick: (workflow) => { + setSelectedWorkflow(workflow); + toggleDeleteDialog(); + }, + } as Action, + ]} + loader={loader} + ariaLabelKey="workflows" + emptyState={ + navigate(toAddWorkflow({ realm }))} + /> + } + /> + + + ); +} diff --git a/js/apps/admin-ui/src/workflows/routes.ts b/js/apps/admin-ui/src/workflows/routes.ts new file mode 100644 index 00000000000..59be839e11a --- /dev/null +++ b/js/apps/admin-ui/src/workflows/routes.ts @@ -0,0 +1,7 @@ +import type { AppRouteObject } from "../routes"; +import { AddWorkflowRoute } from "./routes/AddWorkflow"; +import { WorkflowsRoute } from "./routes/Workflows"; + +const routes: AppRouteObject[] = [WorkflowsRoute, AddWorkflowRoute]; + +export default routes; diff --git a/js/apps/admin-ui/src/workflows/routes/AddWorkflow.tsx b/js/apps/admin-ui/src/workflows/routes/AddWorkflow.tsx new file mode 100644 index 00000000000..a47ceb09c04 --- /dev/null +++ b/js/apps/admin-ui/src/workflows/routes/AddWorkflow.tsx @@ -0,0 +1,21 @@ +import { lazy } from "react"; +import type { Path } from "react-router-dom"; +import { generateEncodedPath } from "../../utils/generateEncodedPath"; +import type { AppRouteObject } from "../../routes"; + +export type AddWorkflowParams = { realm: string }; + +const CreateWorkflow = lazy(() => import("../CreateWorkflow")); + +export const AddWorkflowRoute: AppRouteObject = { + path: "/:realm/workflows/new", + element: , + breadcrumb: (t) => t("createWorkflow"), + handle: { + access: "manage-realm", + }, +}; + +export const toAddWorkflow = (params: AddWorkflowParams): Partial => ({ + pathname: generateEncodedPath(AddWorkflowRoute.path, params), +}); diff --git a/js/apps/admin-ui/src/workflows/routes/Workflows.tsx b/js/apps/admin-ui/src/workflows/routes/Workflows.tsx new file mode 100644 index 00000000000..a0cff5a8634 --- /dev/null +++ b/js/apps/admin-ui/src/workflows/routes/Workflows.tsx @@ -0,0 +1,21 @@ +import { lazy } from "react"; +import type { Path } from "react-router-dom"; +import { generateEncodedPath } from "../../utils/generateEncodedPath"; +import type { AppRouteObject } from "../../routes"; + +export type WorkflowsParams = { realm: string }; + +const WorkflowsSection = lazy(() => import("../WorkflowsSection")); + +export const WorkflowsRoute: AppRouteObject = { + path: "/:realm/workflows", + element: , + breadcrumb: (t) => t("workflows"), + handle: { + access: "view-realm", + }, +}; + +export const toWorkflows = (params: WorkflowsParams): Partial => ({ + pathname: generateEncodedPath(WorkflowsRoute.path, params), +}); diff --git a/js/apps/keycloak-server/scripts/start-server.js b/js/apps/keycloak-server/scripts/start-server.js index 41331d7cb69..8b886d05bac 100755 --- a/js/apps/keycloak-server/scripts/start-server.js +++ b/js/apps/keycloak-server/scripts/start-server.js @@ -60,7 +60,7 @@ async function startServer() { path.join(SERVER_DIR, `bin/kc${SCRIPT_EXTENSION}`), [ "start-dev", - `--features="login:v2,account:v3,admin-fine-grained-authz:v2,transient-users,oid4vc-vci,organization,declarative-ui,quick-theme,spiffe,kubernetes-service-accounts"`, + `--features="login:v2,account:v3,admin-fine-grained-authz:v2,transient-users,oid4vc-vci,organization,declarative-ui,quick-theme,spiffe,kubernetes-service-accounts,workflows"`, ...keycloakArgs, ], { diff --git a/js/libs/keycloak-admin-client/src/client.ts b/js/libs/keycloak-admin-client/src/client.ts index 385efd2fcfe..f9ef32e2db7 100644 --- a/js/libs/keycloak-admin-client/src/client.ts +++ b/js/libs/keycloak-admin-client/src/client.ts @@ -10,6 +10,7 @@ import { Groups } from "./resources/groups.js"; import { IdentityProviders } from "./resources/identityProviders.js"; import { Realms } from "./resources/realms.js"; import { Organizations } from "./resources/organizations.js"; +import { Workflows } from "./resources/workflows.js"; import { Roles } from "./resources/roles.js"; import { ServerInfo } from "./resources/serverInfo.js"; import { Users } from "./resources/users.js"; @@ -36,6 +37,7 @@ export class KeycloakAdminClient { public groups: Groups; public roles: Roles; public organizations: Organizations; + public workflows: Workflows; public clients: Clients; public realms: Realms; public clientScopes: ClientScopes; @@ -71,6 +73,7 @@ export class KeycloakAdminClient { this.groups = new Groups(this); this.roles = new Roles(this); this.organizations = new Organizations(this); + this.workflows = new Workflows(this); this.clients = new Clients(this); this.realms = new Realms(this); this.clientScopes = new ClientScopes(this); diff --git a/js/libs/keycloak-admin-client/src/defs/workflowRepresentation.ts b/js/libs/keycloak-admin-client/src/defs/workflowRepresentation.ts new file mode 100644 index 00000000000..d76d50c6d14 --- /dev/null +++ b/js/libs/keycloak-admin-client/src/defs/workflowRepresentation.ts @@ -0,0 +1,5 @@ +export default interface WorkflowRepresentation { + id?: string; + name?: string; + enabled?: boolean; +} diff --git a/js/libs/keycloak-admin-client/src/resources/workflows.ts b/js/libs/keycloak-admin-client/src/resources/workflows.ts new file mode 100644 index 00000000000..eff90b0026c --- /dev/null +++ b/js/libs/keycloak-admin-client/src/resources/workflows.ts @@ -0,0 +1,31 @@ +import Resource from "./resource.js"; +import type { KeycloakAdminClient } from "../client.js"; +import WorkflowRepresentation from "../defs/workflowRepresentation.js"; + +export class Workflows extends Resource<{ realm?: string }> { + constructor(client: KeycloakAdminClient) { + super(client, { + path: "/admin/realms/{realm}/workflows", + getUrlParams: () => ({ + realm: client.realmName, + }), + getBaseUrl: () => client.baseUrl, + }); + } + + find = this.makeRequest({ + method: "GET", + path: "/", + }); + + public create = this.makeRequest({ + method: "POST", + returnResourceIdInLocationHeader: { field: "id" }, + }); + + public delById = this.makeRequest<{ id: string }, void>({ + method: "DELETE", + path: "/{id}", + urlParamKeys: ["id"], + }); +}