Initial impl of workflows in admin UI

Closes #42834

Signed-off-by: Stan Silvert <ssilvert@redhat.com>
This commit is contained in:
Stan Silvert
2025-09-26 09:55:22 -04:00
committed by Pedro Igor
parent ab7daf7fac
commit f39735da06
14 changed files with 364 additions and 1 deletions

View File

@@ -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.

View File

@@ -145,6 +145,9 @@ export const PageNav = () => {
)}
<LeftNav title="identityProviders" path="/identity-providers" />
<LeftNav title="userFederation" path="/user-federation" />
{isFeatureEnabled(Feature.Workflows) && (
<LeftNav title="workflows" path="/workflows" />
)}
{isFeatureEnabled(Feature.DeclarativeUI) &&
pages?.map((p) => (
<LeftNav

View File

@@ -20,4 +20,5 @@ export default {
guides: `${keycloakHomepageURL}/guides`,
community: `${keycloakHomepageURL}/community`,
blog: `${keycloakHomepageURL}/blog`,
workflowsUrl: `https://github.com/keycloak/keycloak/issues/39888`,
};

View File

@@ -20,6 +20,7 @@ import realmRoutes from "./realm/routes";
import sessionRoutes from "./sessions/routes";
import userFederationRoutes from "./user-federation/routes";
import userRoutes from "./user/routes";
import workflowRoutes from "./workflows/routes";
export type AppRouteObjectHandle = {
access: AccessType | AccessType[];
@@ -47,6 +48,7 @@ export const routes: AppRouteObject[] = [
...identityProviders,
...organizationRoutes,
...realmRoleRoutes,
...workflowRoutes,
...realmRoutes,
...realmSettingRoutes,
...sessionRoutes,

View File

@@ -18,6 +18,7 @@ export enum Feature {
StandardTokenExchangeV2 = "TOKEN_EXCHANGE_STANDARD_V2",
Passkeys = "PASSKEYS",
ClientAuthFederated = "CLIENT_AUTH_FEDERATED",
Workflows = "WORKFLOWS",
}
export default function useIsFeatureEnabled() {

View File

@@ -0,0 +1,121 @@
import {
ActionGroup,
Button,
FormGroup,
PageSection,
} from "@patternfly/react-core";
import { AlertVariant } from "@patternfly/react-core";
import { useState } from "react";
import {
Controller,
FormProvider,
SubmitHandler,
useForm,
} from "react-hook-form";
import { useTranslation } from "react-i18next";
import { Link, useNavigate } from "react-router-dom";
import { useAdminClient } from "../admin-client";
import {
HelpItem,
FormSubmitButton,
useAlerts,
} from "@keycloak/keycloak-ui-shared";
import { useRealm } from "../context/realm-context/RealmContext";
import { FormAccess } from "../components/form/FormAccess";
import { toWorkflows } from "./routes/Workflows";
import CodeEditor from "../components/form/CodeEditor";
type AttributeForm = {
workflowJSON?: string;
[key: string]: any;
};
export default function CreateWorkflow() {
const { adminClient } = useAdminClient();
const { t } = useTranslation();
const form = useForm<AttributeForm>({ mode: "onChange" });
const { control, handleSubmit } = form;
const navigate = useNavigate();
const { realm } = useRealm();
const { addAlert, addError } = useAlerts();
const [workflowJSON, setWorkflowJSON] = useState("");
const onSubmit: SubmitHandler<AttributeForm> = 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 (
<FormProvider {...form}>
<PageSection variant="light">
<FormAccess
isHorizontal
onSubmit={handleSubmit(onSubmit)}
role={"manage-realm"}
className="pf-v5-u-mt-lg"
fineGrainedAccess={true} // TODO: Set this properly
>
<FormGroup
label={t("workflowJSON")}
labelIcon={
<HelpItem helpText={t("workflowJsonHelp")} fieldLabelId="code" />
}
fieldId="code"
isRequired
>
<Controller
name="workflowJSON"
defaultValue=""
control={control}
render={({ field }) => (
<CodeEditor
id="workflowJSON"
data-testid="workflowJSON"
value={field.value}
onChange={(value) => setWorkflowJSON(value ?? "")}
language="json"
height={600}
/>
)}
/>
</FormGroup>
<ActionGroup>
<FormSubmitButton
formState={form.formState}
data-testid="save"
allowInvalid
allowNonDirty
>
{t("save")}
</FormSubmitButton>
<Button
data-testid="cancel"
variant="link"
component={(props) => (
<Link {...props} to={toWorkflows({ realm })} />
)}
>
{t("cancel")}
</Button>
</ActionGroup>
</FormAccess>
</PageSection>
</FormProvider>
);
}

View File

@@ -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<WorkflowRepresentation>();
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 (
<>
<ViewHeader
titleKey="titleWorkflows"
subKey="workflowsExplain"
helpUrl={helpUrls.workflowsUrl}
/>
<PageSection variant="light" padding={{ default: "noPadding" }}>
<DeleteConfirm />
<KeycloakDataTable
key={key}
toolbarItem={
<Button
data-testid="create-workflow"
component={(props) => (
<Link {...props} to={toAddWorkflow({ realm })} />
)}
>
{t("createWorkflow")}
</Button>
}
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<WorkflowRepresentation>,
]}
loader={loader}
ariaLabelKey="workflows"
emptyState={
<ListEmptyState
message={t("emptyWorkflows")}
instructions={t("emptyWorkflowsInstructions")}
primaryActionText={t("createWorkflow")}
onPrimaryAction={() => navigate(toAddWorkflow({ realm }))}
/>
}
/>
</PageSection>
</>
);
}

View File

@@ -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;

View File

@@ -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: <CreateWorkflow />,
breadcrumb: (t) => t("createWorkflow"),
handle: {
access: "manage-realm",
},
};
export const toAddWorkflow = (params: AddWorkflowParams): Partial<Path> => ({
pathname: generateEncodedPath(AddWorkflowRoute.path, params),
});

View File

@@ -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: <WorkflowsSection />,
breadcrumb: (t) => t("workflows"),
handle: {
access: "view-realm",
},
};
export const toWorkflows = (params: WorkflowsParams): Partial<Path> => ({
pathname: generateEncodedPath(WorkflowsRoute.path, params),
});

View File

@@ -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,
],
{

View File

@@ -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);

View File

@@ -0,0 +1,5 @@
export default interface WorkflowRepresentation {
id?: string;
name?: string;
enabled?: boolean;
}

View File

@@ -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<WorkflowRepresentation, { id: string }>({
method: "POST",
returnResourceIdInLocationHeader: { field: "id" },
});
public delById = this.makeRequest<{ id: string }, void>({
method: "DELETE",
path: "/{id}",
urlParamKeys: ["id"],
});
}