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:
parent
04b16a914c
commit
c7361ccf6e
47 changed files with 578 additions and 1262 deletions
3
.github/workflows/js-ci.yml
vendored
3
.github/workflows/js-ci.yml
vendored
|
@ -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 }}
|
||||
|
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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);
|
||||
|
|
|
@ -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>
|
|
@ -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…">
|
||||
<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>
|
|
@ -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",
|
||||
},
|
||||
|
||||
|
|
|
@ -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>
|
|
@ -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 });
|
|
@ -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`,
|
||||
},
|
||||
};
|
||||
|
||||
|
|
|
@ -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,
|
||||
});
|
||||
|
||||
|
|
|
@ -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("/");
|
||||
|
|
|
@ -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";
|
|
@ -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>,
|
||||
) => {
|
||||
|
|
|
@ -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 ({
|
||||
|
|
|
@ -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 });
|
||||
|
|
|
@ -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,
|
||||
},
|
||||
},
|
||||
|
|
|
@ -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";
|
||||
|
|
|
@ -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", () => {
|
||||
|
|
|
@ -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");
|
||||
|
|
1
js/apps/admin-ui/cypress/support/constants.ts
Normal file
1
js/apps/admin-ui/cypress/support/constants.ts
Normal file
|
@ -0,0 +1 @@
|
|||
export const SERVER_URL = "http://localhost:8080";
|
|
@ -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");
|
||||
}
|
||||
|
|
|
@ -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",
|
||||
});
|
||||
|
||||
|
|
|
@ -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>
|
|
@ -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…">
|
||||
<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>
|
|
@ -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>
|
|
@ -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);
|
||||
});
|
||||
});
|
|
@ -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"],
|
||||
},
|
||||
},
|
||||
|
|
|
@ -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.**
|
||||
|
|
|
@ -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",
|
||||
|
|
|
@ -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
|
||||
}
|
||||
}
|
|
@ -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));
|
||||
}
|
||||
|
|
|
@ -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 };
|
||||
}
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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);
|
||||
}
|
||||
|
||||
|
|
|
@ -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() {
|
||||
|
|
|
@ -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) {
|
||||
|
|
|
@ -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(""))) {
|
||||
|
|
|
@ -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")));
|
||||
|
|
|
@ -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);
|
||||
|
|
|
@ -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);
|
||||
|
|
|
@ -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);
|
||||
|
|
|
@ -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");
|
||||
|
|
|
@ -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);
|
||||
|
|
|
@ -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);
|
||||
|
|
36
services/src/main/java/org/keycloak/services/util/Chunk.java
Normal file
36
services/src/main/java/org/keycloak/services/util/Chunk.java
Normal 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
|
||||
){}
|
|
@ -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();
|
||||
}
|
||||
}
|
Loading…
Reference in a new issue