Run the Vite dev server through the Keycloak server (#27311)

Closes #19750
Closes #28643
Closes #30115

Signed-off-by: Jon Koops <jonkoops@gmail.com>
This commit is contained in:
Jon Koops 2024-06-12 11:55:14 +02:00 committed by GitHub
parent 04b16a914c
commit c7361ccf6e
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
47 changed files with 578 additions and 1262 deletions

View file

@ -182,8 +182,6 @@ jobs:
- name: Run Playwright tests
run: pnpm --fail-if-no-match --filter ${{ env.WORKSPACE }} test
env:
KEYCLOAK_SERVER: http://localhost:8080
- name: Upload Playwright report
uses: actions/upload-artifact@v4
@ -285,7 +283,6 @@ jobs:
working-directory: js/apps/admin-ui
env:
CYPRESS_BASE_URL: http://localhost:8080/admin/
CYPRESS_KEYCLOAK_SERVER: http://localhost:8080
SPLIT: ${{ strategy.job-total }}
SPLIT_INDEX: ${{ strategy.job-index }}
SPLIT_RANDOM_SEED: ${{ needs.generate-test-seed.outputs.seed }}

View file

@ -29,6 +29,10 @@ public class Environment {
public static final int DEFAULT_JBOSS_AS_STARTUP_TIMEOUT = 300;
public static final String PROFILE = "kc.profile";
public static final String ENV_PROFILE = "KC_PROFILE";
public static final String DEV_PROFILE_VALUE = "dev";
public static int getServerStartupTimeout() {
String timeout = System.getProperty("jboss.as.management.blocking.timeout");
if (timeout != null) {
@ -57,4 +61,17 @@ public class Environment {
return false;
}
public static boolean isDevMode() {
return DEV_PROFILE_VALUE.equalsIgnoreCase(getProfile());
}
public static String getProfile() {
String profile = System.getProperty(PROFILE);
if (profile != null) {
return profile;
}
return System.getenv(ENV_PROFILE);
}
}

View file

@ -54,6 +54,10 @@
<groupId>com.fasterxml.jackson.core</groupId>
<artifactId>jackson-databind</artifactId>
</dependency>
<dependency>
<groupId>com.fasterxml.jackson.datatype</groupId>
<artifactId>jackson-datatype-jdk8</artifactId>
</dependency>
<dependency>
<groupId>org.jboss.logging</groupId>
<artifactId>jboss-logging</artifactId>

View file

@ -24,6 +24,7 @@ import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.databind.SerializationFeature;
import com.fasterxml.jackson.databind.node.ObjectNode;
import com.fasterxml.jackson.datatype.jdk8.Jdk8Module;
import java.io.IOException;
import java.io.InputStream;
@ -41,6 +42,7 @@ public class JsonSerialization {
public static final ObjectMapper sysPropertiesAwareMapper = new ObjectMapper(new SystemPropertiesJsonParserFactory());
static {
mapper.registerModule(new Jdk8Module());
mapper.configure(SerializationFeature.FAIL_ON_EMPTY_BEANS, false);
mapper.setSerializationInclusion(JsonInclude.Include.NON_NULL);
prettyMapper.configure(SerializationFeature.FAIL_ON_EMPTY_BEANS, false);

View file

@ -1,63 +0,0 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<base href="./" />
<link rel="icon" type="image/svg+xml" href="./favicon.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="description" content="Web site to manage keycloak" />
<title>Account Management</title>
<style>
body {
margin: 0;
}
body, #app {
height: 100%;
}
.container {
padding: 0;
margin: 0;
width: 100%;
}
.keycloak__loading-container {
height: 100vh;
width: 100%;
background-color: #f0f0f0;
display: flex;
align-items: center;
justify-content: center;
flex-direction: column;
margin: 0;
}
#loading-text {
z-index: 1000;
font-size: 20px;
font-weight: 600;
padding-top: 32px;
}
</style>
</head>
<body>
<div id="app">
<main class="container">
<div class="keycloak__loading-container">
<span class="pf-c-spinner pf-m-xl" role="progressbar" aria-valuetext="Loading...">
<span class="pf-c-spinner__clipper"></span>
<span class="pf-c-spinner__lead-ball"></span>
<span class="pf-c-spinner__tail-ball"></span>
</span>
<div>
<p id="loading-text">Loading the account console</p>
</div>
</div>
</main>
</div>
<noscript>You need to enable JavaScript to run this app.</noscript>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

View file

@ -0,0 +1,139 @@
<!doctype html>
<html lang="${locale}">
<head>
<meta charset="utf-8">
<base href="${resourceUrl}/">
<link rel="icon" type="${properties.favIconType!'image/svg+xml'}" href="${resourceUrl}${properties.favIcon!'/favicon.svg'}">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="description" content="${properties.description!'The Account Console is a web-based interface for managing your account.'}">
<title>${properties.title!'Account Management'}</title>
<style>
body {
margin: 0;
}
body, #app {
height: 100%;
}
.container {
padding: 0;
margin: 0;
width: 100%;
}
.keycloak__loading-container {
height: 100vh;
width: 100%;
background-color: #f0f0f0;
display: flex;
align-items: center;
justify-content: center;
flex-direction: column;
margin: 0;
}
#loading-text {
z-index: 1000;
font-size: 20px;
font-weight: 600;
padding-top: 32px;
}
</style>
<script type="importmap">
{
"imports": {
"react": "${resourceCommonUrl}/vendor/react/react.production.min.js",
"react/jsx-runtime": "${resourceCommonUrl}/vendor/react/react-jsx-runtime.production.min.js",
"react-dom": "${resourceCommonUrl}/vendor/react-dom/react-dom.production.min.js"
}
}
</script>
<#if devServerUrl?has_content>
<script type="module">
import { injectIntoGlobalHook } from "${devServerUrl}/@react-refresh";
injectIntoGlobalHook(window);
window.$RefreshReg$ = () => {};
window.$RefreshSig$ = () => (type) => type;
</script>
<script type="module">
import { inject } from "${devServerUrl}/@vite-plugin-checker-runtime";
inject({
overlayConfig: {},
base: "/",
});
</script>
<script type="module" src="${devServerUrl}/@vite/client"></script>
<script type="module" src="${devServerUrl}/src/main.tsx"></script>
</#if>
<#if entryStyles?has_content>
<#list entryStyles as style>
<link rel="stylesheet" href="${resourceUrl}/${style}">
</#list>
</#if>
<#if properties.styles?has_content>
<#list properties.styles?split(' ') as style>
<link rel="stylesheet" href="${resourceUrl}/${style}">
</#list>
</#if>
<#if entryScript?has_content>
<script type="module" src="${resourceUrl}/${entryScript}"></script>
</#if>
<#if properties.scripts?has_content>
<#list properties.scripts?split(' ') as script>
<script type="module" src="${resourceUrl}/${script}"></script>
</#list>
</#if>
<#if entryImports?has_content>
<#list entryImports as import>
<link rel="modulepreload" href="${resourceUrl}/${import}">
</#list>
</#if>
</head>
<body>
<div id="app">
<main class="container">
<div class="keycloak__loading-container">
<span class="pf-c-spinner pf-m-xl" role="progressbar" aria-valuetext="Loading&hellip;">
<span class="pf-c-spinner__clipper"></span>
<span class="pf-c-spinner__lead-ball"></span>
<span class="pf-c-spinner__tail-ball"></span>
</span>
<div>
<p id="loading-text">Loading the Account Console</p>
</div>
</div>
</main>
</div>
<noscript>JavaScript is required to use the Account Console.</noscript>
<script id="environment" type="application/json">
{
"authUrl": "${authUrl}",
"authServerUrl": "${authServerUrl}",
"realm": "${realm.name}",
"clientId": "${clientId}",
"resourceUrl": "${resourceUrl}",
"logo": "${properties.logo!""}",
"logoUrl": "${properties.logoUrl!""}",
"baseUrl": "${baseUrl}",
"locale": "${locale}",
"referrerName": "${referrerName!""}",
"referrerUrl": "${referrer_uri!""}",
"features": {
"isRegistrationEmailAsUsername": ${realm.registrationEmailAsUsername?c},
"isEditUserNameAllowed": ${realm.editUsernameAllowed?c},
"isInternationalizationEnabled": ${realm.isInternationalizationEnabled()?c},
"isLinkedAccountsEnabled": ${realm.identityFederationEnabled?c},
"isMyResourcesEnabled": ${(realm.userManagedAccessAllowed && isAuthorizationEnabled)?c},
"deleteAccountAllowed": ${deleteAccountAllowed?c},
"updateEmailFeatureEnabled": ${updateEmailFeatureEnabled?c},
"updateEmailActionEnabled": ${updateEmailActionEnabled?c},
"isViewGroupsEnabled": ${isViewGroupsEnabled?c},
"isOid4VciEnabled": ${isOid4VciEnabled?c}
}
}
</script>
</body>
</html>

View file

@ -1,5 +1,6 @@
import { defineConfig, devices } from "@playwright/test";
import { getRootPath } from "./src/utils/getRootPath";
import { getAccountUrl } from "./test/utils";
/**
* See https://playwright.dev/docs/test-configuration.
@ -16,7 +17,7 @@ export default defineConfig({
},
use: {
baseURL: `http://localhost:8080${getRootPath()}`,
baseURL: getAccountUrl(),
trace: "on-first-retry",
},

View file

@ -94,110 +94,6 @@
</execution>
</executions>
</plugin>
<plugin>
<groupId>com.google.code.maven-replacer-plugin</groupId>
<artifactId>maven-replacer-plugin</artifactId>
<executions>
<execution>
<phase>process-resources</phase>
<goals>
<goal>replace</goal>
</goals>
</execution>
</executions>
<configuration>
<file>dist/index.html</file>
<outputFile>target/classes/theme/keycloak.v3/account/index.ftl</outputFile>
<regex>false</regex>
<replacements>
<replacement>
<token>src="./</token>
<value>src="${resourceUrl}/</value>
</replacement>
<replacement>
<token>href="./</token>
<value>href="${resourceUrl}/</value>
</replacement>
<replacement>
<token><![CDATA[<link rel="icon" type="image/svg+xml" href="${resourceUrl}/favicon.svg" />]]></token>
<value xml:space="preserve"><![CDATA[<link rel="icon" type="${properties.favIconType!"image/svg+xml"}" href="${resourceUrl}${properties.favIcon!"/favicon.svg"}" />]]></value>
</replacement>
<replacement>
<token><![CDATA[<meta name="description" content="Web site to manage keycloak" />]]></token>
<value xml:space="preserve"><![CDATA[<meta name="description" content="${properties.description!"Web site to manage keycloak"}" />]]></value>
</replacement>
<replacement>
<token><![CDATA[<title>Account Management</title>]]></token>
<value xml:space="preserve">
<![CDATA[
<title>${properties.title!"Account Management"}</title>
<script type="importmap">
{
"imports": {
"react": "${resourceCommonUrl}/vendor/react/react.production.min.js",
"react/jsx-runtime": "${resourceCommonUrl}/vendor/react/react-jsx-runtime.production.min.js",
"react-dom": "${resourceCommonUrl}/vendor/react-dom/react-dom.production.min.js"
}
}
</script>
]]></value>
</replacement>
<replacement>
<token><![CDATA[</body>]]></token>
<value xml:space="preserve">
<![CDATA[
<script id="environment" type="application/json">
{
"authUrl": "${authUrl}",
"authServerUrl": "${authServerUrl}",
"realm": "${realm.name}",
"clientId": "${clientId}",
"resourceUrl": "${resourceUrl}",
"logo": "${properties.logo!""}",
"logoUrl": "${properties.logoUrl!""}",
"baseUrl": "${baseUrl}",
"locale": "${locale}",
"referrerName": "${referrerName!""}",
"referrerUrl": "${referrer_uri!""}",
"features": {
"isRegistrationEmailAsUsername": ${realm.registrationEmailAsUsername?c},
"isEditUserNameAllowed": ${realm.editUsernameAllowed?c},
"isInternationalizationEnabled": ${realm.isInternationalizationEnabled()?c},
"isLinkedAccountsEnabled": ${realm.identityFederationEnabled?c},
"isMyResourcesEnabled": ${(realm.userManagedAccessAllowed && isAuthorizationEnabled)?c},
"deleteAccountAllowed": ${deleteAccountAllowed?c},
"updateEmailFeatureEnabled": ${updateEmailFeatureEnabled?c},
"updateEmailActionEnabled": ${updateEmailActionEnabled?c},
"isViewGroupsEnabled": ${isViewGroupsEnabled?c},
"isOid4VciEnabled": ${isOid4VciEnabled?c}
}
}
</script>
</body>
]]>
</value>
</replacement>
<replacement>
<token><![CDATA[</head>]]></token>
<value xml:space="preserve">
<![CDATA[
<#if properties.scripts?has_content>
<#list properties.scripts?split(' ') as script>
<script src="${resourceUrl}/${script}"></script>
</#list>
</#if>
<#if properties.styles?has_content>
<#list properties.styles?split(' ') as style>
<link href="${resourceUrl}/${style}" rel="stylesheet"/>
</#list>
</#if>
</head>
]]>
</value>
</replacement>
</replacements>
</configuration>
</plugin>
</plugins>
</build>
</project>

View file

@ -1,5 +0,0 @@
import { generatePath } from "react-router-dom";
import { DEFAULT_REALM, ROOT_PATH } from "../constants";
export const getRootPath = (realm = DEFAULT_REALM) =>
generatePath(ROOT_PATH, { realm });

View file

@ -13,8 +13,8 @@ import {
findClientByClientId,
inRealm,
} from "../admin-client";
import { SERVER_URL } from "../constants";
import groupsIdPClient from "../realms/groups-idp.json" assert { type: "json" };
import { getKeycloakServerUrl } from "../utils";
const realm = "groups";
@ -32,7 +32,6 @@ test.describe("Account linking", () => {
groupIdPClientId = await createClient(
groupsIdPClient as ClientRepresentation,
);
const baseUrl = getKeycloakServerUrl();
const idp: IdentityProviderRepresentation = {
alias: "master-idp",
providerId: "oidc",
@ -41,12 +40,12 @@ test.describe("Account linking", () => {
clientId: "groups-idp",
clientSecret: "H0JaTc7VBu3HJR26vrzMxgidfJmgI5Dw",
validateSignature: "false",
tokenUrl: `${baseUrl}/realms/master/protocol/openid-connect/token`,
jwksUrl: `${baseUrl}/realms/master/protocol/openid-connect/certs`,
issuer: `${baseUrl}/realms/master`,
authorizationUrl: `${baseUrl}/realms/master/protocol/openid-connect/auth`,
logoutUrl: `${baseUrl}/realms/master/protocol/openid-connect/logout`,
userInfoUrl: `${baseUrl}/realms/master/protocol/openid-connect/userinfo`,
tokenUrl: `${SERVER_URL}/realms/master/protocol/openid-connect/token`,
jwksUrl: `${SERVER_URL}/realms/master/protocol/openid-connect/certs`,
issuer: `${SERVER_URL}/realms/master`,
authorizationUrl: `${SERVER_URL}/realms/master/protocol/openid-connect/auth`,
logoutUrl: `${SERVER_URL}/realms/master/protocol/openid-connect/logout`,
userInfoUrl: `${SERVER_URL}/realms/master/protocol/openid-connect/userinfo`,
},
};

View file

@ -5,11 +5,10 @@ import RealmRepresentation from "@keycloak/keycloak-admin-client/lib/defs/realmR
import type { UserProfileConfig } from "@keycloak/keycloak-admin-client/lib/defs/userProfileMetadata";
import UserRepresentation from "@keycloak/keycloak-admin-client/lib/defs/userRepresentation";
import { DEFAULT_REALM } from "../src/constants";
import { getKeycloakServerUrl } from "./utils";
import { DEFAULT_REALM, SERVER_URL } from "./constants";
const adminClient = new KeycloakAdminClient({
baseUrl: getKeycloakServerUrl(),
baseUrl: SERVER_URL,
realmName: DEFAULT_REALM,
});

View file

@ -1,12 +1,11 @@
import { expect, test } from "@playwright/test";
import { getRootPath } from "../src/utils/getRootPath";
import { login } from "./login";
import { getAccountUrl, getAdminUrl } from "./utils";
import { getAccountUrl, getAdminUrl, getRootPath } from "./utils";
test.describe("Applications test", () => {
test.beforeEach(async ({ page }) => {
// Sign out all devices before each test
await login(page, "admin", "admin");
await login(page);
await page.getByTestId("accountSecurity").click();
await page.getByTestId("account-security/device-activity").click();
@ -21,13 +20,13 @@ test.describe("Applications test", () => {
});
test("Single application", async ({ page }) => {
await login(page, "admin", "admin");
await login(page);
await page.getByTestId("applications").click();
await expect(page.getByTestId("applications-list-item")).toHaveCount(1);
await expect(page.getByTestId("applications-list-item")).toContainText(
process.env.CI ? "Account Console" : "security-admin-console-v2",
"Account Console",
);
});
@ -41,17 +40,15 @@ test.describe("Applications test", () => {
const page1 = await context1.newPage();
const page2 = await context2.newPage();
await login(page1, "admin", "admin");
await login(page2, "admin", "admin");
await login(page1);
await login(page2);
await page1.getByTestId("applications").click();
await expect(page1.getByTestId("applications-list-item")).toHaveCount(1);
await expect(
page1.getByTestId("applications-list-item").nth(0),
).toContainText(
process.env.CI ? "Account Console" : "security-admin-console-v2",
);
).toContainText("Account Console");
} finally {
await context1.close();
await context2.close();
@ -59,12 +56,7 @@ test.describe("Applications test", () => {
});
test("Two applications", async ({ page }) => {
test.skip(
!process.env.CI,
"Skip this test if not running with regular Keycloak",
);
await login(page, "admin", "admin");
await login(page);
// go to admin console
await page.goto("/");

View file

@ -1,4 +1,5 @@
export const DEFAULT_REALM = "master";
export const SERVER_URL = "http://localhost:8080";
export const ROOT_PATH = "/realms/:realm/account";
export const DEFAULT_REALM = "master";
export const ADMIN_USER = "admin";
export const ADMIN_PASSWORD = "admin";

View file

@ -1,11 +1,12 @@
import { Page } from "@playwright/test";
import { DEFAULT_REALM } from "../src/constants";
import { getRootPath } from "../src/utils/getRootPath";
import { ADMIN_PASSWORD, ADMIN_USER, DEFAULT_REALM } from "./constants";
import { getRootPath } from "./utils";
export const login = async (
page: Page,
username: string,
password: string,
username = ADMIN_USER,
password = ADMIN_PASSWORD,
realm = DEFAULT_REALM,
queryParams?: Record<string, string>,
) => {

View file

@ -1,10 +1,8 @@
import { expect, test } from "@playwright/test";
import { ADMIN_PASSWORD, ADMIN_USER, DEFAULT_REALM } from "./constants";
import { login } from "./login";
import { getAdminUrl } from "./utils";
import { ADMIN_PASSWORD, ADMIN_USER, DEFAULT_REALM } from "../src/constants";
// NOTE: This test suite will only pass when running a production build, as the referrer is extracted on the server side.
// This will change once https://github.com/keycloak/keycloak/pull/27311 has been merged.
test.describe("Signing in with referrer link", () => {
test("shows a referrer link when a matching client exists", async ({

View file

@ -1,18 +1,14 @@
import { getRootPath } from "../src/utils/getRootPath";
import { generatePath } from "react-router-dom";
function getTestServerUrl(): string {
return process.env.KEYCLOAK_SERVER ?? "http://localhost:8080";
}
export function getKeycloakServerUrl(): string {
// In CI, the Keycloak server is running in the same server as tested console, while in dev, it is running on a different port
return process.env.CI ? getTestServerUrl() : "http://localhost:8180";
}
import { DEFAULT_REALM, ROOT_PATH, SERVER_URL } from "./constants";
export function getAccountUrl() {
return getTestServerUrl() + getRootPath();
return SERVER_URL + getRootPath();
}
export function getAdminUrl() {
return getKeycloakServerUrl() + "/admin/master/console/";
return SERVER_URL + "/admin/master/console/";
}
export const getRootPath = (realm = DEFAULT_REALM) =>
generatePath(ROOT_PATH, { realm });

View file

@ -4,8 +4,6 @@ import { defineConfig, loadEnv } from "vite";
import { checker } from "vite-plugin-checker";
import dts from "vite-plugin-dts";
import { getRootPath } from "./src/utils/getRootPath";
// https://vitejs.dev/config/
export default defineConfig(({ mode }) => {
const env = loadEnv(mode, process.cwd(), "");
@ -28,8 +26,8 @@ export default defineConfig(({ mode }) => {
return {
base: "",
server: {
port: 8080,
open: getRootPath(),
origin: "http://localhost:5173",
port: 5173,
},
build: {
...lib,
@ -37,7 +35,9 @@ export default defineConfig(({ mode }) => {
target: "esnext",
modulePreload: false,
cssMinify: "lightningcss",
manifest: true,
rollupOptions: {
input: "src/main.tsx",
external,
},
},

View file

@ -1,17 +1,18 @@
import { SERVER_URL } from "../support/constants";
import LoginPage from "../support/pages/LoginPage";
import ListingPage from "../support/pages/admin-ui/ListingPage";
import Masthead from "../support/pages/admin-ui/Masthead";
import SidebarPage from "../support/pages/admin-ui/SidebarPage";
import LoginPage from "../support/pages/LoginPage";
import { keycloakBefore } from "../support/util/keycloak_hooks";
import ListingPage from "../support/pages/admin-ui/ListingPage";
import CreateProviderPage from "../support/pages/admin-ui/manage/identity_providers/CreateProviderPage";
import ModalUtils from "../support/util/ModalUtils";
import AddMapperPage from "../support/pages/admin-ui/manage/identity_providers/AddMapperPage";
import ProviderBaseGeneralSettingsPage from "../support/pages/admin-ui/manage/identity_providers/ProviderBaseGeneralSettingsPage";
import CreateProviderPage from "../support/pages/admin-ui/manage/identity_providers/CreateProviderPage";
import ProviderBaseAdvancedSettingsPage, {
ClientAssertionSigningAlg,
ClientAuthentication,
PromptSelect,
} from "../support/pages/admin-ui/manage/identity_providers/ProviderBaseAdvancedSettingsPage";
import ProviderBaseGeneralSettingsPage from "../support/pages/admin-ui/manage/identity_providers/ProviderBaseGeneralSettingsPage";
import ModalUtils from "../support/util/ModalUtils";
import { keycloakBefore } from "../support/util/keycloak_hooks";
describe("OIDC identity provider test", () => {
const loginPage = new LoginPage();
@ -27,9 +28,8 @@ describe("OIDC identity provider test", () => {
const deletePrompt = "Delete provider?";
const deleteSuccessMsg = "Provider successfully deleted.";
const keycloakServer = Cypress.env("KEYCLOAK_SERVER");
const discoveryUrl = `${keycloakServer}/realms/master/.well-known/openid-configuration`;
const authorizationUrl = `${keycloakServer}/realms/master/protocol/openid-connect/auth`;
const discoveryUrl = `${SERVER_URL}/realms/master/.well-known/openid-configuration`;
const authorizationUrl = `${SERVER_URL}/realms/master/protocol/openid-connect/auth`;
describe("OIDC Identity provider creation", () => {
const oidcProviderName = "oidc";

View file

@ -1,12 +1,13 @@
import { SERVER_URL } from "../support/constants";
import LoginPage from "../support/pages/LoginPage";
import ListingPage from "../support/pages/admin-ui/ListingPage";
import Masthead from "../support/pages/admin-ui/Masthead";
import SidebarPage from "../support/pages/admin-ui/SidebarPage";
import LoginPage from "../support/pages/LoginPage";
import { keycloakBefore } from "../support/util/keycloak_hooks";
import ListingPage from "../support/pages/admin-ui/ListingPage";
import CreateProviderPage from "../support/pages/admin-ui/manage/identity_providers/CreateProviderPage";
import ModalUtils from "../support/util/ModalUtils";
import AddMapperPage from "../support/pages/admin-ui/manage/identity_providers/AddMapperPage";
import CreateProviderPage from "../support/pages/admin-ui/manage/identity_providers/CreateProviderPage";
import ProviderSAMLSettings from "../support/pages/admin-ui/manage/identity_providers/social/ProviderSAMLSettings";
import ModalUtils from "../support/util/ModalUtils";
import { keycloakBefore } from "../support/util/keycloak_hooks";
describe("SAML identity provider test", () => {
const loginPage = new LoginPage();
@ -28,8 +29,7 @@ describe("SAML identity provider test", () => {
const classRefName = "acClassRef-1";
const declRefName = "acDeclRef-1";
const keycloakServer = Cypress.env("KEYCLOAK_SERVER");
const samlDiscoveryUrl = `${keycloakServer}/realms/master/protocol/saml/descriptor`;
const samlDiscoveryUrl = `${SERVER_URL}/realms/master/protocol/saml/descriptor`;
const samlDisplayName = "saml";
describe("SAML identity provider creation", () => {

View file

@ -1,10 +1,11 @@
import { v4 as uuid } from "uuid";
import SidebarPage from "../support/pages/admin-ui/SidebarPage";
import { SERVER_URL } from "../support/constants";
import LoginPage from "../support/pages/LoginPage";
import RealmSettingsPage from "../support/pages/admin-ui/manage/realm_settings/RealmSettingsPage";
import Masthead from "../support/pages/admin-ui/Masthead";
import { keycloakBefore } from "../support/util/keycloak_hooks";
import SidebarPage from "../support/pages/admin-ui/SidebarPage";
import RealmSettingsPage from "../support/pages/admin-ui/manage/realm_settings/RealmSettingsPage";
import adminClient from "../support/util/AdminClient";
import { keycloakBefore } from "../support/util/keycloak_hooks";
const loginPage = new LoginPage();
const sidebarPage = new SidebarPage();
@ -135,9 +136,7 @@ describe("Realm settings general tab tests", () => {
.should(
"have.attr",
"href",
`${Cypress.env(
"KEYCLOAK_SERVER",
)}/realms/${realmName}/.well-known/openid-configuration`,
`${SERVER_URL}/realms/${realmName}/.well-known/openid-configuration`,
)
.should("have.attr", "target", "_blank")
.should("have.attr", "rel", "noreferrer noopener");
@ -160,9 +159,7 @@ describe("Realm settings general tab tests", () => {
.should(
"have.attr",
"href",
`${Cypress.env(
"KEYCLOAK_SERVER",
)}/realms/${realmName}/protocol/saml/descriptor`,
`${SERVER_URL}/realms/${realmName}/protocol/saml/descriptor`,
)
.should("have.attr", "target", "_blank")
.should("have.attr", "rel", "noreferrer noopener");

View file

@ -0,0 +1 @@
export const SERVER_URL = "http://localhost:8080";

View file

@ -18,8 +18,3 @@ import "./commands";
// Alternatively you can use CommonJS syntax:
// require('./commands')
// Set Keycloak server to development path if not set.
if (!Cypress.env("KEYCLOAK_SERVER")) {
Cypress.env("KEYCLOAK_SERVER", "http://localhost:8180");
}

View file

@ -9,10 +9,11 @@ import type { UserProfileConfig } from "@keycloak/keycloak-admin-client/lib/defs
import type UserRepresentation from "@keycloak/keycloak-admin-client/lib/defs/userRepresentation";
import { Credentials } from "@keycloak/keycloak-admin-client/lib/utils/auth";
import { merge } from "lodash-es";
import { SERVER_URL } from "../constants";
class AdminClient {
readonly #client = new KeycloakAdminClient({
baseUrl: Cypress.env("KEYCLOAK_SERVER"),
baseUrl: SERVER_URL,
realmName: "master",
});

View file

@ -1,63 +0,0 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<base href="./" />
<link rel="icon" type="image/svg+xml" href="./favicon.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="description" content="Web site to manage keycloak" />
<title>Keycloak Administration UI</title>
<style>
body {
margin: 0;
}
body, #app {
height: 100%;
}
.container {
padding: 0;
margin: 0;
width: 100%;
}
.keycloak__loading-container {
height: 100vh;
width: 100%;
background-color: #f0f0f0;
display: flex;
align-items: center;
justify-content: center;
flex-direction: column;
margin: 0;
}
#loading-text {
z-index: 1000;
font-size: 20px;
font-weight: 600;
padding-top: 32px;
}
</style>
</head>
<body>
<div id="app">
<main class="container">
<div class="keycloak__loading-container">
<span class="pf-c-spinner pf-m-xl" role="progressbar" aria-valuetext="Loading...">
<span class="pf-c-spinner__clipper"></span>
<span class="pf-c-spinner__lead-ball"></span>
<span class="pf-c-spinner__tail-ball"></span>
</span>
<div>
<p id="loading-text">Loading the Admin UI</p>
</div>
</div>
</main>
</div>
<noscript>You need to enable JavaScript to run this app.</noscript>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

View file

@ -0,0 +1,126 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<base href="${resourceUrl}/">
<link rel="icon" type="${properties.favIconType!'image/svg+xml'}" href="${resourceUrl}${properties.favIcon!'/favicon.svg'}">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="description" content="${properties.description!'The Keycloak Administration Console is a web-based interface for managing Keycloak.'}">
<title>${properties.title!'Keycloak Administration Console'}</title>
<style>
body {
margin: 0;
}
body, #app {
height: 100%;
}
.container {
padding: 0;
margin: 0;
width: 100%;
}
.keycloak__loading-container {
height: 100vh;
width: 100%;
background-color: #f0f0f0;
display: flex;
align-items: center;
justify-content: center;
flex-direction: column;
margin: 0;
}
#loading-text {
z-index: 1000;
font-size: 20px;
font-weight: 600;
padding-top: 32px;
}
</style>
<script type="importmap">
{
"imports": {
"react": "${resourceCommonUrl}/vendor/react/react.production.min.js",
"react/jsx-runtime": "${resourceCommonUrl}/vendor/react/react-jsx-runtime.production.min.js",
"react-dom": "${resourceCommonUrl}/vendor/react-dom/react-dom.production.min.js"
}
}
</script>
<#if devServerUrl?has_content>
<script type="module">
import { injectIntoGlobalHook } from "${devServerUrl}/@react-refresh";
injectIntoGlobalHook(window);
window.$RefreshReg$ = () => {};
window.$RefreshSig$ = () => (type) => type;
</script>
<script type="module">
import { inject } from "${devServerUrl}/@vite-plugin-checker-runtime";
inject({
overlayConfig: {},
base: "/",
});
</script>
<script type="module" src="${devServerUrl}/@vite/client"></script>
<script type="module" src="${devServerUrl}/src/main.tsx"></script>
</#if>
<#if entryStyles?has_content>
<#list entryStyles as style>
<link rel="stylesheet" href="${resourceUrl}/${style}">
</#list>
</#if>
<#if properties.styles?has_content>
<#list properties.styles?split(' ') as style>
<link rel="stylesheet" href="${resourceUrl}/${style}">
</#list>
</#if>
<#if entryScript?has_content>
<script type="module" src="${resourceUrl}/${entryScript}"></script>
</#if>
<#if properties.scripts?has_content>
<#list properties.scripts?split(' ') as script>
<script type="module" src="${resourceUrl}/${script}"></script>
</#list>
</#if>
<#if entryImports?has_content>
<#list entryImports as import>
<link rel="modulepreload" href="${resourceUrl}/${import}">
</#list>
</#if>
</head>
<body>
<div id="app">
<main class="container">
<div class="keycloak__loading-container">
<span class="pf-c-spinner pf-m-xl" role="progressbar" aria-valuetext="Loading&hellip;">
<span class="pf-c-spinner__clipper"></span>
<span class="pf-c-spinner__lead-ball"></span>
<span class="pf-c-spinner__tail-ball"></span>
</span>
<div>
<p id="loading-text">Loading the Administration Console</p>
</div>
</div>
</main>
</div>
<noscript>JavaScript is required to use the Administration Console.</noscript>
<script id="environment" type="application/json">
{
"authUrl": "${authUrl}",
"authServerUrl": "${authServerUrl}",
"realm": "${loginRealm!"master"}",
"clientId": "${clientId}",
"resourceUrl": "${resourceUrl}",
"logo": "${properties.logo!""}",
"logoUrl": "${properties.logoUrl!""}",
"consoleBaseUrl": "${consoleBaseUrl}",
"masterRealm": "${masterRealm}",
"resourceVersion": "${resourceVersion}"
}
</script>
</body>
</html>

View file

@ -70,92 +70,6 @@
<groupId>com.github.eirslett</groupId>
<artifactId>frontend-maven-plugin</artifactId>
</plugin>
<plugin>
<groupId>com.google.code.maven-replacer-plugin</groupId>
<artifactId>maven-replacer-plugin</artifactId>
<executions>
<execution>
<phase>process-resources</phase>
<goals>
<goal>replace</goal>
</goals>
</execution>
</executions>
<configuration>
<file>dist/index.html</file>
<outputFile>target/classes/theme/keycloak.v2/admin/index.ftl</outputFile>
<regex>false</regex>
<replacements>
<replacement>
<token>src="./</token>
<value>src="${resourceUrl}/</value>
</replacement>
<replacement>
<token>href="./</token>
<value>href="${resourceUrl}/</value>
</replacement>
<replacement>
<token><![CDATA[<link rel="icon" type="image/svg+xml" href="${resourceUrl}/favicon.svg" />]]></token>
<value xml:space="preserve"><![CDATA[<link rel="icon" type="${properties.favIconType!"image/svg+xml"}" href="${resourceUrl}${properties.favIcon!"/favicon.svg"}" />]]></value>
</replacement>
<replacement>
<token><![CDATA[<meta name="description" content="Web site to manage keycloak" />]]></token>
<value xml:space="preserve"><![CDATA[<meta name="description" content="${properties.description!"Web site to manage keycloak"}" />]]></value>
</replacement>
<replacement>
<token><![CDATA[<title>Keycloak Administration UI</title>]]></token>
<value xml:space="preserve">
<![CDATA[
<title>${properties.title!"Keycloak Administration UI"}</title>
<script type="importmap">
{
"imports": {
"react": "${resourceCommonUrl}/vendor/react/react.production.min.js",
"react/jsx-runtime": "${resourceCommonUrl}/vendor/react/react-jsx-runtime.production.min.js",
"react-dom": "${resourceCommonUrl}/vendor/react-dom/react-dom.production.min.js"
}
}
</script>
]]></value>
</replacement>
<replacement>
<token><![CDATA[</body>]]></token>
<value xml:space="preserve">
<![CDATA[
<script id="environment" type="application/json">
{
"authUrl": "${authUrl}",
"authServerUrl": "${authServerUrl}",
"realm": "${loginRealm!"master"}",
"clientId": "${clientId}",
"resourceUrl": "${resourceUrl}",
"logo": "${properties.logo!""}",
"logoUrl": "${properties.logoUrl!""}",
"consoleBaseUrl": "${consoleBaseUrl}",
"masterRealm": "${masterRealm}",
"resourceVersion": "${resourceVersion}"
}
</script>
</body>
]]>
</value>
</replacement>
<replacement>
<token><![CDATA[</head>]]></token>
<value xml:space="preserve">
<![CDATA[
<#if properties.styles?has_content>
<#list properties.styles?split(' ') as style>
<link href="${resourceUrl}/${style}" rel="stylesheet"/>
</#list>
</#if>
</head>
]]>
</value>
</replacement>
</replacements>
</configuration>
</plugin>
</plugins>
</build>
</project>

View file

@ -1,637 +0,0 @@
// eslint-disable-next-line no-restricted-imports, @typescript-eslint/no-unused-vars
import * as React from "react";
import { render } from "@testing-library/react";
import { FlowDiagram } from "../components/FlowDiagram";
import { describe, expect, it, beforeEach } from "vitest";
import { ExecutionList } from "../execution-model";
// mock react-flow
// code from https://reactflow.dev/learn/advanced-use/testing
class ResizeObserver {
callback: globalThis.ResizeObserverCallback;
constructor(callback: globalThis.ResizeObserverCallback) {
this.callback = callback;
}
observe(target: Element) {
this.callback([{ target } as globalThis.ResizeObserverEntry], this);
}
// eslint-disable-next-line @typescript-eslint/no-empty-function
unobserve() {}
// eslint-disable-next-line @typescript-eslint/no-empty-function
disconnect() {}
}
class DOMMatrixReadOnly {
m22: number;
constructor(transform: string) {
const scale = transform.match(/scale\(([1-9.])\)/)?.[1];
this.m22 = scale !== undefined ? +scale : 1;
}
}
// Only run the shim once when requested
let init = false;
export const mockReactFlow = () => {
if (init) return;
init = true;
global.ResizeObserver = ResizeObserver;
// @ts-ignore
global.DOMMatrixReadOnly = DOMMatrixReadOnly;
Object.defineProperties(global.HTMLElement.prototype, {
offsetHeight: {
get() {
return parseFloat(this.style.height) || 1;
},
},
offsetWidth: {
get() {
return parseFloat(this.style.width) || 1;
},
},
});
(global.SVGElement as any).prototype.getBBox = () => ({
x: 0,
y: 0,
width: 0,
height: 0,
});
};
describe("<FlowDiagram />", () => {
beforeEach(() => {
mockReactFlow();
});
const reactFlowTester = (container: HTMLElement) => ({
expectEdgeLabels: (expectedEdges: string[]) => {
const edges = Array.from(
container.getElementsByClassName("react-flow__edge"),
);
expect(
edges.map((edge) => edge.getAttribute("aria-label")).sort(),
).toEqual(expectedEdges.sort());
},
expectNodeIds: (expectedNodes: string[]) => {
const nodes = Array.from(
container.getElementsByClassName("react-flow__node"),
);
expect(nodes.map((node) => node.getAttribute("data-id")).sort()).toEqual(
expectedNodes.sort(),
);
},
});
it("should render a flow with one required step", () => {
const executionList = new ExecutionList([
{ id: "single", displayName: "Single", level: 0 },
]);
const { container } = render(<FlowDiagram executionList={executionList} />);
// const nodes = Array.from(container.getElementsByClassName("react-flow__node"));
const testHelper = reactFlowTester(container);
const expectedEdges = [
"Edge from start to single",
"Edge from single to end",
];
testHelper.expectEdgeLabels(expectedEdges);
const expectedNodes = new Set(["start", "single", "end"]);
testHelper.expectNodeIds(Array.from(expectedNodes));
});
it("should render a start connected to end with no steps", () => {
const executionList = new ExecutionList([]);
const { container } = render(<FlowDiagram executionList={executionList} />);
const testHelper = reactFlowTester(container);
const expectedEdges = ["Edge from start to end"];
testHelper.expectEdgeLabels(expectedEdges);
const expectedNodes = new Set(["start", "end"]);
testHelper.expectNodeIds(Array.from(expectedNodes));
});
it("should render two branches with two alternative steps", () => {
const executionList = new ExecutionList([
{
id: "alt1",
displayName: "Alt1",
requirement: "ALTERNATIVE",
},
{
id: "alt2",
displayName: "Alt2",
requirement: "ALTERNATIVE",
},
]);
const { container } = render(<FlowDiagram executionList={executionList} />);
const testHelper = reactFlowTester(container);
const expectedEdges = [
"Edge from start to alt1",
"Edge from alt1 to end",
"Edge from alt1 to alt2",
"Edge from alt2 to end",
];
testHelper.expectEdgeLabels(expectedEdges);
const expectedNodes = new Set(["start", "alt1", "alt2", "end"]);
testHelper.expectNodeIds(Array.from(expectedNodes));
});
it("should render a flow with a subflow", () => {
const executionList = new ExecutionList([
{
id: "requiredElement",
displayName: "Required Element",
requirement: "REQUIRED",
level: 0,
},
{
id: "subflow",
displayName: "Subflow",
requirement: "REQUIRED",
level: 0,
},
{
id: "subElement",
displayName: "Sub Element",
requirement: "REQUIRED",
level: 1,
},
]);
const { container } = render(<FlowDiagram executionList={executionList} />);
const testHelper = reactFlowTester(container);
const expectedNodes = ["start", "requiredElement", "subElement", "end"];
testHelper.expectNodeIds(expectedNodes);
const expectedEdges = [
"Edge from start to requiredElement",
"Edge from requiredElement to subElement",
"Edge from subElement to end",
];
testHelper.expectEdgeLabels(expectedEdges);
});
it("should render a flow with a subflow with alternative steps", () => {
const executionList = new ExecutionList([
{
id: "requiredElement",
displayName: "Required Element",
requirement: "REQUIRED",
level: 0,
},
{
id: "subflow",
displayName: "Subflow",
requirement: "REQUIRED",
level: 0,
},
{
id: "subElement1",
displayName: "Sub Element",
requirement: "ALTERNATIVE",
level: 1,
},
{
id: "subElement2",
displayName: "Sub Element",
requirement: "ALTERNATIVE",
level: 1,
},
]);
const { container } = render(<FlowDiagram executionList={executionList} />);
const testHelper = reactFlowTester(container);
const expectedEdges = [
"Edge from start to requiredElement",
"Edge from requiredElement to subElement1",
"Edge from subElement1 to end",
"Edge from subElement1 to subElement2",
"Edge from subElement2 to end",
];
testHelper.expectEdgeLabels(expectedEdges);
const expectedNodes = [
"start",
"requiredElement",
"subElement1",
"subElement2",
"end",
];
testHelper.expectNodeIds(expectedNodes);
});
it("should render a flow with a subflow with alternative steps and combine to a required step", () => {
const executionList = new ExecutionList([
{
id: "requiredElement",
displayName: "Required Element",
requirement: "REQUIRED",
level: 0,
},
{
id: "subflow",
displayName: "Subflow",
requirement: "REQUIRED",
level: 0,
},
{
id: "subElement1",
displayName: "Sub Element",
requirement: "ALTERNATIVE",
level: 1,
},
{
id: "subElement2",
displayName: "Sub Element",
requirement: "ALTERNATIVE",
level: 1,
},
{
id: "finalStep",
displayName: "Final Step",
requirement: "REQUIRED",
level: 0,
},
]);
const { container } = render(<FlowDiagram executionList={executionList} />);
const testHelper = reactFlowTester(container);
const expectedEdges = [
"Edge from start to requiredElement",
"Edge from requiredElement to subElement1",
"Edge from subElement1 to finalStep",
"Edge from subElement1 to subElement2",
"Edge from subElement2 to finalStep",
"Edge from finalStep to end",
];
testHelper.expectEdgeLabels(expectedEdges);
const expectedNodes = [
"start",
"requiredElement",
"subElement1",
"subElement2",
"finalStep",
"end",
];
testHelper.expectNodeIds(expectedNodes);
});
it("should render a flow with a conditional subflow followed by a required step", () => {
const executionList = new ExecutionList([
{
id: "chooseUser",
displayName: "Required Element",
requirement: "REQUIRED",
level: 0,
},
{
id: "sendReset",
displayName: "Send Reset",
requirement: "REQUIRED",
level: 0,
},
{
id: "conditionalOTP",
displayName: "Conditional OTP",
requirement: "CONDITIONAL",
level: 0,
},
{
id: "conditionOtpConfigured",
displayName: "Condition - User Configured",
requirement: "REQUIRED",
level: 1,
},
{
id: "otpForm",
displayName: "OTP Form",
requirement: "REQUIRED",
level: 1,
},
{
id: "resetPassword",
displayName: "Reset Password",
requirement: "REQUIRED",
level: 0,
},
]);
const { container } = render(<FlowDiagram executionList={executionList} />);
const testHelper = reactFlowTester(container);
const expectedNodes = [
"start",
"chooseUser",
"sendReset",
"conditionOtpConfigured",
"otpForm",
"resetPassword",
"end",
];
testHelper.expectNodeIds(expectedNodes);
const expectedEdges = [
"Edge from start to chooseUser",
"Edge from chooseUser to sendReset",
"Edge from sendReset to conditionOtpConfigured",
"Edge from conditionOtpConfigured to otpForm",
"Edge from conditionOtpConfigured to resetPassword",
"Edge from otpForm to resetPassword",
"Edge from resetPassword to end",
];
testHelper.expectEdgeLabels(expectedEdges);
});
it("should render a complex flow with serial conditionals", () => {
// flow inspired by ![conditional flow PR](https://github.com/keycloak/keycloak/pull/28481)
const executionList = new ExecutionList([
{
id: "exampleForms",
displayName: "Example Forms",
requirement: "ALTERNATIVE",
level: 0,
},
{
id: "usernamePasswordForm",
displayName: "Username Password Form",
requirement: "REQUIRED",
level: 1,
},
{
id: "conditionalOTP",
displayName: "Conditional OTP",
requirement: "CONDITIONAL",
level: 1,
},
{
id: "conditionUserConfigured",
displayName: "Condition - User Configured",
requirement: "REQUIRED",
level: 2,
},
{
id: "conditionUserAttribute",
displayName: "Condition - User Attribute",
requirement: "REQUIRED",
level: 2,
},
{
id: "otpForm",
displayName: "OTP Form",
requirement: "REQUIRED",
level: 2,
},
{
id: "confirmLink",
displayName: "Confirm Link",
requirement: "REQUIRED",
level: 2,
},
{
id: "conditionalReviewProfile",
displayName: "Conditional Review Profile",
requirement: "CONDITIONAL",
level: 0,
},
{
id: "conditionLoa",
displayName: "Condition - Loa",
requirement: "REQUIRED",
level: 1,
},
{
id: "reviewProfile",
displayName: "Review Profile",
requirement: "REQUIRED",
level: 1,
},
]);
const { container } = render(<FlowDiagram executionList={executionList} />);
const testHelper = reactFlowTester(container);
const expectedNodes = [
"start",
"usernamePasswordForm",
"conditionUserConfigured",
"conditionUserAttribute",
"otpForm",
"confirmLink",
"conditionLoa",
"reviewProfile",
"end",
];
testHelper.expectNodeIds(expectedNodes);
const expectedEdges = [
"Edge from start to usernamePasswordForm",
"Edge from usernamePasswordForm to conditionUserConfigured",
"Edge from conditionUserConfigured to conditionUserAttribute",
"Edge from conditionUserConfigured to end",
"Edge from conditionUserAttribute to otpForm",
"Edge from conditionUserAttribute to end",
"Edge from otpForm to confirmLink",
"Edge from confirmLink to end",
"Edge from usernamePasswordForm to conditionLoa",
"Edge from conditionLoa to reviewProfile",
"Edge from conditionLoa to end",
"Edge from reviewProfile to end",
];
testHelper.expectEdgeLabels(expectedEdges);
});
it("should render the default first broker login flow", () => {
const executionList = new ExecutionList([
{
id: "reviewProfile",
displayName: "Review Profile",
requirement: "REQUIRED",
level: 0,
},
{
id: "createOrLink",
displayName: "User creation or linking",
requirement: "REQUIRED",
level: 0,
},
{
id: "createUnique",
displayName: "Create User If Unique",
requirement: "ALTERNATIVE",
level: 1,
},
{
id: "existingAccount",
displayName: "Handle Existing Account",
requirement: "ALTERNATIVE",
level: 1,
},
{
id: "confirmLink",
displayName: "Confirm link existing account",
requirement: "REQUIRED",
level: 2,
},
{
id: "accountVerification",
displayName: "Account verification options",
requirement: "REQUIRED",
level: 2,
},
{
id: "emailVerify",
displayName: "Verify existing account by Email",
requirement: "ALTERNATIVE",
level: 3,
},
{
id: "reauthVerify",
displayName: "Verify Existing Account by Re-authentication",
requirement: "ALTERNATIVE",
level: 3,
},
{
id: "usernamePassword",
displayName:
"Username Password Form for identity provider reauthentication",
requirement: "REQUIRED",
level: 4,
},
{
id: "conditionalOtp",
displayName: "First broker login - Conditional OTP",
requirement: "CONDITIONAL",
level: 4,
},
{
id: "conditionUserConfigured",
displayName: "Condition - user configured",
requirement: "REQUIRED",
level: 5,
},
{
id: "otpForm",
displayName: "OTP Form",
requirement: "REQUIRED",
level: 5,
},
]);
const { container } = render(<FlowDiagram executionList={executionList} />);
const testHelper = reactFlowTester(container);
const expectedNodes = [
"start",
"reviewProfile",
"createUnique",
"confirmLink",
"usernamePassword",
"conditionUserConfigured",
"otpForm",
"emailVerify",
"end",
];
testHelper.expectNodeIds(expectedNodes);
const expectedEdges = [
"Edge from start to reviewProfile",
"Edge from reviewProfile to createUnique",
"Edge from createUnique to confirmLink",
"Edge from createUnique to end",
"Edge from confirmLink to emailVerify",
"Edge from emailVerify to usernamePassword",
"Edge from usernamePassword to conditionUserConfigured",
"Edge from conditionUserConfigured to otpForm",
"Edge from conditionUserConfigured to end",
"Edge from otpForm to end",
"Edge from emailVerify to end",
];
testHelper.expectEdgeLabels(expectedEdges);
});
it("should hide disabled steps", () => {
const executionList = new ExecutionList([
{
id: "disabled",
displayName: "Disabled",
requirement: "DISABLED",
},
{
id: "required",
displayName: "Required",
requirement: "REQUIRED",
},
]);
const { container } = render(<FlowDiagram executionList={executionList} />);
const testHelper = reactFlowTester(container);
const expectedNodes = ["start", "required", "end"];
testHelper.expectNodeIds(expectedNodes);
const expectedEdges = [
"Edge from start to required",
"Edge from required to end",
];
testHelper.expectEdgeLabels(expectedEdges);
});
it("should hide disabled subflow", () => {
const executionList = new ExecutionList([
{
id: "required",
displayName: "Required",
requirement: "REQUIRED",
level: 0,
},
{
id: "subflow",
displayName: "Subflow",
requirement: "DISABLED",
level: 0,
},
{
id: "subElement",
displayName: "Sub Element",
requirement: "REQUIRED",
level: 1,
},
]);
const { container } = render(<FlowDiagram executionList={executionList} />);
const testHelper = reactFlowTester(container);
const expectedNodes = ["start", "required", "end"];
testHelper.expectNodeIds(expectedNodes);
const expectedEdges = [
"Edge from start to required",
"Edge from required to end",
];
testHelper.expectEdgeLabels(expectedEdges);
});
});

View file

@ -6,14 +6,17 @@ import { checker } from "vite-plugin-checker";
export default defineConfig({
base: "",
server: {
port: 8080,
origin: "http://localhost:5174",
port: 5174,
},
build: {
sourcemap: true,
target: "esnext",
modulePreload: false,
cssMinify: "lightningcss",
manifest: true,
rollupOptions: {
input: "src/main.tsx",
external: ["react", "react/jsx-runtime", "react-dom"],
},
},

View file

@ -6,22 +6,39 @@ This app allows you to run a local development version of the Keycloak server.
First, ensure that all dependencies are installed locally using PNPM by running:
```bash
```sh
pnpm install
```
After the dependencies are installed we can start the Keycloak server by running the following command:
```bash
```sh
pnpm start
```
This will download the [Nightly version](https://github.com/keycloak/keycloak/releases/tag/nightly) of the Keycloak server and run it locally on port `8180`. If a previously downloaded version was found in the `server/` directory then that one will be used instead. If you want to download the latest Nightly version you can remove the server directory before running the command to start the server.
If you want to run the server against a local development Vite server, you'll have to pass the `--admin-dev` or `--account-dev` flag:
```sh
pnpm start --admin-dev
pnpm start --account-dev
```
The above commands will download the [Nightly version](https://github.com/keycloak/keycloak/releases/tag/nightly) of the Keycloak server and run it locally on port `8080`. If a previously downloaded version was found in the `server/` directory then that one will be used instead. If you want to download the latest Nightly version you can remove the server directory before running the command to start the server:
```sh
pnpm delete-server
```
Or if you just want to clear the data so you can start fresh without downloading the server again:
```sh
pnpm delete-data
```
If you want to run with a local Quarkus distribution of Keycloak for development purposes, you can do so by running this command instead:
```bash
pnpm start -- --local
```sh
pnpm start --local
```
**All other arguments will be passed through to the underlying Keycloak server.**

View file

@ -2,20 +2,11 @@
"name": "keycloak-server",
"type": "module",
"scripts": {
"start": "wireit",
"start": "node ./scripts/start-server.js",
"delete-data": "rm -r ./server/data",
"delete-server": "rm -r ./server"
},
"wireit": {
"start": {
"command": "node ./scripts/start-server.js",
"dependencies": [
"../../libs/keycloak-admin-client:build"
]
}
},
"dependencies": {
"@keycloak/keycloak-admin-client": "workspace:*",
"@octokit/rest": "^20.1.1",
"@types/gunzip-maybe": "^1.4.2",
"@types/tar-fs": "^2.0.4",

View file

@ -1,49 +0,0 @@
{
"clientId": "security-admin-console-v2",
"rootUrl": "http://localhost:8080/",
"adminUrl": "http://localhost:8080/",
"surrogateAuthRequired": false,
"enabled": true,
"alwaysDisplayInConsole": false,
"clientAuthenticatorType": "client-secret",
"redirectUris": [
"http://localhost:8080/*"
],
"webOrigins": [
"http://localhost:8080"
],
"notBefore": 0,
"bearerOnly": false,
"consentRequired": false,
"standardFlowEnabled": true,
"implicitFlowEnabled": false,
"directAccessGrantsEnabled": true,
"serviceAccountsEnabled": false,
"publicClient": true,
"frontchannelLogout": false,
"protocol": "openid-connect",
"attributes": {
"security.admin.console": "true"
},
"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

@ -1,10 +1,8 @@
#!/usr/bin/env node
import KcAdminClient from "@keycloak/keycloak-admin-client";
import { Octokit } from "@octokit/rest";
import gunzip from "gunzip-maybe";
import { spawn } from "node:child_process";
import fs from "node:fs";
import { readFile } from "node:fs/promises";
import path from "node:path";
import { pipeline } from "node:stream/promises";
import { fileURLToPath } from "node:url";
@ -18,13 +16,17 @@ const LOCAL_DIST_NAME = "keycloak-999.0.0-SNAPSHOT.tar.gz";
const SCRIPT_EXTENSION = process.platform === "win32" ? ".bat" : ".sh";
const ADMIN_USERNAME = "admin";
const ADMIN_PASSWORD = "admin";
const AUTH_DELAY = 10000;
const AUTH_RETRY_LIMIT = 3;
const options = {
local: {
type: "boolean",
},
"account-dev": {
type: "boolean",
},
"admin-dev": {
type: "boolean",
},
};
await startServer();
@ -34,30 +36,37 @@ async function startServer() {
await downloadServer(scriptArgs.local);
const env = {
KEYCLOAK_ADMIN: ADMIN_USERNAME,
KEYCLOAK_ADMIN_PASSWORD: ADMIN_PASSWORD,
...process.env,
};
if (scriptArgs["account-dev"]) {
env.KC_ACCOUNT_VITE_URL = "http://localhost:5173";
}
if (scriptArgs["admin-dev"]) {
env.KC_ADMIN_VITE_URL = "http://localhost:5174";
}
console.info("Starting server…");
const child = spawn(
path.join(SERVER_DIR, `bin/kc${SCRIPT_EXTENSION}`),
[
"start-dev",
"--http-port=8180",
`--features="login2,account3,admin-fine-grained-authz,transient-users,oid4vc-vci"`,
...keycloakArgs,
],
{
shell: true,
env: {
KEYCLOAK_ADMIN: ADMIN_USERNAME,
KEYCLOAK_ADMIN_PASSWORD: ADMIN_PASSWORD,
...process.env,
},
env,
},
);
child.stdout.pipe(process.stdout);
child.stderr.pipe(process.stderr);
await wait(AUTH_DELAY);
await importClient();
}
function handleArgs(args) {
@ -102,35 +111,6 @@ async function downloadServer(local) {
await extractTarball(assetStream, SERVER_DIR, { strip: 1 });
}
async function importClient() {
const adminClient = new KcAdminClient({
baseUrl: "http://127.0.0.1:8180",
realmName: "master",
});
await authenticateAdminClient(adminClient);
console.info("Checking if client already exists…");
const adminConsoleClient = await adminClient.clients.find({
clientId: "security-admin-console-v2",
});
if (adminConsoleClient.length > 0) {
console.info("Client already exists, skipping import.");
return;
}
console.info("Importing client…");
const configPath = path.join(DIR_NAME, "security-admin-console-v2.json");
const config = JSON.parse(await readFile(configPath, "utf-8"));
await adminClient.clients.create(config);
console.info("Client imported successfully.");
}
async function getNightlyAsset() {
const api = new Octokit();
const release = await api.repos.getReleaseByTag({
@ -157,36 +137,3 @@ async function getAssetAsStream(asset) {
function extractTarball(stream, path, options) {
return pipeline(stream, gunzip(), extract(path, options));
}
async function authenticateAdminClient(
adminClient,
numRetries = AUTH_RETRY_LIMIT,
) {
console.log("Authenticating admin client…");
try {
await adminClient.auth({
username: ADMIN_USERNAME,
password: ADMIN_PASSWORD,
grantType: "password",
clientId: "admin-cli",
});
} catch (error) {
if (numRetries === 0) {
throw error;
}
console.info(
`Authentication failed, retrying in ${AUTH_DELAY / 1000} seconds.`,
);
await wait(AUTH_DELAY);
await authenticateAdminClient(adminClient, numRetries - 1);
}
console.log("Admin client authenticated successfully.");
}
async function wait(delay) {
return new Promise((resolve) => setTimeout(() => resolve(), delay));
}

View file

@ -20,25 +20,21 @@ export type BaseEnvironment = {
};
/**
* Extracts the environment variables that are passed if the application is running as a Keycloak theme and combines them with the provided defaults.
* These variables are injected by Keycloak into the `index.ftl` as a script tag, the contents of which can be parsed as JSON.
* Extracts the environment variables from the document, these variables are injected by Keycloak as a script tag, the contents of which can be parsed as JSON.
*
* @argument defaults - The default values to fall to if a value is not present in the environment.
* @argument defaults - The default values to fall to if a value is not found in the environment.
*/
export function getInjectedEnvironment<T>(defaults: T): T {
const element = document.getElementById("environment");
let env = {} as T;
const contents = element?.textContent;
if (typeof contents !== "string") {
throw new Error("Environment variables not found in the document.");
}
// Attempt to parse the contents as JSON and return its value.
try {
// If the element cannot be found, return an empty record.
if (element?.textContent) {
env = JSON.parse(element.textContent);
}
return { ...defaults, ...JSON.parse(contents) };
} catch (error) {
console.error("Unable to parse environment variables.");
throw new Error("Unable to parse environment variables as JSON.");
}
// Return the merged environment variables with the defaults.
return { ...defaults, ...env };
}

View file

@ -298,9 +298,6 @@ importers:
js/apps/keycloak-server:
dependencies:
'@keycloak/keycloak-admin-client':
specifier: workspace:*
version: link:../../libs/keycloak-admin-client
'@octokit/rest':
specifier: ^20.1.1
version: 20.1.1

View file

@ -541,10 +541,10 @@ class KeycloakProcessor {
Configuration.markAsOptimized(properties);
}
String profile = Environment.getProfile();
String profile = org.keycloak.common.util.Environment.getProfile();
if (profile != null) {
properties.put(Environment.PROFILE, profile);
properties.put(org.keycloak.common.util.Environment.PROFILE, profile);
properties.put(LaunchMode.current().getProfileKey(), profile);
}

View file

@ -43,11 +43,8 @@ import org.keycloak.quarkus.runtime.configuration.PersistedConfigSource;
public final class Environment {
public static final String IMPORT_EXPORT_MODE = "import_export";
public static final String PROFILE ="kc.profile";
public static final String ENV_PROFILE ="KC_PROFILE";
public static final String DATA_PATH = File.separator + "data";
public static final String DEFAULT_THEMES_PATH = File.separator + "themes";
public static final String DEV_PROFILE_VALUE = "dev";
public static final String PROD_PROFILE_VALUE = "prod";
public static final String LAUNCH_MODE = "kc.launch.mode";
@ -103,18 +100,8 @@ public final class Environment {
return "kc.sh";
}
public static String getProfile() {
String profile = System.getProperty(PROFILE);
if (profile == null) {
profile = System.getenv(ENV_PROFILE);
}
return profile;
}
public static void setProfile(String profile) {
System.setProperty(PROFILE, profile);
System.setProperty(org.keycloak.common.util.Environment.PROFILE, profile);
System.setProperty(LaunchMode.current().getProfileKey(), profile);
System.setProperty(SmallRyeConfig.SMALLRYE_CONFIG_PROFILE, profile);
if (isTestLaunchMode()) {
@ -123,15 +110,15 @@ public final class Environment {
}
public static String getCurrentOrPersistedProfile() {
String profile = getProfile();
String profile = org.keycloak.common.util.Environment.getProfile();
if(profile == null) {
profile = PersistedConfigSource.getInstance().getValue(PROFILE);
profile = PersistedConfigSource.getInstance().getValue(org.keycloak.common.util.Environment.PROFILE);
}
return profile;
}
public static String getProfileOrDefault(String defaultProfile) {
String profile = getProfile();
String profile = org.keycloak.common.util.Environment.getProfile();
if (profile == null) {
profile = defaultProfile;
@ -141,19 +128,19 @@ public final class Environment {
}
public static boolean isDevMode() {
if (DEV_PROFILE_VALUE.equalsIgnoreCase(getProfile())) {
if (org.keycloak.common.util.Environment.isDevMode()) {
return true;
}
return DEV_PROFILE_VALUE.equals(getBuildTimeProperty(PROFILE).orElse(null));
return org.keycloak.common.util.Environment.DEV_PROFILE_VALUE.equals(getBuildTimeProperty(org.keycloak.common.util.Environment.PROFILE).orElse(null));
}
public static boolean isDevProfile(){
return Optional.ofNullable(getProfile()).orElse("").equalsIgnoreCase(DEV_PROFILE_VALUE);
return Optional.ofNullable(org.keycloak.common.util.Environment.getProfile()).orElse("").equalsIgnoreCase(org.keycloak.common.util.Environment.DEV_PROFILE_VALUE);
}
public static boolean isImportExportMode() {
return IMPORT_EXPORT_MODE.equalsIgnoreCase(getProfile());
return IMPORT_EXPORT_MODE.equalsIgnoreCase(org.keycloak.common.util.Environment.getProfile());
}
public static boolean isWindows() {
@ -161,7 +148,7 @@ public final class Environment {
}
public static void forceDevProfile() {
setProfile(DEV_PROFILE_VALUE);
setProfile(org.keycloak.common.util.Environment.DEV_PROFILE_VALUE);
}
public static Map<String, File> getProviderFiles() {

View file

@ -32,7 +32,7 @@ public final class Messages {
public static String httpsConfigurationNotSet() {
StringBuilder builder = new StringBuilder("Key material not provided to setup HTTPS. Please configure your keys/certificates");
if (!Environment.DEV_PROFILE_VALUE.equals(Environment.getProfile())) {
if (!org.keycloak.common.util.Environment.DEV_PROFILE_VALUE.equals(org.keycloak.common.util.Environment.getProfile())) {
builder.append(" or start the server in development mode");
}
builder.append(".");
@ -44,7 +44,7 @@ public final class Messages {
}
public static String devProfileNotAllowedError(String cmd) {
return String.format("You can not '%s' the server in %s mode. Please re-build the server first, using 'kc.sh build' for the default production mode.%n", cmd, Environment.getKeycloakModeFromProfile(Environment.DEV_PROFILE_VALUE));
return String.format("You can not '%s' the server in %s mode. Please re-build the server first, using 'kc.sh build' for the default production mode.%n", cmd, Environment.getKeycloakModeFromProfile(org.keycloak.common.util.Environment.DEV_PROFILE_VALUE));
}
public static String invalidLogLevel(String logLevel) {

View file

@ -168,7 +168,7 @@ public final class Picocli {
}
if (currentCommandName.equals(StartDev.NAME)) {
String profile = Environment.getProfile();
String profile = org.keycloak.common.util.Environment.getProfile();
if (profile == null) {
// force the server image to be set with the dev profile
@ -461,7 +461,7 @@ public final class Picocli {
}
private static boolean hasConfigChanges(CommandLine cmdCommand) {
Optional<String> currentProfile = ofNullable(Environment.getProfile());
Optional<String> currentProfile = ofNullable(org.keycloak.common.util.Environment.getProfile());
Optional<String> persistedProfile = getBuildTimeProperty("kc.profile");
if (!persistedProfile.orElse("").equals(currentProfile.orElse(""))) {

View file

@ -65,7 +65,7 @@ public final class Start extends AbstractStartCommand implements Runnable {
}
public static boolean isDevProfileNotAllowed() {
Optional<String> currentProfile = Optional.ofNullable(Environment.getProfile());
Optional<String> currentProfile = Optional.ofNullable(org.keycloak.common.util.Environment.getProfile());
Optional<String> persistedProfile = getRawPersistedProperty("kc.profile");
setProfile(currentProfile.orElse(persistedProfile.orElse("prod")));

View file

@ -88,10 +88,10 @@ public final class Configuration {
}
if (value.isEmpty()) {
String profile = Environment.getProfile();
String profile = org.keycloak.common.util.Environment.getProfile();
if (profile == null) {
profile = getConfig().getRawValue(Environment.PROFILE);
profile = getConfig().getRawValue(org.keycloak.common.util.Environment.PROFILE);
}
value = getRawPersistedProperty("%" + profile + "." + name);

View file

@ -42,7 +42,7 @@ public class KeycloakConfigSourceProvider implements ConfigSourceProvider, Confi
}
private static void initializeSources() {
String profile = Environment.getProfile();
String profile = org.keycloak.common.util.Environment.getProfile();
if (profile != null) {
System.setProperty("quarkus.profile", profile);

View file

@ -83,7 +83,7 @@ public final class PropertyMappers {
&& !ConfigArgsConfigSource.CLI_ARGS.equals(name)
&& !"kc.home.dir".equals(name)
&& !"kc.config.file".equals(name)
&& !Environment.PROFILE.equals(name)
&& !org.keycloak.common.util.Environment.PROFILE.equals(name)
&& !"kc.show.config".equals(name)
&& !"kc.show.config.runtime".equals(name)
&& !"kc.config-file".equals(name);

View file

@ -185,7 +185,7 @@ public class ConfigurationTest {
@Test
public void testKeycloakProfilePropertySubstitution() {
System.setProperty(Environment.PROFILE, "user-profile");
System.setProperty(org.keycloak.common.util.Environment.PROFILE, "user-profile");
assertEquals("http://filepropprofile.unittest", initConfig("hostname", "default").get("frontendUrl"));
}
@ -430,11 +430,11 @@ public class ConfigurationTest {
Assert.assertEquals("cache-ispn.xml", initConfig("connectionsInfinispan", "quarkus").get("configFile"));
// If explicitly set, then it is always used regardless of the profile
System.clearProperty(Environment.PROFILE);
System.clearProperty(org.keycloak.common.util.Environment.PROFILE);
ConfigArgsConfigSource.setCliArgs("--cache=cluster-foo.xml");
Assert.assertEquals("cluster-foo.xml", initConfig("connectionsInfinispan", "quarkus").get("configFile"));
System.setProperty(Environment.PROFILE, "dev");
System.setProperty(org.keycloak.common.util.Environment.PROFILE, "dev");
Assert.assertEquals("cluster-foo.xml", initConfig("connectionsInfinispan", "quarkus").get("configFile"));
ConfigArgsConfigSource.setCliArgs("--cache-stack=foo");

View file

@ -1,29 +1,14 @@
package org.keycloak.services.resources.account;
import java.io.IOException;
import java.io.InputStream;
import java.net.URI;
import java.util.HashMap;
import java.util.Locale;
import java.util.Map;
import java.util.Properties;
import java.util.Scanner;
import java.util.function.Function;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
import java.util.stream.Collectors;
import jakarta.ws.rs.GET;
import jakarta.ws.rs.Path;
import jakarta.ws.rs.core.Response;
import jakarta.ws.rs.core.UriBuilder;
import jakarta.ws.rs.core.UriInfo;
import org.jboss.logging.Logger;
import org.jboss.resteasy.reactive.NoCache;
import org.keycloak.authentication.requiredactions.DeleteAccount;
import org.keycloak.common.Profile;
import org.keycloak.common.Version;
import org.keycloak.events.EventStoreProvider;
import org.keycloak.common.util.Environment;
import org.keycloak.models.AccountRoles;
import org.keycloak.models.ClientModel;
import org.keycloak.models.Constants;
@ -40,6 +25,7 @@ import org.keycloak.services.managers.AuthenticationManager;
import org.keycloak.services.resource.AccountResourceProvider;
import org.keycloak.services.resources.RealmsResource;
import org.keycloak.services.util.ResolveRelative;
import org.keycloak.services.util.ViteManifest;
import org.keycloak.services.validation.Validation;
import org.keycloak.theme.FreeMarkerException;
import org.keycloak.theme.Theme;
@ -49,6 +35,19 @@ import org.keycloak.urls.UrlType;
import org.keycloak.util.JsonSerialization;
import org.keycloak.utils.MediaType;
import java.io.IOException;
import java.io.InputStream;
import java.net.URI;
import java.util.HashMap;
import java.util.Locale;
import java.util.Map;
import java.util.Properties;
import java.util.Scanner;
import java.util.function.Function;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
import java.util.stream.Collectors;
/**
* Created by st on 29/03/17.
*/
@ -161,6 +160,26 @@ public class AccountConsole implements AccountResourceProvider {
RequiredActionProviderModel updateEmailActionProvider = realm.getRequiredActionProviderByAlias(UserModel.RequiredAction.UPDATE_EMAIL.name());
map.put("updateEmailActionEnabled", updateEmailActionProvider != null && updateEmailActionProvider.isEnabled());
final var devServerUrl = Environment.isDevMode() ? System.getenv(ViteManifest.ACCOUNT_VITE_URL) : null;
if (devServerUrl != null) {
map.put("devServerUrl", devServerUrl);
}
final var manifestFile = theme.getResourceAsStream(ViteManifest.MANIFEST_FILE_PATH);
if (devServerUrl == null && manifestFile != null) {
final var manifest = ViteManifest.parseFromInputStream(manifestFile);
final var entryChunk = manifest.getEntryChunk();
final var entryStyles = entryChunk.css().orElse(new String[] {});
final var entryScript = entryChunk.file();
final var entryImports = entryChunk.imports().orElse(new String[] {});
map.put("entryStyles", entryStyles);
map.put("entryScript", entryScript);
map.put("entryImports", entryImports);
}
FreeMarkerProvider freeMarkerUtil = session.getProvider(FreeMarkerProvider.class);
String result = freeMarkerUtil.processTemplate(map, "index.ftl", theme);
Response.ResponseBuilder builder = Response.status(Response.Status.OK).type(MediaType.TEXT_HTML_UTF_8).language(Locale.ENGLISH).entity(result);

View file

@ -18,12 +18,21 @@ package org.keycloak.services.resources.admin;
import com.fasterxml.jackson.annotation.JsonProperty;
import jakarta.ws.rs.ForbiddenException;
import jakarta.ws.rs.GET;
import jakarta.ws.rs.NotAuthorizedException;
import jakarta.ws.rs.NotFoundException;
import jakarta.ws.rs.OPTIONS;
import jakarta.ws.rs.Path;
import jakarta.ws.rs.Produces;
import jakarta.ws.rs.QueryParam;
import jakarta.ws.rs.core.Response;
import org.jboss.logging.Logger;
import org.jboss.resteasy.reactive.NoCache;
import org.keycloak.Config;
import org.keycloak.common.ClientConnection;
import org.keycloak.common.Profile;
import org.keycloak.common.Version;
import org.keycloak.common.util.Environment;
import org.keycloak.common.util.UriUtils;
import org.keycloak.headers.SecurityHeadersProvider;
import org.keycloak.http.HttpRequest;
@ -42,20 +51,13 @@ import org.keycloak.services.managers.AppAuthManager;
import org.keycloak.services.managers.AuthenticationManager;
import org.keycloak.services.managers.ClientManager;
import org.keycloak.services.managers.RealmManager;
import org.keycloak.services.util.ViteManifest;
import org.keycloak.theme.FreeMarkerException;
import org.keycloak.theme.Theme;
import org.keycloak.theme.freemarker.FreeMarkerProvider;
import org.keycloak.urls.UrlType;
import org.keycloak.utils.MediaType;
import jakarta.ws.rs.GET;
import jakarta.ws.rs.NotAuthorizedException;
import jakarta.ws.rs.NotFoundException;
import jakarta.ws.rs.OPTIONS;
import jakarta.ws.rs.Path;
import jakarta.ws.rs.Produces;
import jakarta.ws.rs.QueryParam;
import jakarta.ws.rs.core.Response;
import java.io.IOException;
import java.net.URI;
import java.net.URISyntaxException;
@ -358,6 +360,26 @@ public class AdminConsole {
map.put("clientId", Constants.ADMIN_CONSOLE_CLIENT_ID);
map.put("properties", theme.getProperties());
final var devServerUrl = Environment.isDevMode() ? System.getenv(ViteManifest.ADMIN_VITE_URL) : null;
if (devServerUrl != null) {
map.put("devServerUrl", devServerUrl);
}
final var manifestFile = theme.getResourceAsStream(".vite/manifest.json");
if (devServerUrl == null && manifestFile != null) {
final var manifest = ViteManifest.parseFromInputStream(manifestFile);
final var entryChunk = manifest.getEntryChunk();
final var entryStyles = entryChunk.css().orElse(new String[] {});
final var entryScript = entryChunk.file();
final var entryImports = entryChunk.imports().orElse(new String[] {});
map.put("entryStyles", entryStyles);
map.put("entryScript", entryScript);
map.put("entryImports", entryImports);
}
FreeMarkerProvider freeMarkerUtil = session.getProvider(FreeMarkerProvider.class);
String result = freeMarkerUtil.processTemplate(map, "index.ftl", theme);
Response.ResponseBuilder builder = Response.status(Response.Status.OK).type(MediaType.TEXT_HTML_UTF_8).language(Locale.ENGLISH).entity(result);

View file

@ -0,0 +1,36 @@
package org.keycloak.services.util;
import com.fasterxml.jackson.annotation.JsonProperty;
import java.util.Optional;
/**
* Represents a chunk from the Vite build manifest (see {@link ViteManifest}).
*/
public record Chunk (
@JsonProperty(required = true)
String file,
@JsonProperty
Optional<String> src,
@JsonProperty
Optional<String> name,
@JsonProperty
Optional<Boolean> isEntry,
@JsonProperty
Optional<Boolean> isDynamicEntry,
@JsonProperty
Optional<String[]> imports,
@JsonProperty
Optional<String[]> dynamicImports,
@JsonProperty
Optional<String[]> assets,
@JsonProperty Optional<String[]> css
){}

View file

@ -0,0 +1,42 @@
package org.keycloak.services.util;
import com.fasterxml.jackson.core.type.TypeReference;
import org.keycloak.util.JsonSerialization;
import java.io.IOException;
import java.io.InputStream;
import java.util.HashMap;
import java.util.Optional;
/**
* This class is used to parse the Vite manifest file which is generated by the build, this file contains
* a mapping of non-hashed asset filenames to their hashed versions, which can then be used to render the
* correct asset links for scripts, styles, etc.
*
* @see <a href="https://vitejs.dev/guide/backend-integration.html">Vite documentation Backend Integration</a>
*/
public class ViteManifest {
public static final String MANIFEST_FILE_PATH = ".vite/manifest.json";
public static final String ACCOUNT_VITE_URL = "KC_ACCOUNT_VITE_URL";
public static final String ADMIN_VITE_URL = "KC_ADMIN_VITE_URL";
private final HashMap<String, Chunk> manifest;
private ViteManifest(HashMap<String, Chunk> value) {
this.manifest = value;
}
public static ViteManifest parseFromInputStream(InputStream input) throws IOException {
final var typeRef = new TypeReference<HashMap<String, Chunk>>() {};
final var value = JsonSerialization.readValue(input, typeRef);
return new ViteManifest(value);
}
public Chunk getEntryChunk() {
return manifest.values().stream()
.filter(chunk -> chunk.isEntry().orElse(false))
.findFirst()
.orElseThrow();
}
}