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 c56703517a8..b5a93cb8501 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
@@ -3332,4 +3332,5 @@ UNMANAGED=Unmanaged
deleteConfirmUsers_one=Delete user {{name}}?
deleteConfirmUsers_other=Delete {{count}} users?
downloadThemeJar=Download theme JAR
-themeColorInfo=Here you can set the patternfly color variables and create a "theme jar" file that you can download and put in your providers folder to apply the theme to your realm.
\ No newline at end of file
+themeColorInfo=Here you can set the patternfly color variables and create a "theme jar" file that you can download and put in your providers folder to apply the theme to your realm.
+permissionsSubTitle=Fine-grained admin permissions allow assigning detailed, specific access rights, controlling which resources and actions can be managed.
diff --git a/js/apps/admin-ui/src/PageNav.tsx b/js/apps/admin-ui/src/PageNav.tsx
index 279f422cc3e..738a5643324 100644
--- a/js/apps/admin-ui/src/PageNav.tsx
+++ b/js/apps/admin-ui/src/PageNav.tsx
@@ -128,6 +128,10 @@ export const PageNav = () => {
+ {isFeatureEnabled(Feature.AdminFineGrainedAuthzV2) &&
+ realmRepresentation?.adminPermissionsEnabled && (
+
+ )}
{isFeatureEnabled(Feature.DeclarativeUI) &&
pages?.map((p) => (
void;
@@ -213,6 +214,8 @@ export default function ClientDetails() {
const { clientId } = useParams();
const [key, setKey] = useState(0);
+ const isAdminPermissionsClient = useIsAdminPermissionsClient(clientId);
+
const clientAuthenticatorType = useWatch({
control: form.control,
name: "clientAuthenticatorType",
@@ -547,6 +550,7 @@ export default function ClientDetails() {
)}
{client!.authorizationServicesEnabled &&
+ !isAdminPermissionsClient &&
(hasManageAuthorization || hasViewAuthorization) && (
();
+ const [changeAuthenticatorOpen, toggleChangeAuthenticatorOpen] = useToggle();
+ const form = useForm();
+
+ const usePermissionsTabs = (tab: PermissionsTabs) =>
+ useRoutableTab(
+ toPermissionsTabs({
+ realm,
+ tab,
+ }),
+ );
+
+ const clientAuthenticatorType = useWatch({
+ control: form.control,
+ name: "clientAuthenticatorType",
+ defaultValue: "client-secret",
+ });
+
+ const hasManageAuthorization = hasAccess("manage-authorization");
+ const hasViewUsers = hasAccess("view-users");
+ const permissionsResourcesTab = usePermissionsTabs("resources");
+ const permissionsPoliciesTab = usePermissionsTabs("policies");
+ const permissionsEvaluateTab = usePermissionsTabs("evaluate");
+
+ useFetch(
+ async () => {
+ const clients = await adminClient.clients.find();
+ return clients;
+ },
+ (clients) => {
+ const adminPermissionsClient = clients.find(
+ (client) => client.clientId === "admin-permissions",
+ );
+ setAdminPermissionsClient(adminPermissionsClient!);
+ },
+ [],
+ );
+
+ const setupForm = (client: ClientRepresentation) => {
+ form.reset({ ...client });
+ convertToFormValues(client, form.setValue);
+ if (client.attributes?.["acr.loa.map"]) {
+ form.setValue(
+ convertAttributeNameToForm("attributes.acr.loa.map"),
+ // @ts-ignore
+ Object.entries(JSON.parse(client.attributes["acr.loa.map"])).flatMap(
+ ([key, value]) => ({ key, value }),
+ ),
+ );
+ }
+ };
+
+ const save = async (
+ { confirmed = false, messageKey = "clientSaveSuccess" }: SaveOptions = {
+ confirmed: false,
+ messageKey: "clientSaveSuccess",
+ },
+ ) => {
+ if (!(await form.trigger())) {
+ return;
+ }
+
+ if (
+ !adminPermissionsClient?.publicClient &&
+ adminPermissionsClient?.clientAuthenticatorType !==
+ clientAuthenticatorType &&
+ !confirmed
+ ) {
+ toggleChangeAuthenticatorOpen();
+ return;
+ }
+
+ const values = convertFormValuesToObject(form.getValues());
+
+ const submittedClient =
+ convertFormValuesToObject(values);
+
+ if (submittedClient.attributes?.["acr.loa.map"]) {
+ submittedClient.attributes["acr.loa.map"] = JSON.stringify(
+ Object.fromEntries(
+ (submittedClient.attributes["acr.loa.map"] as KeyValueType[])
+ .filter(({ key }) => key !== "")
+ .map(({ key, value }) => [key, value]),
+ ),
+ );
+ }
+
+ try {
+ const newClient: ClientRepresentation = {
+ ...adminPermissionsClient,
+ ...submittedClient,
+ };
+
+ newClient.clientId = newClient.clientId?.trim();
+
+ await adminClient.clients.update(
+ { id: adminPermissionsClient!.clientId! },
+ newClient,
+ );
+ setupForm(newClient);
+ setAdminPermissionsClient(newClient);
+ addAlert(t(messageKey), AlertVariant.success);
+ } catch (error) {
+ addError("clientSaveError", error);
+ }
+ };
+
+ return (
+ adminPermissionsClient && (
+ <>
+ save({ confirmed: true })}
+ >
+ <>
+ {t("changeAuthenticatorConfirm", {
+ clientAuthenticatorType: clientAuthenticatorType,
+ })}
+ >
+
+
+
+
+
+ {t("resources")}}
+ {...permissionsResourcesTab}
+ >
+
+
+ {t("policies")}}
+ {...permissionsPoliciesTab}
+ >
+
+
+ {hasViewUsers && (
+ {t("evaluate")}}
+ {...permissionsEvaluateTab}
+ >
+
+
+ )}
+
+
+
+ >
+ )
+ );
+}
diff --git a/js/apps/admin-ui/src/permissions/routes.ts b/js/apps/admin-ui/src/permissions/routes.ts
new file mode 100644
index 00000000000..4c713caff37
--- /dev/null
+++ b/js/apps/admin-ui/src/permissions/routes.ts
@@ -0,0 +1,7 @@
+import type { AppRouteObject } from "../routes";
+import { PermissionsRoute } from "./routes/Permissions";
+import { PermissionsTabsRoute } from "./routes/PermissionsTabs";
+
+const routes: AppRouteObject[] = [PermissionsRoute, PermissionsTabsRoute];
+
+export default routes;
diff --git a/js/apps/admin-ui/src/permissions/routes/Permissions.tsx b/js/apps/admin-ui/src/permissions/routes/Permissions.tsx
new file mode 100644
index 00000000000..67af3b624ff
--- /dev/null
+++ b/js/apps/admin-ui/src/permissions/routes/Permissions.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 PermissionsParams = { realm: string };
+
+const PermissionsSection = lazy(() => import("../PermissionsSection"));
+
+export const PermissionsRoute: AppRouteObject = {
+ path: "/:realm/permissions",
+ element: ,
+ breadcrumb: (t) => t("titlePermissions"),
+ handle: {
+ access: ["view-realm", "view-clients", "view-users"],
+ },
+};
+
+export const toPermissions = (params: PermissionsParams): Partial => ({
+ pathname: generateEncodedPath(PermissionsRoute.path, params),
+});
diff --git a/js/apps/admin-ui/src/permissions/routes/PermissionsTabs.tsx b/js/apps/admin-ui/src/permissions/routes/PermissionsTabs.tsx
new file mode 100644
index 00000000000..ac1c0aabff3
--- /dev/null
+++ b/js/apps/admin-ui/src/permissions/routes/PermissionsTabs.tsx
@@ -0,0 +1,28 @@
+import { lazy } from "react";
+import type { Path } from "react-router-dom";
+import { generateEncodedPath } from "../../utils/generateEncodedPath";
+import type { AppRouteObject } from "../../routes";
+
+export type PermissionsTabs = "resources" | "policies" | "evaluate";
+
+export type PermissionsTabsParams = {
+ realm: string;
+ tab: PermissionsTabs;
+};
+
+const PermissionsSection = lazy(() => import("../PermissionsSection"));
+
+export const PermissionsTabsRoute: AppRouteObject = {
+ path: "/:realm/permissions/:tab",
+ element: ,
+ handle: {
+ access: (accessChecker) =>
+ accessChecker.hasAny("view-realm", "view-clients", "view-users"),
+ },
+};
+
+export const toPermissionsTabs = (
+ params: PermissionsTabsParams,
+): Partial => ({
+ pathname: generateEncodedPath(PermissionsTabsRoute.path, params),
+});
diff --git a/js/apps/admin-ui/src/routes.tsx b/js/apps/admin-ui/src/routes.tsx
index 74d4d6f7125..bf8763cd793 100644
--- a/js/apps/admin-ui/src/routes.tsx
+++ b/js/apps/admin-ui/src/routes.tsx
@@ -19,6 +19,7 @@ import realmRoutes from "./realm/routes";
import sessionRoutes from "./sessions/routes";
import userFederationRoutes from "./user-federation/routes";
import userRoutes from "./user/routes";
+import permissionsRoute from "./permissions/routes";
export type AppRouteObjectHandle = {
access: AccessType | AccessType[];
@@ -50,6 +51,7 @@ export const routes: AppRouteObject[] = [
...realmSettingRoutes,
...sessionRoutes,
...userFederationRoutes,
+ ...permissionsRoute,
...userRoutes,
...groupsRoutes,
...dashboardRoutes,
diff --git a/js/apps/admin-ui/src/utils/useIsAdminPermissionsClient.ts b/js/apps/admin-ui/src/utils/useIsAdminPermissionsClient.ts
new file mode 100644
index 00000000000..31095924876
--- /dev/null
+++ b/js/apps/admin-ui/src/utils/useIsAdminPermissionsClient.ts
@@ -0,0 +1,29 @@
+import { useState } from "react";
+import { useFetch } from "@keycloak/keycloak-ui-shared";
+import { useAdminClient } from "../admin-client";
+import ClientRepresentation from "@keycloak/keycloak-admin-client/lib/defs/clientRepresentation";
+
+export function useIsAdminPermissionsClient(selectedClientId: string) {
+ const { adminClient } = useAdminClient();
+ const [isAdminPermissionsClient, setIsAdminPermissionsClient] =
+ useState(false);
+
+ useFetch(
+ async () => {
+ const clients: ClientRepresentation[] = await adminClient.clients.find();
+ return clients;
+ },
+ (clients: ClientRepresentation[]) => {
+ const adminPermissionsClient = clients.find(
+ (client) => client.clientId === "admin-permissions",
+ );
+
+ setIsAdminPermissionsClient(
+ selectedClientId === adminPermissionsClient?.id,
+ );
+ },
+ [],
+ );
+
+ return isAdminPermissionsClient;
+}
diff --git a/js/libs/keycloak-admin-client/src/defs/realmRepresentation.ts b/js/libs/keycloak-admin-client/src/defs/realmRepresentation.ts
index 93d87bdc867..4600a3d0ccc 100644
--- a/js/libs/keycloak-admin-client/src/defs/realmRepresentation.ts
+++ b/js/libs/keycloak-admin-client/src/defs/realmRepresentation.ts
@@ -24,6 +24,7 @@ export default interface RealmRepresentation {
actionTokenGeneratedByUserLifespan?: number;
adminEventsDetailsEnabled?: boolean;
adminEventsEnabled?: boolean;
+ adminPermissionsEnabled?: boolean;
adminTheme?: string;
attributes?: Record;
// AuthenticationFlowRepresentation