Implement workflow view/copy/enable/disable.

Closes #43041

Signed-off-by: Stan Silvert <ssilvert@redhat.com>
This commit is contained in:
Stan Silvert
2025-10-09 10:48:44 -04:00
committed by Pedro Igor
parent aadffb94fb
commit 23f21c8232
9 changed files with 322 additions and 159 deletions

View File

@@ -3580,6 +3580,18 @@ 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.
# OID4VCI Credential Configuration
credentialConfigurationId=Credential Configuration ID
credentialConfigurationIdHelp=The unique identifier for this credential configuration. This ID is used in the credential issuer metadata and credential requests.
credentialIdentifier=Credential Identifier
credentialIdentifierHelp=A specific identifier for this credential type. This can be used to distinguish between different variants of the same credential type.
issuerDid=Issuer DID
issuerDidHelp=The Decentralized Identifier (DID) of the credential issuer. This identifies who is issuing the verifiable credentials.
credentialLifetime=Credential Lifetime (seconds)
credentialLifetimeHelp=The lifetime of the credential in seconds. After this time, the credential will expire and become invalid.
supportedFormats=Supported Formats
supportedFormatsHelp=The format of the verifiable credential. Currently supported formats: SD-JWT VC (dc+sd-jwt), JWT VC (jwt_vc_json).
# Workflows
workflows=Workflows
titleWorkflows=Workflows
workflowsExplain=Workflows empower administrators to automate the management of realm resources through time-based or event-based policies.
@@ -3595,14 +3607,11 @@ workflowDeleteConfirmDialog=This action will permanently delete the workflow. Th
workflowNameRequired=Workflow name is required.
workflowDeletedSuccess=The workflow has been deleted.
workflowDeleteError=Could not delete the workflow\: {{error}}
# OID4VCI Credential Configuration
credentialConfigurationId=Credential Configuration ID
credentialConfigurationIdHelp=The unique identifier for this credential configuration. This ID is used in the credential issuer metadata and credential requests.
credentialIdentifier=Credential Identifier
credentialIdentifierHelp=A specific identifier for this credential type. This can be used to distinguish between different variants of the same credential type.
issuerDid=Issuer DID
issuerDidHelp=The Decentralized Identifier (DID) of the credential issuer. This identifies who is issuing the verifiable credentials.
credentialLifetime=Credential Lifetime (seconds)
credentialLifetimeHelp=The lifetime of the credential in seconds. After this time, the credential will expire and become invalid.
supportedFormats=Supported Formats
supportedFormatsHelp=The format of the verifiable credential. Currently supported formats: SD-JWT VC (dc+sd-jwt), JWT VC (jwt_vc_json).
viewWorkflow=View workflow
copyWorkflow=Copy workflow
workflowDetails=Workflow details
viewWorkflowDetails=Workflows can not be edited except to change enabled/disabled. You can copy the workflow and edit the copy.
copyWorkflowDetails=You are about to create a new workflow based on an existing one. You can change the name and edit the JSON of the new workflow.
createWorkflowDetails=Create a new workflow by providing its JSON representation.
workflowEnabled=Workflow enabled
workflowDisabled=Workflow disabled

View File

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

View File

@@ -1,121 +0,0 @@
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,226 @@
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,
useFetch,
} 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";
import { useParams } from "../utils/useParams";
import {
WorkflowDetailParams,
toWorkflowDetail,
} from "./routes/WorkflowDetail";
import { ViewHeader } from "../components/view-header/ViewHeader";
type AttributeForm = {
workflowJSON?: string;
[key: string]: any;
};
export default function WorkflowDetailForm() {
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 { mode, id } = useParams<WorkflowDetailParams>();
const [workflowJSON, setWorkflowJSON] = useState("");
const [enabled, setEnabled] = useState(true);
useFetch(
async () => {
if (mode === "create") {
return undefined;
}
return adminClient.workflows.findOne({
id: id!,
});
},
(workflow) => {
if (!workflow) {
return;
}
if (mode === "copy") {
delete workflow.id;
workflow.name = `${workflow.name} -- ${t("copy")}`;
}
setWorkflowJSON(JSON.stringify(workflow, null, 2));
setEnabled(workflow?.enabled ?? true);
form.reset({
workflowJSON: JSON.stringify(workflow, null, 2),
});
},
[mode, id],
);
const onSubmit: SubmitHandler<AttributeForm> = async () => {
if (mode === "view") {
navigate(toWorkflowDetail({ realm, mode: "copy", id: id! }));
return;
}
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);
}
};
const toggleEnabled = async () => {
const json = JSON.parse(workflowJSON);
json.enabled = !enabled;
try {
const payload = {
realm,
...json,
};
await adminClient.workflows.update({ id: json.id }, payload);
setWorkflowJSON(JSON.stringify(json, null, 2));
setEnabled(!enabled);
form.reset({
workflowJSON: JSON.stringify(json, null, 2),
});
addAlert(
enabled ? t("workflowDisabled") : t("workflowEnabled"),
AlertVariant.success,
);
} catch (error) {
addError("workflowCreateError", error);
}
};
const titlekeyMap: Record<WorkflowDetailParams["mode"], string> = {
copy: "copyWorkflow",
create: "createWorkflow",
view: "viewWorkflow",
};
const subkeyMap: Record<WorkflowDetailParams["mode"], string> = {
copy: "copyWorkflowDetails",
create: "createWorkflowDetails",
view: "viewWorkflowDetails",
};
return (
<>
<ViewHeader
titleKey={titlekeyMap[mode]}
subKey={subkeyMap[mode]}
isEnabled={enabled}
onToggle={mode === "view" ? toggleEnabled : undefined}
/>
<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"
readOnly={mode === "view"}
value={field.value}
onChange={(value) => setWorkflowJSON(value ?? "")}
language="json"
height={600}
/>
)}
/>
</FormGroup>
<ActionGroup>
{mode !== "view" && (
<FormSubmitButton
formState={form.formState}
data-testid="save"
allowInvalid
allowNonDirty
>
{t("save")}
</FormSubmitButton>
)}
{mode === "view" && (
<FormSubmitButton
formState={form.formState}
data-testid="copy"
allowInvalid
allowNonDirty
>
{t("copy")}
</FormSubmitButton>
)}
<Button
data-testid="cancel"
variant="link"
component={(props) => (
<Link {...props} to={toWorkflows({ realm })} />
)}
>
{t("cancel")}
</Button>
</ActionGroup>
</FormAccess>
</PageSection>
</FormProvider>
</>
);
}

