mirror of
https://github.com/keycloak/keycloak.git
synced 2025-12-20 05:50:08 -06:00
Implement workflow view/copy/enable/disable.
Closes #43041 Signed-off-by: Stan Silvert <ssilvert@redhat.com>
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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`,
|
||||
};
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
226
js/apps/admin-ui/src/workflows/WorkflowDetailForm.tsx
Normal file
226
js/apps/admin-ui/src/workflows/WorkflowDetailForm.tsx
Normal 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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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" }))
|
||||
}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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),
|
||||
});
|
||||
29
js/apps/admin-ui/src/workflows/routes/WorkflowDetail.tsx
Normal file
29
js/apps/admin-ui/src/workflows/routes/WorkflowDetail.tsx
Normal 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),
|
||||
};
|
||||
};
|
||||
@@ -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" },
|
||||
|
||||
Reference in New Issue
Block a user