Skip to content

Commit

Permalink
Add a way to extend the UI with an Java API (keycloak#23772)
Browse files Browse the repository at this point in the history
* POC to see how we could extend the UI

This is very crude and there are still open issues that need to be worked out

Signed-off-by: Erik Jan de Wit <erikjan.dewit@gmail.com>

* added saving option

Signed-off-by: Erik Jan de Wit <erikjan.dewit@gmail.com>

* added list and recreate client form

Signed-off-by: Erik Jan de Wit <erikjan.dewit@gmail.com>

* add tab ui

Signed-off-by: Erik Jan de Wit <erikjan.dewit@gmail.com>

* integrate tabs

Signed-off-by: Erik Jan de Wit <erikjan.dewit@gmail.com>

* remove examples

Signed-off-by: Erik Jan de Wit <erikjan.dewit@gmail.com>

* fixed error messages

Signed-off-by: Erik Jan de Wit <erikjan.dewit@gmail.com>

* added Feature for ui customization

Signed-off-by: Erik Jan de Wit <erikjan.dewit@gmail.com>

---------

Signed-off-by: Erik Jan de Wit <erikjan.dewit@gmail.com>
Signed-off-by: ShefeeqPM <86718986+ShefeeqPM@users.noreply.github.com>
  • Loading branch information
edewit authored and ShefeeqPM committed Jan 27, 2024
1 parent 1743d42 commit ba345a1
Show file tree
Hide file tree
Showing 32 changed files with 729 additions and 155 deletions.
3 changes: 3 additions & 0 deletions common/src/main/java/org/keycloak/common/Profile.java
Original file line number Diff line number Diff line change
Expand Up @@ -105,8 +105,11 @@ public enum Feature {
MULTI_SITE("Multi-site support", Type.PREVIEW),

OFFLINE_SESSION_PRELOADING("Offline session preloading", Type.DEPRECATED),

HOSTNAME_V1("Hostname Options V1", Type.DEFAULT),
//HOSTNAME_V2("Hostname Options V2", Type.DEFAULT, 2),

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

private final Type type;
Expand Down
1 change: 1 addition & 0 deletions common/src/test/java/org/keycloak/common/ProfileTest.java
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,7 @@ public void checkDefaults() {
Profile.Feature.DYNAMIC_SCOPES,
Profile.Feature.DOCKER,
Profile.Feature.MULTI_SITE,
Profile.Feature.DECLARATIVE_UI,
Profile.Feature.RECOVERY_CODES,
Profile.Feature.SCRIPTS,
Profile.Feature.TOKEN_EXCHANGE,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2986,4 +2986,16 @@ customValue=Custom value
termsAndConditionsUserAttribute=Terms and conditions accepted timestamp
realmOverridesDescription= Realm overrides allow you to specify translations that will take effect for the entire realm. These translations will override any translation specified by a theme.
addTranslation=Add translation
effectiveMessageBundlesDescription=An effective message bundle is the set of translations for a given language, theme, and theme type. It also takes into account any realm overrides, which will take precedence.
effectiveMessageBundlesDescription=An effective message bundle is the set of translations for a given language, theme, and theme type. It also takes into account any realm overrides, which will take precedence.
clientsClientScopesHelp=The scopes associated with this resource.
searchItem=Search item
createItem=Create item
itemDelete=Delete item
itemDeleteConfirm=Are you sure you want to permanently delete the item
itemDeleteConfirmTitle=Delete item?
itemDeletedSuccess=The item has been deleted
itemDeleteError=Could not delete item: {{error}}
noItems=There are no items
noItemsInstructions=You haven't created any items in this realm. Create a item to get started.
itemSaveError=Error could not save item\! {{error}}
itemSaveSuccessful=Sucessful saved
44 changes: 22 additions & 22 deletions js/apps/admin-ui/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -24,21 +24,23 @@ import { AuthWall } from "./root/AuthWall";

const AppContexts = ({ children }: PropsWithChildren) => (
<ErrorBoundaryProvider>
<RealmsProvider>
<RealmContextProvider>
<WhoAmIContextProvider>
<RecentRealmsProvider>
<AccessContextProvider>
<Help>
<AlertProvider>
<SubGroups>{children}</SubGroups>
</AlertProvider>
</Help>
</AccessContextProvider>
</RecentRealmsProvider>
</WhoAmIContextProvider>
</RealmContextProvider>
</RealmsProvider>
<ServerInfoProvider>
<RealmsProvider>
<RealmContextProvider>
<WhoAmIContextProvider>
<RecentRealmsProvider>
<AccessContextProvider>
<Help>
<AlertProvider>
<SubGroups>{children}</SubGroups>
</AlertProvider>
</Help>
</AccessContextProvider>
</RecentRealmsProvider>
</WhoAmIContextProvider>
</RealmContextProvider>
</RealmsProvider>
</ServerInfoProvider>
</ErrorBoundaryProvider>
);

Expand All @@ -53,13 +55,11 @@ export const App = () => {
mainContainerId={mainPageContentId}
>
<ErrorBoundaryFallback fallback={ErrorRenderer}>
<ServerInfoProvider>
<Suspense fallback={<KeycloakSpinner />}>
<AuthWall>
<Outlet />
</AuthWall>
</Suspense>
</ServerInfoProvider>
<Suspense fallback={<KeycloakSpinner />}>
<AuthWall>
<Outlet />
</AuthWall>
</Suspense>
</ErrorBoundaryFallback>
</Page>
</AppContexts>
Expand Down
21 changes: 17 additions & 4 deletions js/apps/admin-ui/src/PageNav.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,23 +9,25 @@ import {
import { FormEvent } from "react";
import { useTranslation } from "react-i18next";
import { NavLink, useMatch, useNavigate } from "react-router-dom";

import { RealmSelector } from "./components/realm-selector/RealmSelector";
import { useAccess } from "./context/access/Access";
import { useRealm } from "./context/realm-context/RealmContext";
import { useServerInfo } from "./context/server-info/ServerInfoProvider";
import { toPage } from "./page/routes";
import { AddRealmRoute } from "./realm/routes/AddRealm";
import { routes } from "./routes";

import "./page-nav.css";

type LeftNavProps = { title: string; path: string };
type LeftNavProps = { title: string; path: string; id?: string };

const LeftNav = ({ title, path }: LeftNavProps) => {
const LeftNav = ({ title, path, id }: LeftNavProps) => {
const { t } = useTranslation();
const { hasAccess } = useAccess();
const { realm } = useRealm();
const route = routes.find(
(route) => route.path.replace(/\/:.+?(\?|(?:(?!\/).)*|$)/g, "") === path,
(route) =>
route.path.replace(/\/:.+?(\?|(?:(?!\/).)*|$)/g, "") === (id || path),
);

const accessAllowed =
Expand Down Expand Up @@ -56,6 +58,9 @@ const LeftNav = ({ title, path }: LeftNavProps) => {
export const PageNav = () => {
const { t } = useTranslation();
const { hasSomeAccess } = useAccess();
const { componentTypes } = useServerInfo();
const pages =
componentTypes?.["org.keycloak.services.ui.extend.UiPageProvider"];

const navigate = useNavigate();

Expand Down Expand Up @@ -116,6 +121,14 @@ export const PageNav = () => {
<LeftNav title="authentication" path="/authentication" />
<LeftNav title="identityProviders" path="/identity-providers" />
<LeftNav title="userFederation" path="/user-federation" />
{pages?.map((p) => (
<LeftNav
key={p.id}
title={p.id}
path={toPage({ providerId: p.id }).pathname!}
id="/page-section"
/>
))}
</NavGroup>
)}
</Nav>
Expand Down
60 changes: 56 additions & 4 deletions js/apps/admin-ui/src/components/routable-tabs/RoutableTabs.tsx
Original file line number Diff line number Diff line change
@@ -1,16 +1,28 @@
import {
Tab,
TabProps,
Tabs,
TabsComponent,
TabsProps,
} from "@patternfly/react-core";
import {
Children,
isValidElement,
JSXElementConstructor,
PropsWithChildren,
ReactElement,
isValidElement,
} from "react";
import { Path, useHref, useLocation } from "react-router-dom";
import {
Path,
generatePath,
matchPath,
useHref,
useLocation,
useParams,
} from "react-router-dom";
import { useServerInfo } from "../../context/server-info/ServerInfoProvider";
import { PageHandler } from "../../page/PageHandler";
import { TAB_PROVIDER } from "../../page/PageList";

// TODO: Remove the custom 'children' props and type once the following issue has been resolved:
// https://github.com/patternfly/patternfly-react/issues/6766
Expand All @@ -32,14 +44,31 @@ export const RoutableTabs = ({
...otherProps
}: RoutableTabsProps) => {
const { pathname } = useLocation();
const params = useParams();
const { componentTypes } = useServerInfo();
const tabs = componentTypes?.[TAB_PROVIDER] || [];

const matchedTabs = tabs
.filter((tab) => matchPath({ path: tab.metadata.path }, pathname))
.map((t) => ({
...t,
pathname: generatePath(t.metadata.path, {
...params,
...t.metadata.params,
}),
}));
// Extract all keys from matchedTabs
const matchedTabsKeys = matchedTabs.map((t) => t.pathname);

// Extract event keys from children.
// Extract event keys from children
const eventKeys = Children.toArray(children)
.filter((child): child is ChildElement => isValidElement(child))
.map((child) => child.props.eventKey.toString());

const allKeys = [...eventKeys, ...matchedTabsKeys];

// Determine if there is an exact match.
const exactMatch = eventKeys.find(
const exactMatch = allKeys.find(
(eventKey) => eventKey === decodeURI(pathname),
);

Expand All @@ -63,10 +92,33 @@ export const RoutableTabs = ({
{...otherProps}
>
{children}
{matchedTabs.map((t) => (
<DynamicTab key={t.id} eventKey={t.pathname} title={t.id}>
<PageHandler page={t} providerType={TAB_PROVIDER} />
</DynamicTab>
))}
</Tabs>
);
};

type DynamicTabProps = {
title: string;
eventKey: string;
};

const DynamicTab = ({
children,
...props
}: PropsWithChildren<DynamicTabProps>) => {
const href = useHref(props.eventKey);

return (
<Tab href={href} {...props}>
{children}
</Tab>
);
};

export const useRoutableTab = (to: Partial<Path>) => ({
eventKey: to.pathname ?? "",
href: useHref(to),
Expand Down
67 changes: 67 additions & 0 deletions js/apps/admin-ui/src/page/Page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
import { ButtonVariant, DropdownItem } from "@patternfly/react-core";
import { useTranslation } from "react-i18next";
import { useNavigate, useParams } from "react-router-dom";
import { adminClient } from "../admin-client";
import { useAlerts } from "../components/alert/Alerts";
import { useConfirmDialog } from "../components/confirm-dialog/ConfirmDialog";
import { ViewHeader } from "../components/view-header/ViewHeader";
import { useServerInfo } from "../context/server-info/ServerInfoProvider";
import { PageHandler } from "./PageHandler";
import { PAGE_PROVIDER } from "./PageList";
import { PageParams, toPage } from "./routes";
import { useRealm } from "../context/realm-context/RealmContext";

export default function Page() {
const { t } = useTranslation();
const { componentTypes } = useServerInfo();
const { realm } = useRealm();
const pages = componentTypes?.[PAGE_PROVIDER];
const navigate = useNavigate();
const { id, providerId } = useParams<PageParams>();
const { addAlert, addError } = useAlerts();

const page = pages?.find((p) => p.id === providerId);
if (!page) {
throw new Error(t("notFound"));
}

const [toggleDeleteDialog, DeleteConfirm] = useConfirmDialog({
titleKey: "itemDeleteConfirmTitle",
messageKey: "itemDeleteConfirm",
continueButtonLabel: "delete",
continueButtonVariant: ButtonVariant.danger,
onConfirm: async () => {
try {
await adminClient.components.del({
id: id!,
});
addAlert(t("itemDeletedSuccess"));
navigate(toPage({ realm, providerId: providerId! }));
} catch (error) {
addError("itemSaveError", error);
}
},
});
return (
<>
<DeleteConfirm />
<ViewHeader
titleKey={id || t("createItem")}
dropdownItems={
id
? [
<DropdownItem
data-testid="delete-item"
key="delete"
onClick={() => toggleDeleteDialog()}
>
{t("delete")}
</DropdownItem>,
]
: undefined
}
/>
<PageHandler providerType={PAGE_PROVIDER} id={id} page={page} />
</>
);
}
Loading

0 comments on commit ba345a1

Please sign in to comment.