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"],
+ });
+}