Run as theme (#840)

* Build for Keycloak theme

* Update based on lastest index.html changes.

* Fix realm dropdown when home realm is not master.

* Fix readme.

* Fix linting errors.

* Try to fix tests.

* Address Jon's comments.
This commit is contained in:
Stan Silvert 2021-07-14 11:35:49 -04:00 committed by GitHub
parent 0930778390
commit d606dc6bee
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
13 changed files with 172 additions and 132 deletions

View file

@ -30,7 +30,7 @@ jobs:
run: ./start.js & sleep 40 run: ./start.js & sleep 40
- name: Run Admin Console - name: Run Admin Console
run: npx http-server ./build -P http://localhost:8180/ & sleep 30 run: npx http-server ./build/src/main/resources/admin/resources -P http://localhost:8180/ & sleep 30
- name: Admin Console client - name: Admin Console client
run: ./import.js run: ./import.js

5
.gitignore vendored
View file

@ -128,8 +128,9 @@ sketch
# snowpack # snowpack
web_modules/ web_modules/
build/ build/src
.build/ build/target
.build/target
public/assets/ public/assets/
# server-install # server-install

27
build/README.md Normal file
View file

@ -0,0 +1,27 @@
# Keycloak Admin Console V2 Maven Build
The Maven build prepares the admin console to be deployed as a theme on the Keycloak server.
# Build Instructions
```bash
$> npm run build
$> cd build
$> mvn install
```
# Deployment
The jar created with `mvn install` needs to be deployed to a Maven repository. From there, it will become part of the Keycloak server build.
For development, you can also just copy the contents of `build/target/classes` to `<keycloak server>/themes/keycloak.v2`. Then restart the server.
# To Run
Until New Admin Console becomes the default, you will need to start Keycloak server like this:
```bash
$> bin/standalone.sh -Dprofile.feature.newadmin=enabled
```
Then go to `Realm Settings --> Themes` and set Admin Console Theme to `keycloak.v2`.

92
build/pom.xml Normal file
View file

@ -0,0 +1,92 @@
<?xml version="1.0" encoding="UTF-8"?>
<!--
JBoss, Home of Professional Open Source
Copyright 2016, Red Hat, Inc. and/or its affiliates, and individual
contributors by the @authors tag. See the copyright.txt in the
distribution for a full listing of individual contributors.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
-->
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/maven-v4_0_0.xsd">
<modelVersion>4.0.0</modelVersion>
<groupId>org.keycloak</groupId>
<artifactId>keycloak-admin-ui</artifactId>
<version>0.0.1-SNAPSHOT</version>
<name>Keycloak Administration UI</name>
<licenses>
<license>
<name>Apache License, Version 2.0.0</name>
<url>http://www.apache.org/licenses/LICENSE-2.0</url>
<distribution>repo</distribution>
</license>
</licenses>
<repositories>
<repository>
<id>jboss</id>
<url>https://repository.jboss.org/nexus/content/groups/public/</url>
<snapshots>
<enabled>false</enabled>
</snapshots>
</repository>
</repositories>
<build>
<plugins>
<plugin>
<groupId>com.google.code.maven-replacer-plugin</groupId>
<artifactId>maven-replacer-plugin</artifactId>
<executions>
<execution>
<phase>prepare-package</phase>
<goals>
<goal>replace</goal>
</goals>
</execution>
</executions>
<configuration>
<file>target/classes/admin/resources/index.html</file>
<outputFile>target/classes/admin/index.ftl</outputFile>
<regex>false</regex>
<replacements>
<replacement>
<token>src="/_dist_</token>
<value>src="${resourceUrl}/_dist_</value>
</replacement>
<replacement>
<token>href="./</token>
<value>href="${resourceUrl}/</value>
</replacement>
<replacement>
<token>&lt;head&gt;</token>
<value>
&lt;head&gt;
&lt;script type="text/javascript"&gt;
var loginRealm = "${loginRealm}";
var authServerUrl = "${authServerUrl}";
var authUrl = "${authUrl}";
var consoleBaseUrl = "${consoleBaseUrl}";
var resourceUrl = "${resourceUrl}";
var masterRealm = "${masterRealm}";
var resourceVersion = "${resourceVersion}";
&lt;/script&gt;
</value>
</replacement>
</replacements>
</configuration>
</plugin>
</plugins>
</build>
</project>

View file

@ -1,67 +0,0 @@
{
"clientId": "security-admin-console-v2",
"rootUrl": "",
"adminUrl": "",
"baseUrl": "",
"surrogateAuthRequired": false,
"enabled": true,
"alwaysDisplayInConsole": false,
"clientAuthenticatorType": "client-secret",
"redirectUris": [
"/adminv2/*",
"http://localhost:8080/*",
"http://localhost/*"
],
"webOrigins": [
"*"
],
"notBefore": 0,
"bearerOnly": false,
"consentRequired": false,
"standardFlowEnabled": true,
"implicitFlowEnabled": false,
"directAccessGrantsEnabled": true,
"serviceAccountsEnabled": false,
"publicClient": true,
"frontchannelLogout": false,
"protocol": "openid-connect",
"attributes": {
"saml.assertion.signature": "false",
"saml.force.post.binding": "false",
"saml.multivalued.roles": "false",
"saml.encrypt": "false",
"backchannel.logout.revoke.offline.tokens": "false",
"saml.server.signature": "false",
"saml.server.signature.keyinfo.ext": "false",
"exclude.session.state.from.auth.response": "false",
"backchannel.logout.session.required": "true",
"client_credentials.use_refresh_token": "false",
"saml_force_name_id_format": "false",
"saml.client.signature": "false",
"tls.client.certificate.bound.access.tokens": "false",
"saml.authnstatement": "false",
"display.on.consent.screen": "false",
"saml.onetimeuse.condition": "false"
},
"authenticationFlowBindingOverrides": {},
"fullScopeAllowed": true,
"nodeReRegistrationTimeout": -1,
"defaultClientScopes": [
"web-origins",
"role_list",
"roles",
"profile",
"email"
],
"optionalClientScopes": [
"address",
"phone",
"offline_access",
"microprofile-jwt"
],
"access": {
"view": true,
"configure": true,
"manage": true
}
}

View file

@ -9,7 +9,10 @@ module.exports = {
"@snowpack/plugin-typescript", "@snowpack/plugin-typescript",
], ],
buildOptions: { buildOptions: {
baseUrl: "/adminv2", baseUrl: "./",
clean: true, clean: true,
}, },
devOptions: {
out: "build/src/main/resources/admin/resources", // For snowpack 3, "out" goes under buildOptions.
},
}; };

