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
- 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
run: ./import.js

5
.gitignore vendored
View file

@ -128,8 +128,9 @@ sketch
# snowpack
web_modules/
build/
.build/
build/src
build/target
.build/target
public/assets/
# 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",
],
buildOptions: {
baseUrl: "/adminv2",
baseUrl: "./",
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 { Link, useHistory } from "react-router-dom";
import { useAdminClient } from "./context/auth/AdminClient";
import { resourceUri } from "./util";
export const Header = () => {
const adminClient = useAdminClient();
@ -126,7 +127,7 @@ export const Header = () => {
<UserDropdown />
</PageHeaderToolsItem>
</PageHeaderToolsGroup>
<Avatar src="./img_avatar.svg" alt="Avatar image" />
<Avatar src={resourceUri + "/img_avatar.svg"} alt="Avatar image" />
</PageHeaderTools>
);
};
@ -180,7 +181,7 @@ export const Header = () => {
logo={
<Link to="/">
<Brand
src="./logo.svg"
src={resourceUri + "/logo.svg"}
id="masthead-logo"
alt="Logo"
className="keycloak__pageheader_brand"

View file

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

View file

@ -1,51 +1,25 @@
import KcAdminClient from "keycloak-admin";
import { homeRealm, isDevMode, authUri } from "../../util";
export default async function (): Promise<KcAdminClient> {
const realm =
new URLSearchParams(window.location.search).get("realm") || "master";
const kcAdminClient = new KcAdminClient();
try {
await kcAdminClient.init(
{ onLoad: "check-sso", pkceMethod: "S256" },
{
url: keycloakAuthUrl(),
realm: realm,
clientId: "security-admin-console-v2",
url: authUri(),
realm: homeRealm(),
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
const devMode = !window.location.pathname.startsWith("/adminv2");
kcAdminClient.baseUrl = devMode ? "/auth" : keycloakAuthUrl();
kcAdminClient.baseUrl = authUri();
} catch (error) {
alert("failed to initialize keycloak");
}
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 { AccessType } from "keycloak-admin/lib/defs/whoAmIRepresentation";
import { useAdminClient, useFetch } from "../auth/AdminClient";
import { homeRealm } from "../../util";
export class WhoAmI {
constructor(
private homeRealm?: string | undefined,
private me?: WhoAmIRepresentation | undefined
) {
constructor(private me?: WhoAmIRepresentation) {
if (this.me !== undefined && this.me.locale) {
i18n.changeLanguage(this.me.locale, (error) => {
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.
*/
public getHomeRealm(): string {
let realm: string | undefined = this.homeRealm;
if (realm === undefined) realm = this.me?.realm;
if (realm === undefined) realm = "master"; // this really can't happen in the real world
return realm;
return homeRealm();
}
public canCreateRealm(): boolean {
@ -72,7 +66,7 @@ export const WhoAmIContextProvider = ({ children }: WhoAmIProviderProps) => {
useFetch(
() => adminClient.whoAmI.find(),
(me) => {
const whoAmI = new WhoAmI(adminClient.keycloak?.realm, me);
const whoAmI = new WhoAmI(me);
setWhoAmI(whoAmI);
},
[key]

View file

@ -3,24 +3,22 @@ import { WhoAmI } from "../WhoAmI";
import type WhoAmIRepresentation from "keycloak-admin/lib/defs/whoAmIRepresentation";
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");
});
test("returns correct home realm", () => {
let whoami = new WhoAmI("myrealm", whoamiMock as WhoAmIRepresentation);
expect(whoami.getHomeRealm()).toEqual("myrealm");
whoami = new WhoAmI(undefined, whoamiMock as WhoAmIRepresentation);
test("returns correct home realm in dev mode", () => {
const whoami = new WhoAmI(whoamiMock as WhoAmIRepresentation);
expect(whoami.getHomeRealm()).toEqual("master");
});
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);
});
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(whoami.getRealmAccess()["master"].length).toEqual(18);
});

View file

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