Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add a way to extend the UI with an Java API #23772

Merged
merged 9 commits into from Jan 22, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
3 changes: 3 additions & 0 deletions common/src/main/java/org/keycloak/common/Profile.java
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
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
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
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
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
@@ -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
@@ -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} />
</>
);
}