View file

@ -18,6 +18,7 @@ import { WhoAmIContext } from "./context/whoami/WhoAmI";
import { HelpContext, HelpHeader } from "./components/help-enabler/HelpHeader"; import { HelpContext, HelpHeader } from "./components/help-enabler/HelpHeader";
import { Link, useHistory } from "react-router-dom"; import { Link, useHistory } from "react-router-dom";
import { useAdminClient } from "./context/auth/AdminClient"; import { useAdminClient } from "./context/auth/AdminClient";
import { resourceUri } from "./util";
export const Header = () => { export const Header = () => {
const adminClient = useAdminClient(); const adminClient = useAdminClient();
@ -126,7 +127,7 @@ export const Header = () => {
<UserDropdown /> <UserDropdown />
</PageHeaderToolsItem> </PageHeaderToolsItem>
</PageHeaderToolsGroup> </PageHeaderToolsGroup>
<Avatar src="./img_avatar.svg" alt="Avatar image" /> <Avatar src={resourceUri + "/img_avatar.svg"} alt="Avatar image" />
</PageHeaderTools> </PageHeaderTools>
); );
}; };
@ -180,7 +181,7 @@ export const Header = () => {
logo={ logo={
<Link to="/"> <Link to="/">
<Brand <Brand
src="./logo.svg" src={resourceUri + "/logo.svg"}
id="masthead-logo" id="masthead-logo"
alt="Logo" alt="Logo"
className="keycloak__pageheader_brand" className="keycloak__pageheader_brand"

View file

@ -16,7 +16,7 @@ describe("FormAccess", () => {
<WhoAmIContext.Provider <WhoAmIContext.Provider
value={{ value={{
refresh: () => {}, refresh: () => {},
whoAmI: new WhoAmI("master", whoami as WhoAmIRepresentation), whoAmI: new WhoAmI(whoami as WhoAmIRepresentation),
}} }}
> >
<RealmContext.Provider <RealmContext.Provider

View file

@ -1,51 +1,25 @@
import KcAdminClient from "keycloak-admin"; import KcAdminClient from "keycloak-admin";
import { homeRealm, isDevMode, authUri } from "../../util";
export default async function (): Promise<KcAdminClient> { export default async function (): Promise<KcAdminClient> {
const realm =
new URLSearchParams(window.location.search).get("realm") || "master";
const kcAdminClient = new KcAdminClient(); const kcAdminClient = new KcAdminClient();
try { try {
await kcAdminClient.init( await kcAdminClient.init(
{ onLoad: "check-sso", pkceMethod: "S256" }, { onLoad: "check-sso", pkceMethod: "S256" },
{ {
url: keycloakAuthUrl(), url: authUri(),
realm: realm, realm: homeRealm(),
clientId: "security-admin-console-v2", clientId: isDevMode
? "security-admin-console-v2"
: "security-admin-console",
} }
); );
kcAdminClient.setConfig({ realmName: realm }); kcAdminClient.setConfig({ realmName: homeRealm() });
// we can get rid of devMode once developers upgrade to Keycloak 13 kcAdminClient.baseUrl = authUri();
const devMode = !window.location.pathname.startsWith("/adminv2");
kcAdminClient.baseUrl = devMode ? "/auth" : keycloakAuthUrl();
} catch (error) { } catch (error) {
alert("failed to initialize keycloak"); alert("failed to initialize keycloak");
} }
return kcAdminClient; return kcAdminClient;
} }
const keycloakAuthUrl = () => {
// Eventually, authContext should not be hard-coded.
// You are allowed to change this context on your keycloak server,
// but it is rarely done.
const authContext = "/auth";
const searchParams = new URLSearchParams(window.location.search);
// passed in as query param
const authUrlFromParam = searchParams.get("keycloak-server");
if (authUrlFromParam) return authUrlFromParam + authContext;
// dev mode
if (!window.location.pathname.startsWith("/adminv2"))
return "http://localhost:8180" + authContext;
// demo mode
if (searchParams.get("demo")) return "http://localhost:8080" + authContext;
// admin console served from keycloak server
return window.location.origin + authContext;
};

View file

@ -4,12 +4,10 @@ import i18n from "../../i18n";
import type WhoAmIRepresentation from "keycloak-admin/lib/defs/whoAmIRepresentation"; import type WhoAmIRepresentation from "keycloak-admin/lib/defs/whoAmIRepresentation";
import type { AccessType } from "keycloak-admin/lib/defs/whoAmIRepresentation"; import type { AccessType } from "keycloak-admin/lib/defs/whoAmIRepresentation";
import { useAdminClient, useFetch } from "../auth/AdminClient"; import { useAdminClient, useFetch } from "../auth/AdminClient";
import { homeRealm } from "../../util";
export class WhoAmI { export class WhoAmI {
constructor( constructor(private me?: WhoAmIRepresentation) {
private homeRealm?: string | undefined,
private me?: WhoAmIRepresentation | undefined
) {
if (this.me !== undefined && this.me.locale) { if (this.me !== undefined && this.me.locale) {
i18n.changeLanguage(this.me.locale, (error) => { i18n.changeLanguage(this.me.locale, (error) => {
if (error) console.error("Unable to set locale to", this.me?.locale); if (error) console.error("Unable to set locale to", this.me?.locale);
@ -33,11 +31,7 @@ export class WhoAmI {
* Return the realm I am signed in to. * Return the realm I am signed in to.
*/ */
public getHomeRealm(): string { public getHomeRealm(): string {
let realm: string | undefined = this.homeRealm; return homeRealm();
if (realm === undefined) realm = this.me?.realm;
if (realm === undefined) realm = "master"; // this really can't happen in the real world
return realm;
} }
public canCreateRealm(): boolean { public canCreateRealm(): boolean {
@ -72,7 +66,7 @@ export const WhoAmIContextProvider = ({ children }: WhoAmIProviderProps) => {
useFetch( useFetch(
() => adminClient.whoAmI.find(), () => adminClient.whoAmI.find(),
(me) => { (me) => {
const whoAmI = new WhoAmI(adminClient.keycloak?.realm, me); const whoAmI = new WhoAmI(me);
setWhoAmI(whoAmI); setWhoAmI(whoAmI);
}, },
[key] [key]

View file

@ -3,24 +3,22 @@ import { WhoAmI } from "../WhoAmI";
import type WhoAmIRepresentation from "keycloak-admin/lib/defs/whoAmIRepresentation"; import type WhoAmIRepresentation from "keycloak-admin/lib/defs/whoAmIRepresentation";
test("returns display name", () => { test("returns display name", () => {
const whoami = new WhoAmI("master", whoamiMock as WhoAmIRepresentation); const whoami = new WhoAmI(whoamiMock as WhoAmIRepresentation);
expect(whoami.getDisplayName()).toEqual("Stan Silvert"); expect(whoami.getDisplayName()).toEqual("Stan Silvert");
}); });
test("returns correct home realm", () => { test("returns correct home realm in dev mode", () => {
let whoami = new WhoAmI("myrealm", whoamiMock as WhoAmIRepresentation); const whoami = new WhoAmI(whoamiMock as WhoAmIRepresentation);
expect(whoami.getHomeRealm()).toEqual("myrealm");
whoami = new WhoAmI(undefined, whoamiMock as WhoAmIRepresentation);
expect(whoami.getHomeRealm()).toEqual("master"); expect(whoami.getHomeRealm()).toEqual("master");
}); });
test("can not create realm", () => { test("can not create realm", () => {
const whoami = new WhoAmI("master", whoamiMock as WhoAmIRepresentation); const whoami = new WhoAmI(whoamiMock as WhoAmIRepresentation);
expect(whoami.canCreateRealm()).toEqual(false); expect(whoami.canCreateRealm()).toEqual(false);
}); });
test("getRealmAccess", () => { test("getRealmAccess", () => {
const whoami = new WhoAmI("master", whoamiMock as WhoAmIRepresentation); const whoami = new WhoAmI(whoamiMock as WhoAmIRepresentation);
expect(Object.keys(whoami.getRealmAccess()).length).toEqual(3); expect(Object.keys(whoami.getRealmAccess()).length).toEqual(3);
expect(whoami.getRealmAccess()["master"].length).toEqual(18); expect(whoami.getRealmAccess()["master"].length).toEqual(18);
}); });

View file

@ -1,6 +1,5 @@
import { import {
Brand, Brand,
Button,
Card, Card,
CardBody, CardBody,
CardTitle, CardTitle,
@ -29,17 +28,17 @@ import { useRealm } from "../context/realm-context/RealmContext";
import { useServerInfo } from "../context/server-info/ServerInfoProvider"; import { useServerInfo } from "../context/server-info/ServerInfoProvider";
import "./dashboard.css"; import "./dashboard.css";
import { toUpperCase } from "../util"; import { toUpperCase, resourceUri } from "../util";
import { HelpItem } from "../components/help-enabler/HelpItem"; import { HelpItem } from "../components/help-enabler/HelpItem";
const EmptyDashboard = () => { const EmptyDashboard = () => {
const { t } = useTranslation("dashboard"); const { t } = useTranslation("dashboard");
const { realm, setRealm } = useRealm(); const { realm } = useRealm();
return ( return (
<PageSection variant="light"> <PageSection variant="light">
<EmptyState variant="large"> <EmptyState variant="large">
<Brand <Brand
src="./icon.svg" src={resourceUri + "/icon.svg"}
alt="Keycloak icon" alt="Keycloak icon"
className="keycloak__dashboard_icon" className="keycloak__dashboard_icon"
/> />
@ -50,9 +49,6 @@ const EmptyDashboard = () => {
{realm} {realm}
</Title> </Title>
<EmptyStateBody>{t("introduction")}</EmptyStateBody> <EmptyStateBody>{t("introduction")}</EmptyStateBody>
<Button variant="link" onClick={() => setRealm("master")}>
{t("common:realmInfo")}
</Button>
</EmptyState> </EmptyState>
</PageSection> </PageSection>
); );

View file

@ -5,6 +5,27 @@ import type ClientRepresentation from "keycloak-admin/lib/defs/clientRepresentat
import type { ProviderRepresentation } from "keycloak-admin/lib/defs/serverInfoRepesentation"; import type { ProviderRepresentation } from "keycloak-admin/lib/defs/serverInfoRepesentation";
import type KeycloakAdminClient from "keycloak-admin"; import type KeycloakAdminClient from "keycloak-admin";
// if we are running on Keycloak server, resourceUrl will be passed from index.ftl
declare const resourceUrl: string;
export const isDevMode = typeof resourceUrl === "undefined";
export const resourceUri = isDevMode ? "." : resourceUrl;
// if we are running on Keycloak server, loginRealm will be passed from index.ftl
declare const loginRealm: string;
export const homeRealm = () => {
if (typeof loginRealm !== "undefined") return loginRealm;
return new URLSearchParams(window.location.search).get("realm") || "master";
};
// if we are running on Keycloak server, authUrl will be passed from index.ftl
declare const authUrl: string;
export const authUri = () => {
if (typeof authUrl !== "undefined") return authUrl;
return "http://localhost:8180/auth";
};
export const sortProviders = (providers: { export const sortProviders = (providers: {
[index: string]: ProviderRepresentation; [index: string]: ProviderRepresentation;
}) => { }) => {