From ffd5e5805dd43987a359c8fba5c94a727a5627ad Mon Sep 17 00:00:00 2001 From: Erik Jan de Wit Date: Sat, 25 Jul 2026 13:29:26 +0200 Subject: [PATCH] Use browser routing in admin UI and restore native JumpLinks behavior Switch the Admin Console from hash routing to browser routing with server-side deep-link fallback, and handle routable tab clicks through React Router so tab changes stay client-side. With path-based routing in place, remove the ScrollForm hash-safe click override and rely on PatternFly JumpLinks href/node behavior, including stable anchors for bordered panels. Closes #39693 Closes #45494 Related: patternfly/patternfly-react#12223 Signed-off-by: Erik Jan de Wit --- js/apps/admin-ui/src/App.tsx | 8 +--- .../components/routable-tabs/RoutableTabs.tsx | 19 ++++++-- .../context/realm-context/RealmContext.tsx | 7 +-- .../src/context/realm-context/useHash.tsx | 25 ---------- js/apps/admin-ui/src/main.tsx | 30 ++++++++---- .../identity-providers/default-trust.spec.ts | 2 +- .../admin-ui/test/identity-providers/main.ts | 2 +- .../saml-signature-defaults.spec.ts | 2 +- .../ui-shared/src/scroll-form/FormPanel.tsx | 4 +- .../ui-shared/src/scroll-form/ScrollForm.tsx | 48 ++++--------------- .../resources/admin/AdminConsole.java | 8 ++-- 11 files changed, 61 insertions(+), 94 deletions(-) delete mode 100644 js/apps/admin-ui/src/context/realm-context/useHash.tsx diff --git a/js/apps/admin-ui/src/App.tsx b/js/apps/admin-ui/src/App.tsx index 7dc43b3c0b36..5e6d30ee2ca0 100644 --- a/js/apps/admin-ui/src/App.tsx +++ b/js/apps/admin-ui/src/App.tsx @@ -5,7 +5,7 @@ import { mainPageContentId, } from "@keycloak/keycloak-ui-shared"; import { Flex, FlexItem, Page } from "@patternfly/react-core"; -import { PropsWithChildren, Suspense, useEffect } from "react"; +import { PropsWithChildren, Suspense } from "react"; import { Outlet } from "react-router-dom"; import { AdminClientProvider } from "./admin-client"; import { Header } from "./PageHeader"; @@ -40,12 +40,6 @@ export const AppContexts = ({ children }: PropsWithChildren) => ( ); export const App = () => { - const hrefEndsWithHashSlash = location.href.endsWith("#/"); - useEffect(() => { - if (!hrefEndsWithHashSlash) return; - history.replaceState(null, "", location.pathname); - }, [hrefEndsWithHashSlash, location.pathname]); - return ( diff --git a/js/apps/admin-ui/src/components/routable-tabs/RoutableTabs.tsx b/js/apps/admin-ui/src/components/routable-tabs/RoutableTabs.tsx index 8a2987826d91..c0c34bd5d06f 100644 --- a/js/apps/admin-ui/src/components/routable-tabs/RoutableTabs.tsx +++ b/js/apps/admin-ui/src/components/routable-tabs/RoutableTabs.tsx @@ -14,9 +14,11 @@ import { } from "react"; import { Path, + To, generatePath, matchPath, useHref, + useLinkClickHandler, useLocation, useParams, } from "react-router-dom"; @@ -118,15 +120,22 @@ const DynamicTab = ({ ...props }: PropsWithChildren) => { const href = useHref(props.eventKey); + const onClick = useLinkClickHandler(props.eventKey); return ( - + {children} ); }; -export const useRoutableTab = (to: Partial) => ({ - eventKey: to.pathname ?? "", - href: useHref(to), -}); +export const useRoutableTab = (to: Partial) => { + const href = useHref(to); + const onClick = useLinkClickHandler(to as To); + + return { + eventKey: to.pathname ?? "", + href, + onClick, + }; +}; diff --git a/js/apps/admin-ui/src/context/realm-context/RealmContext.tsx b/js/apps/admin-ui/src/context/realm-context/RealmContext.tsx index a0f89dfb8cc4..7f89da0b66cd 100644 --- a/js/apps/admin-ui/src/context/realm-context/RealmContext.tsx +++ b/js/apps/admin-ui/src/context/realm-context/RealmContext.tsx @@ -7,8 +7,8 @@ import { useRequiredContext, } from "@keycloak/keycloak-ui-shared"; import { PropsWithChildren, useEffect, useState } from "react"; +import { useLocation } from "react-router-dom"; import { useAdminClient } from "../../admin-client"; -import { useHash } from "./useHash"; import { useTranslation } from "react-i18next"; type RealmContextType = { @@ -26,13 +26,14 @@ export const RealmContextProvider = ({ children }: PropsWithChildren) => { const { adminClient } = useAdminClient(); const { environment } = useEnvironment(); const { i18n } = useTranslation(); + const { pathname } = useLocation(); const [key, setKey] = useState(0); const refresh = () => setKey(key + 1); const [realmRepresentation, setRealmRepresentation] = useState(); - const locationRealm = useHash(); - const realm = locationRealm.split("/")[1] ?? environment.realm; + const locationRealm = decodeURIComponent(pathname); + const realm = locationRealm.split("/")[1] || environment.realm; // Configure admin client to use selected realm when it changes. useEffect(() => { diff --git a/js/apps/admin-ui/src/context/realm-context/useHash.tsx b/js/apps/admin-ui/src/context/realm-context/useHash.tsx deleted file mode 100644 index 1fa12c6b23e3..000000000000 --- a/js/apps/admin-ui/src/context/realm-context/useHash.tsx +++ /dev/null @@ -1,25 +0,0 @@ -import { useEffect, useState } from "react"; - -function getHash(url: URL) { - return decodeURIComponent(url.hash.substring(1)); -} - -export const useHash = () => { - const [hash, setHash] = useState(getHash(new URL(window.location.href))); - - useEffect(() => { - const orgPushState = window.history.pushState; - window.history.pushState = new Proxy(window.history.pushState, { - apply: (func, target, args) => { - const url = new URL(args[2], window.location.origin); - setHash(getHash(url)); - return Reflect.apply(func, target, args); - }, - }); - return () => { - window.history.pushState = orgPushState; - }; - }, []); - - return hash; -}; diff --git a/js/apps/admin-ui/src/main.tsx b/js/apps/admin-ui/src/main.tsx index 119ab93ef950..6e28b3e19d3e 100644 --- a/js/apps/admin-ui/src/main.tsx +++ b/js/apps/admin-ui/src/main.tsx @@ -3,7 +3,8 @@ import "@patternfly/react-core/dist/styles/base.css"; import { StrictMode } from "react"; import { createRoot } from "react-dom/client"; -import { createHashRouter, RouterProvider } from "react-router-dom"; +import { createBrowserRouter, RouterProvider } from "react-router-dom"; +import { environment } from "./environment"; import { i18n } from "./i18n/i18n"; import { Root } from "./Root"; import { routes } from "./routes"; @@ -13,13 +14,26 @@ import "./index.css"; // Initialize required components before rendering app. await i18n.init(); -const router = createHashRouter([ - { - path: "/", - element: , - children: routes, - }, -]); +const basename = + decodeURIComponent( + new URL(environment.consoleBaseUrl, window.location.origin).pathname, + ).replace(/\/+$/, "") || "/"; + +if (window.location.hash.startsWith("#/")) { + const hashPath = decodeURIComponent(window.location.hash.substring(1)); + window.history.replaceState(null, "", `${basename}${hashPath}`); +} + +const router = createBrowserRouter( + [ + { + path: "/", + element: , + children: routes, + }, + ], + { basename }, +); const container = document.getElementById("app"); const root = createRoot(container!); diff --git a/js/apps/admin-ui/test/identity-providers/default-trust.spec.ts b/js/apps/admin-ui/test/identity-providers/default-trust.spec.ts index 8a0419b90fd8..23b0360255f7 100644 --- a/js/apps/admin-ui/test/identity-providers/default-trust.spec.ts +++ b/js/apps/admin-ui/test/identity-providers/default-trust.spec.ts @@ -8,7 +8,7 @@ import { clickSaveButton, createDefaultTrustProvider } from "./main.ts"; const alias = "default-trust"; const addDefaultTrustProviderUrl = - "http://localhost:8080/admin/master/console/#/master/identity-providers/default-trust/add"; + "http://localhost:8080/admin/master/console/master/identity-providers/default-trust/add"; const jwksUrl = "https://localhost/realms/test/protocol/openid-connect/certs"; const jwks = '{"keys":[]}'; diff --git a/js/apps/admin-ui/test/identity-providers/main.ts b/js/apps/admin-ui/test/identity-providers/main.ts index 48d84abfb5be..ef7a603a1b1f 100644 --- a/js/apps/admin-ui/test/identity-providers/main.ts +++ b/js/apps/admin-ui/test/identity-providers/main.ts @@ -97,7 +97,7 @@ export async function createDefaultTrustProvider( (await page.getByTestId("currentRealm").textContent()) ?? "master"; await page.goto( - `${SERVER_URL}/admin/master/console/#/${realm}/identity-providers/default-trust/add`, + `${SERVER_URL}/admin/master/console/${realm}/identity-providers/default-trust/add`, ); await page.getByTestId("alias").fill(alias); diff --git a/js/apps/admin-ui/test/identity-providers/saml-signature-defaults.spec.ts b/js/apps/admin-ui/test/identity-providers/saml-signature-defaults.spec.ts index 3f37cc7b33da..454380b4a098 100644 --- a/js/apps/admin-ui/test/identity-providers/saml-signature-defaults.spec.ts +++ b/js/apps/admin-ui/test/identity-providers/saml-signature-defaults.spec.ts @@ -2,7 +2,7 @@ import { expect, test } from "@playwright/test"; import { login } from "../utils/login.ts"; const addSamlProviderUrl = - "http://localhost:8080/admin/master/console/#/master/identity-providers/saml/add"; + "http://localhost:8080/admin/master/console/master/identity-providers/saml/add"; test("should enable SAML signature switches by default", async ({ page }) => { await login(page); diff --git a/js/libs/ui-shared/src/scroll-form/FormPanel.tsx b/js/libs/ui-shared/src/scroll-form/FormPanel.tsx index 50e4e7fe382f..b18f22bdae13 100644 --- a/js/libs/ui-shared/src/scroll-form/FormPanel.tsx +++ b/js/libs/ui-shared/src/scroll-form/FormPanel.tsx @@ -17,10 +17,10 @@ export const FormPanel = ({ const id = useId(); return ( - + - + {children} diff --git a/js/libs/ui-shared/src/scroll-form/ScrollForm.tsx b/js/libs/ui-shared/src/scroll-form/ScrollForm.tsx index c9de609e8af4..685d0c2add46 100644 --- a/js/libs/ui-shared/src/scroll-form/ScrollForm.tsx +++ b/js/libs/ui-shared/src/scroll-form/ScrollForm.tsx @@ -6,7 +6,7 @@ import { JumpLinksItem, PageSection, } from "@patternfly/react-core"; -import { Fragment, ReactNode, useEffect, useMemo, useState } from "react"; +import { Fragment, ReactNode, useMemo } from "react"; import { FormPanel } from "./FormPanel"; import { ScrollPanel } from "./ScrollPanel"; @@ -41,31 +41,6 @@ export const ScrollForm = ({ [sections], ); - const [activeSection, setActiveSection] = useState(0); - - useEffect(() => { - const scroller = document.getElementById(mainPageContentId); - if (!scroller) return; - - const offset = 100; - const updateActive = () => { - const scrollTop = scroller.scrollTop + offset; - let active = 0; - shownSections.forEach(({ title }, index) => { - const id = spacesToHyphens(title.toLowerCase()); - const el = document.getElementById(id); - if (el && scrollTop >= el.offsetTop) { - active = index; - } - }); - setActiveSection(active); - }; - - updateActive(); - scroller.addEventListener("scroll", updateActive); - return () => scroller.removeEventListener("scroll", updateActive); - }, [shownSections]); - return ( @@ -93,23 +68,20 @@ export const ScrollForm = ({ - - {shownSections.map(({ title }, index) => { + + {shownSections.map(({ title }) => { const scrollId = spacesToHyphens(title.toLowerCase()); return ( { - const element = document.getElementById(scrollId); - if (element) { - element.scrollIntoView({ - behavior: "smooth", - block: "start", - }); - } - }} + href={`#${scrollId}`} + node={`#${scrollId}`} data-testid={`jump-link-${scrollId}`} > {title} diff --git a/services/src/main/java/org/keycloak/services/resources/admin/AdminConsole.java b/services/src/main/java/org/keycloak/services/resources/admin/AdminConsole.java index 8be3ae16e058..2c89e03a0589 100644 --- a/services/src/main/java/org/keycloak/services/resources/admin/AdminConsole.java +++ b/services/src/main/java/org/keycloak/services/resources/admin/AdminConsole.java @@ -31,6 +31,7 @@ import jakarta.ws.rs.NotFoundException; import jakarta.ws.rs.OPTIONS; import jakarta.ws.rs.Path; +import jakarta.ws.rs.PathParam; import jakarta.ws.rs.Produces; import jakarta.ws.rs.QueryParam; import jakarta.ws.rs.core.Response; @@ -323,12 +324,13 @@ protected RealmModel getAdminstrationRealm(RealmManager realmManager) { */ @GET @NoCache - public Response getMainPage() throws IOException, FreeMarkerException { + @Path("{path:.*}") + public Response getMainPage(@PathParam("path") String path) throws IOException, FreeMarkerException { final var baseUriInfo = session.getContext().getUri(UrlType.FRONTEND); final var adminUriInfo = session.getContext().getUri(UrlType.ADMIN); - // Redirect to a URL with a trailing slash if the current URL doesn't have one. - if (!adminUriInfo.getRequestUri().getPath().endsWith("/")) { + // Redirect root URL to one with a trailing slash. + if ((path == null || path.isBlank()) && !adminUriInfo.getRequestUri().getPath().endsWith("/")) { return Response.status(302).location(adminUriInfo.getRequestUriBuilder().path("/").build()).build(); } else { // Get the base URLs of the server and admin console.