mirror of
https://github.com/keycloak/keycloak.git
synced 2026-01-25 16:42:34 +00:00
Initial impl of workflows in admin UI
Closes #42834 Signed-off-by: Stan Silvert <ssilvert@redhat.com>
This commit is contained in:
@@ -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.
|
||||
@@ -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
|
||||
|
||||
@@ -20,4 +20,5 @@ export default {
|
||||
guides: `${keycloakHomepageURL}/guides`,
|
||||
community: `${keycloakHomepageURL}/community`,
|
||||
blog: `${keycloakHomepageURL}/blog`,
|
||||
workflowsUrl: `https://github.com/keycloak/keycloak/issues/39888`,
|
||||
};
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -18,6 +18,7 @@ export enum Feature {
|
||||
StandardTokenExchangeV2 = "TOKEN_EXCHANGE_STANDARD_V2",
|
||||
Passkeys = "PASSKEYS",
|
||||
ClientAuthFederated = "CLIENT_AUTH_FEDERATED",
|
||||
Workflows = "WORKFLOWS",
|
||||
}
|
||||
|
||||
export default function useIsFeatureEnabled() {
|
||||
|
||||
121
js/apps/admin-ui/src/workflows/CreateWorkflow.tsx
Normal file
121
js/apps/admin-ui/src/workflows/CreateWorkflow.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
134
js/apps/admin-ui/src/workflows/WorkflowsSection.tsx
Normal file
134
js/apps/admin-ui/src/workflows/WorkflowsSection.tsx
Normal 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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
7
js/apps/admin-ui/src/workflows/routes.ts
Normal file
7
js/apps/admin-ui/src/workflows/routes.ts
Normal 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;
|
||||
21
js/apps/admin-ui/src/workflows/routes/AddWorkflow.tsx
Normal file
21
js/apps/admin-ui/src/workflows/routes/AddWorkflow.tsx
Normal 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),
|
||||
});
|
||||
21
js/apps/admin-ui/src/workflows/routes/Workflows.tsx
Normal file
21
js/apps/admin-ui/src/workflows/routes/Workflows.tsx
Normal 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),
|
||||
});
|
||||
@@ -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,
|
||||
],
|
||||
{
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
export default interface WorkflowRepresentation {
|
||||
id?: string;
|
||||
name?: string;
|
||||
enabled?: boolean;
|
||||
}
|
||||
31
js/libs/keycloak-admin-client/src/resources/workflows.ts
Normal file
31
js/libs/keycloak-admin-client/src/resources/workflows.ts
Normal 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"],
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user