View File

@@ -19,8 +19,8 @@ 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";
import { toWorkflowDetail } from "./routes/WorkflowDetail";
export default function WorkflowsSection() {
const { adminClient } = useAdminClient();
@@ -85,7 +85,10 @@ export default function WorkflowsSection() {
<Button
data-testid="create-workflow"
component={(props) => (
<Link {...props} to={toAddWorkflow({ realm })} />
<Link
{...props}
to={toWorkflowDetail({ realm, mode: "create", id: "new" })}
/>
)}
>
{t("createWorkflow")}
@@ -95,6 +98,13 @@ export default function WorkflowsSection() {
{
name: "name",
displayKey: "name",
cellRenderer: (row: WorkflowRepresentation) => (
<Link
to={toWorkflowDetail({ realm, mode: "view", id: row.id! })}
>
{row.name}
</Link>
),
},
{
name: "id",
@@ -116,6 +126,15 @@ export default function WorkflowsSection() {
toggleDeleteDialog();
},
} as Action<WorkflowRepresentation>,
{
title: t("copy"),
onRowClick: (workflow) => {
setSelectedWorkflow(workflow);
navigate(
toWorkflowDetail({ realm, mode: "copy", id: workflow.id! }),
);
},
} as Action<WorkflowRepresentation>,
]}
loader={loader}
ariaLabelKey="workflows"
@@ -124,7 +143,9 @@ export default function WorkflowsSection() {
message={t("emptyWorkflows")}
instructions={t("emptyWorkflowsInstructions")}
primaryActionText={t("createWorkflow")}
onPrimaryAction={() => navigate(toAddWorkflow({ realm }))}
onPrimaryAction={() =>
navigate(toWorkflowDetail({ realm, mode: "create", id: "new" }))
}
/>
}
/>

View File

@@ -1,7 +1,7 @@
import type { AppRouteObject } from "../routes";
import { AddWorkflowRoute } from "./routes/AddWorkflow";
import { WorkflowsRoute } from "./routes/Workflows";
import { WorkflowDetailRoute } from "./routes/WorkflowDetail";
const routes: AppRouteObject[] = [WorkflowsRoute, AddWorkflowRoute];
const routes: AppRouteObject[] = [WorkflowsRoute, WorkflowDetailRoute];
export default routes;

View File

@@ -1,21 +0,0 @@
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,29 @@
import { lazy } from "react";
import type { Path } from "react-router-dom";
import { generateEncodedPath } from "../../utils/generateEncodedPath";
import type { AppRouteObject } from "../../routes";
export type WorkflowDetailParams = {
realm: string;
id: string;
mode: "view" | "copy" | "create";
};
const WorkflowDetailForm = lazy(() => import("../WorkflowDetailForm"));
export const WorkflowDetailRoute: AppRouteObject = {
path: "/:realm/workflows/:mode/:id",
element: <WorkflowDetailForm />,
breadcrumb: (t) => t("workflowDetails"),
handle: {
access: "anyone", // TODO: update access when view permission is added
},
};
export const toWorkflowDetail = (
params: WorkflowDetailParams,
): Partial<Path> => {
return {
pathname: generateEncodedPath(WorkflowDetailRoute.path, params),
};
};

View File

@@ -18,6 +18,26 @@ export class Workflows extends Resource<{ realm?: string }> {
path: "/",
});
public findOne = this.makeRequest<
{ id: string },
WorkflowRepresentation | undefined
>({
method: "GET",
path: "/{id}",
urlParamKeys: ["id"],
catchNotFound: true,
});
public update = this.makeUpdateRequest<
{ id: string },
WorkflowRepresentation,
void
>({
method: "PUT",
path: "/{id}",
urlParamKeys: ["id"],
});
public create = this.makeRequest<WorkflowRepresentation, { id: string }>({
method: "POST",
returnResourceIdInLocationHeader: { field: "id" },