8000 Add UiScriptProvider for custom admin console pages by edewit · Pull Request #54586 · keycloak/keycloak · GitHub
[go: up one dir, main page]

Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
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
16 changes: 13 additions & 3 deletions js/apps/admin-ui/src/PageNav.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,8 @@ import { useAccess } from "./context/access/Access";
import { useRealm } from "./context/realm-context/RealmContext";
import { useServerInfo } from "./context/server-info/ServerInfoProvider";
import type { Environment } from "./environment-types";
import { toPage } from "./page/routes";
import { PAGE_PROVIDER, SCRIPT_PROVIDER } from "./page/constants";
import { toPage, toScriptPage } from "./page/routes";
import { normalizeNavRoutePath } from "./page-nav-utils";
import { routes } from "./routes";
import { resolveDisplayName } from "./util";
Expand Down Expand Up @@ -70,8 +71,8 @@ export const PageNav = () => {
const { hasSomeAccess } = useAccess();
const { componentTypes } = useServerInfo();
const isFeatureEnabled = useIsFeatureEnabled();
const pages =
componentTypes?.["org.keycloak.services.ui.extend.UiPageProvider"];
const pages = componentTypes?.[PAGE_PROVIDER];
const scripts = componentTypes?.[SCRIPT_PROVIDER];
const navigate = useNavigate();
const { realm, realmRepresentation } = useRealm();

Expand Down Expand Up @@ -162,6 +163,15 @@ export const PageNav = () => {
id="/page-section"
/>
))}
{isFeatureEnabled(Feature.DeclarativeUI) &&
scripts?.map((s) => (
<LeftNav
key={s.id}
title={s.id}
path={toScriptPage({ providerId: s.id }).pathname!}
id="/ui-script"
/>
))}
</NavGroup>
)}
</Nav>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,12 @@ export const PageBreadCrumbs = () => {
const { realm } = useRealm();
const { pathname } = useLocation();

const excludePaths = new Set(["/", `/${realm}`, `/${realm}/page-section`]);
const excludePaths = new Set([
"/",
`/${realm}`,
`/${realm}/page-section`,
`/${realm}/ui-script`,
]);

// Find every route whose pattern is a prefix of the current URL.
type Crumb = {
Expand Down
91 changes: 91 additions & 0 deletions js/apps/admin-ui/src/page/ScriptPage.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
import type KeycloakAdminClient from "@keycloak/keycloak-admin-client";
import { KeycloakSpinner, useAlerts } from "@keycloak/keycloak-ui-shared";
import { PageSection } from "@patternfly/react-core";
import type { i18n as I18n } from "i18next";
import { useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import { useParams } from "react-router-dom";
import { useAdminClient } from "../admin-client";
import { ViewHeader } from "../components/view-header/ViewHeader";
import { useRealm } from "../context/realm-context/RealmContext";
import { useServerInfo } from "../context/server-info/ServerInfoProvider";
import { SCRIPT_PROVIDER } from "./constants";

export type ScriptPageParams = { realm?: string; providerId: string };

export type UiScriptContext = {
realm: string;
i18n: I18n;
adminClient: KeycloakAdminClient;
};

type UiScriptModule = {
default: (context: UiScriptContext) => HTMLElement;
};

export default function ScriptPage() {
const { adminClient } = useAdminClient();
const { t, i18n } = useTranslation();
const { addError } = useAlerts();
const { realm } = useRealm();
const { componentTypes } = useServerInfo();
const { providerId } = useParams<ScriptPageParams>();
const [container, setContainer] = useState<HTMLDivElement | null>(null);
const [loading, setLoading] = useState(true);

const scripts = componentTypes?.[SCRIPT_PROVIDER];
const script = scripts?.find((s) => s.id === providerId);
const url =
typeof script?.metadata.url === "string" ? script.metadata.url : undefined;

useEffect(() => {
if (!container || !url) {
return;
}

const host = container;
const active = { current: true };
let mounted: HTMLElement | undefined;

setLoading(true);

void (async () => {
try {
const module = (await import(/* @vite-ignore */ url)) as UiScriptModule;
if (!active.current) {
return;
}
mounted = module.default({ realm, i18n, adminClient });
Comment on lines +54 to +58
host.replaceChildren(mounted);
} catch (error) {
if (active.current) {
addError("notFound", error);
}
} finally {
if (active.current) {
setLoading(false);
}
}
})();

return () => {
active.current = false;
mounted?.remove();
host.replaceChildren();
};
}, [container, providerId, url, realm, i18n, adminClient, addError]);

if (!script || !url) {
throw new Error(t("notFound"));
}

return (
<>
<ViewHeader titleKey={script.id} />
<PageSection variant="light">
{loading && <KeycloakSpinner />}
<div ref={setContainer} hidden={loading} />
</PageSection>
</>
);
}
2 changes: 2 additions & 0 deletions js/apps/admin-ui/src/page/constants.ts
Original file line number Diff line number Diff line change
@@ -1,2 +1,4 @@
export const PAGE_PROVIDER = "org.keycloak.services.ui.extend.UiPageProvider";
export const TAB_PROVIDER = "org.keycloak.services.ui.extend.UiTabProvider";
export const SCRIPT_PROVIDER =
"org.keycloak.services.ui.extend.UiScriptProvider";
16 changes: 16 additions & 0 deletions js/apps/admin-ui/src/page/routes.tsx
Original file line number Diff line number Diff line change
@@ -1,12 +1,14 @@
import { Path, generatePath } from "react-router-dom";
import type { AppRouteObject } from "../routes";
import { lazy } from "react";
import type { ScriptPageParams } from "./ScriptPage";

export type PageListParams = { realm?: string; providerId: string };
export type PageParams = { realm: string; providerId: string; id: string };

const PageList = lazy(() => import("./PageList"));
const Page = lazy(() => import("./Page"));
const ScriptPage = lazy(() => import("./ScriptPage"));

const PageListRoute: AppRouteObject = {
path: "/:realm?/page-section/:providerId",
Expand Down Expand Up @@ -35,10 +37,20 @@ const AddPageDetailRoute: AppRouteObject = {
},
};

const ScriptPageRoute: AppRouteObject = {
path: "/:realm?/ui-script/:providerId",
element: <ScriptPage />,
handle: {
access: "view-realm",
breadcrumb: (t) => t("page"),
},
};

const routes: AppRouteObject[] = [
PageDetailRoute,
AddPageDetailRoute,
PageListRoute,
ScriptPageRoute,
];

export const toPage = (params: PageListParams): Partial<Path> => ({
Expand All @@ -53,4 +65,8 @@ export const addDetailPage = (params: Partial<PageParams>): Partial<Path> => ({
pathname: generatePath(AddPageDetailRoute.path, params),
});

export const toScriptPage = (params: ScriptPageParams): Partial<Path> => ({
pathname: generatePath(ScriptPageRoute.path, params),
});

export default routes;
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
package org.keycloak.services.ui.extend;

import org.keycloak.provider.Provider;

/**
* SPI for admin console pages backed by a dynamically loaded JavaScript module.
* Unlike {@link UiPageProvider}, this does not use config properties / Components CRUD.
*/
public interface UiScriptProvider extends Provider {
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
package org.keycloak.services.ui.extend;

import org.keycloak.models.KeycloakSession;
import org.keycloak.provider.ProviderFactory;

/**
* Factory for {@link UiScriptProvider}. {@link #getId()} is used as the nav label;
* {@link #getUrl()} is the module URL the admin console dynamically imports.
* The module must export a {@code default} function that receives
* {@code { realm, i18n, adminClient }} and returns an {@code HTMLElement}.
*/
public interface UiScriptProviderFactory extends ProviderFactory<UiScriptProvider> {

@Override
default UiScriptProvider create(KeycloakSession session) {
return null;
}

/**
* URL of an ES module the admin console will {@code import()}.
*/
String getUrl();
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
package org.keycloak.services.ui.extend;

import org.keycloak.common.Profile;
import org.keycloak.provider.Provider;
import org.keycloak.provider.ProviderFactory;
import org.keycloak.provider.Spi;

public class UiScriptSpi implements Spi {
@Override
public boolean isInternal() {
return true;
}

@Override
public String getName() {
return "ui-script";
}

@Override
public Class<? extends Provider> getProviderClass() {
return UiScriptProvider.class;
}

@Override
public Class<? extends ProviderFactory> getProviderFactoryClass() {
return UiScriptProviderFactory.class;
}

@Override
public boolean isEnabled() {
return Profile.isFeatureEnabled(Profile.Feature.DECLARATIVE_UI);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,7 @@ org.keycloak.services.managers.BruteForceProtectorSpi
org.keycloak.services.resource.AccountResourceSpi
org.keycloak.services.resource.RealmResourceSPI
org.keycloak.services.ui.extend.UiPageSpi
org.keycloak.services.ui.extend.UiScriptSpi
org.keycloak.services.ui.extend.UiTabSpi
org.keycloak.sessions.AuthenticationSessionSpi
org.keycloak.sessions.StickySessionEncoderSpi
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -90,6 +90,7 @@
import org.keycloak.services.resources.admin.AdminAuth;
import org.keycloak.services.resources.admin.fgap.AdminPermissionEvaluator;
import org.keycloak.services.resources.admin.fgap.AdminPermissions;
import org.keycloak.services.ui.extend.UiScriptProviderFactory;
import org.keycloak.theme.Theme;

import org.eclipse.microprofile.openapi.annotations.Operation;
Expand Down Expand Up @@ -233,6 +234,18 @@ public int compare(Spi s1, Spi s2) {
info.getComponentTypes().put(spi.getProviderClass().getName(), reps);
}
reps.add(rep);
} else if (pi instanceof UiScriptProviderFactory scriptFactory) {
ComponentTypeRepresentation rep = new ComponentTypeRepresentation();
rep.setId(pi.getId());
Map<String, Object> metadata = new HashMap<>();
metadata.put("url", scriptFactory.getUrl());
Comment on lines +237 to +241
rep.setMetadata(metadata);
List<ComponentTypeRepresentation> reps = info.getComponentTypes().get(spi.getProviderClass().getName());
if (reps == null) {
reps = new LinkedList<>();
info.getComponentTypes().put(spi.getProviderClass().getName(), reps);
}
reps.add(rep);
}
providers.put(name, provider);
}
Expand Down
Loading
0