8000 added optional storage type for the declarative ui by edewit · Pull Request #47859 · keycloak/keycloak · GitHub
[go: up one dir, main page]

Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
26 commits
Select commit Hold shift + click to select a range
7483156
added optional storage type for the declarative ui
edewit Apr 8, 2026
a28c916
pr review comments
edewit Jul 10, 2026
8c7f74b
PR review comments
edewit Jul 13, 2026
f6ca1fd
removed group as it's impossible to add custom tab on the group section
edewit Jul 16, 2026
b8165f5
split off custom and add dependencies
edewit Jul 16, 2026
08a4267
remove group again
edewit Jul 16, 2026
43606da
Address declarative UI storage review feedback
edewit Sep 4, 2026
b752853
Handle cleared fields and IdentityProviderMultiList storage
edewit Sep 4, 2026
8a498f2
Fix TypeScript build for entity storage updates
edewit Sep 4, 2026
69de334
Complete declarative UI extension phases 2-6
edewit Sep 7, 2026
1062353
Address Copilot review feedback for declarative UI extensions
edewit Sep 7, 2026
1fe31b9
Address second round of Copilot review feedback
edewit Sep 8, 2026
e5f85a8
Address third round of Copilot review feedback
edewit Sep 8, 2026
68002b7
Add integration test for ComponentStorageFactory delegation
edewit Sep 8, 2026
8c4001e
Fix Spotless import ordering violations
edewit Sep 8, 2026
7b2f939
Address remaining Copilot review feedback for custom storage
edewit Sep 11, 2026
6510d75
Fix component API auth ordering and custom storage edge cases
edewit Sep 11, 2026
540b7c7
Fix stale component loading and tighten custom storage authorization
edewit Sep 14, 2026
b61b3ea
Fix duplicate components in type-wide custom storage queries
edewit Sep 15, 2026
6d9179d
Deduplicate custom-storage components in admin component listing
edewit Sep 15, 2026
2195d34
List custom-storage components without merging realm duplicates
edewit Sep 16, 2026
e5c6fbc
Exclude custom-storage provider IDs from realm component listing
edewit Sep 16, 2026
b361ea6
Stabilize custom-storage component listing integration test
edewit Sep 16, 2026
a51bf9b
Add missing cleanupUiPageComponents helper in storage test
edewit Sep 16, 2026
7b9ba58
Reuse existing form utilities for declarative UI entity storage
edewit Sep 17, 2026
708bda8
Map null component lookup to undefined in declarative UI Page
edewit Nov 3, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion common/src/main/java/org/keycloak/common/Profile.java
Original file line number Diff line number Diff line change
Expand Up @@ -155,7 +155,7 @@ public enum Feature {
OPENTELEMETRY_LOGS("OpenTelemetry Logs support", Type.PREVIEW, OPENTELEMETRY),
OPENTELEMETRY_METRICS("Micrometer to OpenTelemetry bridge support for metrics", Type.EXPERIMENTAL, OPENTELEMETRY),

DECLARATIVE_UI("declarative ui spi", Type.EXPERIMENTAL),
DECLARATIVE_UI("declarative ui spi", Type.PREVIEW),

ORGANIZATION("Organization support within realms", Type.DEFAULT),

Expand Down
110 changes: 110 additions & 0 deletions docs/guides/ui-customization/declarative-ui.adoc
Original file line number Diff line number Diff line change
@@ -0,0 +1,110 @@
<#import "/templates/guide.adoc" as tmpl>
<#import "/templates/links.adoc" as links>

<@tmpl.guide
title="Extending the Admin Console declaratively"
priority=25
summary="Add pages and tabs to the Admin Console with Java SPIs and automatic form rendering.">

The declarative UI SPI lets extension authors add Admin Console pages and tabs without writing React code.
Implement `UiPageProviderFactory` for new top-level pages or `UiTabProviderFactory` for tabs on existing routes.
The Admin Console renders forms from `ProviderConfigProperty` definitions returned by the provider factory.

== Enable the feature

The SPI is available as a preview feature:

[source,bash]
----
bin/kc.[sh|bat] start --features=declarative-ui
----

See link:{adminguide_link}#features[Enabling and disabling features] for other ways to enable preview features.

== Implement a page or tab
8000
. Create a JAR with a `META-INF/services/org.keycloak.services.ui.extend.UiPageProviderFactory` or
`META-INF/services/org.keycloak.services.ui.extend.UiTabProviderFactory` service file.
. Implement the factory and return configuration properties from `getConfigProperties()`.
. Deploy the provider JAR to the `providers` directory and restart {project_name}.

An example extension is available in the
https://github.com/keycloak/keycloak-quickstarts/tree/latest/extension/extend-admin-console-spi[keycloak-quickstarts repository].

== Storage types for tabs

Tabs can store form data in different backends by overriding `getStorageType()`:

[cols="1,2", options="header"]
|===
| Storage type | Description

| `COMPONENT`
| Default. Data is stored as a realm component through the components REST API.

| `CLIENT`
| Reads and writes `client.attributes` for the `clientId` in the current route.

| `USER`
| Reads and writes `user.attributes` for the `id` route parameter.

| `IDENTITY_PROVIDER`
| Reads and writes `identityProvider.config` for the `alias` route parameter.

| `CUSTOM`
| Uses a custom admin REST endpoint declared with `getEndpoint()`.
|===

For `CUSTOM` storage, implement GET and PUT handlers relative to `/admin/realms/{realm}/`:

* `GET` returns JSON with a `config` object whose keys match property names.
* `PUT` receives submitted form data plus resolved route parameters as top-level fields.

Custom endpoints are served by a separate `AdminRealmResourceProvider` and are not protected by
`getRequiredViewRoles()` / `getRequiredManageRoles()`. Each handler must enforce admin permissions
explicitly, for example by using `AdminPermissionEvaluator` in the resource implementation.
The UI role metadata only controls Admin Console visibility; it is not a security boundary for
direct REST access.

== Runtime configuration

Static properties from server startup are still exposed through server info.
For context-aware fields (for example dropdowns populated from realm data), override:

[source,java]
----
List<ProviderConfigProperty> getConfigProperties(KeycloakSession session, Map<String, String> contextParams)
----

The Admin Console loads these properties at runtime from:

* `/admin/realms/{realm}/ui-extensions/tabs/{providerId}/config`
* `/admin/realms/{realm}/ui-extensions/pages/{providerId}/config`

Pass route parameters such as `clientId`, `id`, or `alias` as query parameters when opening a tab.

== Permissions and navigation

Override these methods to control access and placement:

* `getRequiredViewRoles()` and `getRequiredManageRoles()` — defaults to `view-realm` / `manage-realm`
* `getNavSection()` for pages — `manage`, `configure`, or `extensions`
* `getTabLabel()` for tabs — localized label key used instead of the provider id

Entity-backed tabs (`CLIENT`, `USER`, `IDENTITY_PROVIDER`) use the existing admin-client APIs, so fine-grained permissions for those resources apply automatically.

Component-backed pages and tabs can declare narrower roles in metadata.
When a factory also implements `ComponentStorageFactory`, component CRUD is delegated to the custom storage backend.

== Page detail tabs

Pages can expose additional declarative tabs on their detail view by overriding:

[source,java]
----
boolean supportsDetailTabs()
----

Child `UiTabProvider` entries whose path matches the page detail route are rendered automatically.

</@tmpl.guide>
Original file line number Diff line number Diff line change
Expand Up @@ -548,6 +548,7 @@ searchProfile=Search profile
eventTypes.UPDATE_EMAIL_ERROR.name=Update email error
removeConfirm_other=Are you sure you want to remove these groups?
configure=Configure
extensions=Extensions
searchScopeHelp=For one level, the search applies only for users in the DNs specified by User DNs. For subtree, the search applies to the whole subtree. See LDAP documentation for more details.
jumpToSection=Jump to section
noUsersEmptyStateDescription=Only the users with this role directly assigned will appear under this tab. If you need to find users assigned to this role, go to
Expand Down
74 changes: 66 additions & 8 deletions js/apps/admin-ui/src/PageNav.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,8 @@ import { useServerInfo } from "./context/server-info/ServerInfoProvider";
import type { Environment } from "./environment-types";
import { toPage } from "./page/routes";
import { normalizeNavRoutePath } from "./page-nav-utils";
import { PAGE_PROVIDER } from "./page/constants";
import { canViewUiExtension, getNavSection } from "./page/uiExtensionAccess";
import { routes } from "./routes";
import { resolveDisplayName } from "./util";
import useIsFeatureEnabled, { Feature } from "./utils/useIsFeatureEnabled";
Expand All @@ -27,6 +29,28 @@ type LeftNavProps = {
id?: string;
};

const ExtensionNav = ({ title, path }: { title: string; path: string }) => {
const { t } = useTranslation();
const { realm } = useRealm();
const encodedRealm = encodeURIComponent(realm);
const name = "nav-item" + path.replace("/", "-");

return (
<li>
<NavLink
id={name}
data-testid={name}
to={`/${encodedRealm}${path}`}
className={({ isActive }) =>
`pf-v5-c-nav__link${isActive ? " pf-m-current" : ""}`
}
>
{t(title)}
</NavLink>
</li>
);
};

const LeftNav = ({ title, path, id }: LeftNavProps) => {
const { t } = useTranslation();
const { hasAccess } = useAccess();
Expand Down Expand Up @@ -67,11 +91,20 @@ const LeftNav = ({ title, path, id }: LeftNavProps) => {
export const PageNav = () => {
const { t } = useTranslation();
const { environment } = useEnvironment<Environment>();
const { hasSomeAccess } = useAccess();
const { hasSomeAccess, hasAccess } = useAccess();
const { componentTypes } = useServerInfo();
const isFeatureEnabled = useIsFeatureEnabled();
const pages =
componentTypes?.["org.keycloak.services.ui.extend.UiPageProvider"];
componentTypes?.[PAGE_PROVIDER]?.filter((page) =>
canViewUiExtension(page, { hasAccess, hasSomeAccess }),
) ?? [];
Comment thread
edewit marked this conversation as resolved.
Resolved
const extensionPages = pages.filter(
(page) => getNavSection(page) === "extensions",
);
const configurePages = pages.filter(
(page) => getNavSection(page) === "configure",
);
const managePages = pages.filter((page) => getNavSection(page) === "manage");
const navigate = useNavigate();
const { realm, realmRepresentation } = useRealm();

Expand Down Expand Up @@ -107,6 +140,12 @@ export const PageNav = () => {
isFeatureEnabled(Feature.Workflows);

const showManageRealm = environment.masterRealm === environment.realm;
const showManageSection =
showManage ||
(isFeatureEnabled(Feature.DeclarativeUI) && managePages.length > 0);
const showConfigureSection =
showConfigure ||
(isFeatureEnabled(Feature.DeclarativeUI) && configurePages.length > 0);

return (
<PageSidebar className="keycloak__page_nav__nav">
Expand All @@ -126,7 +165,7 @@ export const PageNav = () => {
<LeftNav title={t("manageRealms")} path="/realms" />
</NavGroup>
)}
{showManage && (
{showManageSection && (
<NavGroup aria-label={t("manage")} title={t("manage")}>
{isFeatureEnabled(Feature.Organizations) &&
realmRepresentation.organizationsEnabled && (
Expand All @@ -139,10 +178,18 @@ export const PageNav = () => {
<LeftNav title="groups" path="/groups" />
<LeftNav title="sessions" path="/sessions" />
<LeftNav title="events" path="/events" />
{isFeatureEnabled(Feature.DeclarativeUI) &&
managePages.map((p) => (
Comment thread
edewit marked this conversation as resolved.
Resolved
<ExtensionNav
key={p.id}
title={p.id!}
path={toPage({ providerId: p.id }).pathname!}
/>
))}
</NavGroup>
)}

{showConfigure && (
{showConfigureSection && (
<NavGroup aria-label={t("configure")} title={t("configure")}>
<LeftNav title="realmSettings" path="/realm-settings" />
<LeftNav title="authentication" path="/authentication" />
Expand All @@ -154,16 +201,27 @@ export const PageNav = () => {
<LeftNav title="userFederation" path="/user-federation" />
{showWorkflows && <LeftNav title="workflows" path="/workflows" />}
{isFeatureEnabled(Feature.DeclarativeUI) &&
pages?.map((p) => (
<LeftNav
configurePages.map((p) => (
Comment thread
edewit marked this conversation as resolved.
Resolved
<ExtensionNav
key={p.id}
title={p.id}
title={p.id!}
path={toPage({ providerId: p.id }).pathname!}
id="/page-section"
/>
))}
</NavGroup>
)}
{isFeatureEnabled(Feature.DeclarativeUI) &&
extensionPages.length > 0 && (
<NavGroup aria-label={t("extensions")} title={t("extensions")}>
{extensionPages.map((p) => (
<ExtensionNav
key={p.id}
title={p.id!}
path={toPage({ providerId: p.id }).pathname!}
/>
))}
</NavGroup>
)}
</Nav>
</PageSidebarBody>
</PageSidebar>
Expand Down
13 changes: 11 additions & 2 deletions js/apps/admin-ui/src/components/routable-tabs/RoutableTabs.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -21,8 +21,10 @@ import {
useParams,
} from "react-router-dom";
import { useServerInfo } from "../../context/server-info/ServerInfoProvider";
import { useAccess } from "../../context/access/Access";
import { PageHandler } from "../../page/PageHandler";
import { TAB_PROVIDER } from "../../page/constants";
import { canViewUiExtension, getTabTitle } from "../../page/uiExtensionAccess";
import useIsFeatureEnabled, { Feature } from "../../utils/useIsFeatureEnabled";
import { useTranslation } from "react-i18next";

Expand All @@ -48,7 +50,10 @@ export const RoutableTabs = ({
const { pathname } = useLocation();
const params = useParams();
const { componentTypes } = useServerInfo();
const tabs = componentTypes?.[TAB_PROVIDER] || [];
const access = useAccess();
const tabs = (componentTypes?.[TAB_PROVIDER] || []).filter((tab) =>
canViewUiExtension(tab, access),
);
const isFeatureEnabled = useIsFeatureEnabled();
const { t } = useTranslation();

Expand Down Expand Up @@ -100,7 +105,11 @@ export const RoutableTabs = ({
{children as any}
{isFeatureEnabled(Feature.DeclarativeUI) &&
matchedTabs.map<any>((tab) => (
<DynamicTab key={tab.id} eventKey={tab.pathname} title={t(tab.id)}>
<DynamicTab
key={tab.id}
eventKey={tab.pathname}
title={getTabTitle(tab, t)}
>
<PageHandler page={tab} providerType={TAB_PROVIDER} />
</DynamicTab>
))}
Expand Down
Loading
Loading
0