Use browser router for Account Console (#22192)

Closes #27442

Signed-off-by: Jon Koops <jonkoops@gmail.com>
This commit is contained in:
Jon Koops 2024-03-04 13:38:28 +01:00 committed by GitHub
parent be3e2fabc4
commit 7afd75ba08
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
14 changed files with 152 additions and 115 deletions

View file

@ -1,4 +1,5 @@
import { defineConfig, devices } from "@playwright/test"; import { defineConfig, devices } from "@playwright/test";
import { getRootPath } from "./src/utils/getRootPath";
/** /**
* See https://playwright.dev/docs/test-configuration. * See https://playwright.dev/docs/test-configuration.
@ -11,9 +12,7 @@ export default defineConfig({
workers: 1, workers: 1,
reporter: process.env.CI ? [["github"], ["html"]] : "list", reporter: process.env.CI ? [["github"], ["html"]] : "list",
use: { use: {
baseURL: process.env.CI baseURL: `http://localhost:8080${getRootPath()}`,
? "http://localhost:8080/realms/master/account/"
: "http://localhost:8080/",
trace: "on-first-retry", trace: "on-first-retry",
}, },

View file

@ -0,0 +1,2 @@
export const DEFAULT_REALM = "master";
export const ROOT_PATH = "/realms/:realm/account";

View file

@ -1,3 +1,6 @@
import { matchPath } from "react-router-dom";
import { DEFAULT_REALM, ROOT_PATH } from "./constants";
export type Feature = { export type Feature = {
isRegistrationEmailAsUsername: boolean; isRegistrationEmailAsUsername: boolean;
isEditUserNameAllowed: boolean; isEditUserNameAllowed: boolean;
@ -31,11 +34,12 @@ export type Environment = {
features: Feature; features: Feature;
}; };
// The default environment, used during development. // Detect the current realm from the URL.
const realm = new URLSearchParams(window.location.search).get("realm"); const match = matchPath(ROOT_PATH, location.pathname);
const defaultEnvironment: Environment = { const defaultEnvironment: Environment = {
authUrl: "http://localhost:8180", authUrl: "http://localhost:8180",
realm: realm || "master", realm: match?.params.realm ?? DEFAULT_REALM,
clientId: "security-admin-console-v2", clientId: "security-admin-console-v2",
resourceUrl: "http://localhost:8080", resourceUrl: "http://localhost:8080",
logo: "/logo.svg", logo: "/logo.svg",

View file

@ -3,18 +3,22 @@ import "@patternfly/patternfly/patternfly-addons.css";
import { StrictMode } from "react"; import { StrictMode } from "react";
import { createRoot } from "react-dom/client"; import { createRoot } from "react-dom/client";
import { createHashRouter, RouterProvider } from "react-router-dom"; import { createBrowserRouter, RouterProvider } from "react-router-dom";
import { environment } from "./environment";
import { i18n } from "./i18n"; import { i18n } from "./i18n";
import { routes } from "./routes"; import { routes } from "./routes";
import { getRootPath } from "./utils/getRootPath";
// Initialize required components before rendering app. // Initialize required components before rendering app.
await i18n.init(); await i18n.init();
const router = createHashRouter(routes);
const container = document.getElementById("app"); const container = document.getElementById("app");
const root = createRoot(container!); const root = createRoot(container!);
const basename = getRootPath(environment.realm);
const router = createBrowserRouter(routes, { basename });
root.render( root.render(
<StrictMode> <StrictMode>
<RouterProvider router={router} /> <RouterProvider router={router} />

View file

@ -0,0 +1,5 @@
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

@ -1,18 +1,20 @@
import { expect, test } from "@playwright/test";
import {
createIdentityProvider,
deleteIdentityProvider,
createClient,
deleteClient,
inRealm,
findClientByClientId,
createRandomUserWithPassword,
deleteUser,
} from "../admin-client";
import groupsIdPClient from "../realms/groups-idp.json" assert { type: "json" };
import ClientRepresentation from "@keycloak/keycloak-admin-client/lib/defs/clientRepresentation"; import ClientRepresentation from "@keycloak/keycloak-admin-client/lib/defs/clientRepresentation";
import IdentityProviderRepresentation from "@keycloak/keycloak-admin-client/lib/defs/identityProviderRepresentation"; import IdentityProviderRepresentation from "@keycloak/keycloak-admin-client/lib/defs/identityProviderRepresentation";
import { randomUUID } from "crypto"; import { expect, test } from "@playwright/test";
import { randomUUID } from "node:crypto";
import {
createClient,
createIdentityProvider,
createRandomUserWithPassword,
deleteClient,
deleteIdentityProvider,
deleteUser,
findClientByClientId,
inRealm,
} from "../admin-client";
import groupsIdPClient from "../realms/groups-idp.json" assert { type: "json" };
import { getBaseUrl } from "../utils";
const realm = "groups"; const realm = "groups";
@ -30,7 +32,7 @@ test.describe("Account linking", () => {
groupIdPClientId = await createClient( groupIdPClientId = await createClient(
groupsIdPClient as ClientRepresentation, groupsIdPClient as ClientRepresentation,
); );
const kc = process.env.KEYCLOAK_SERVER || "http://localhost:8080"; const baseUrl = getBaseUrl();
const idp: IdentityProviderRepresentation = { const idp: IdentityProviderRepresentation = {
alias: "master-idp", alias: "master-idp",
providerId: "oidc", providerId: "oidc",
@ -39,12 +41,12 @@ test.describe("Account linking", () => {
clientId: "groups-idp", clientId: "groups-idp",
clientSecret: "H0JaTc7VBu3HJR26vrzMxgidfJmgI5Dw", clientSecret: "H0JaTc7VBu3HJR26vrzMxgidfJmgI5Dw",
validateSignature: "false", validateSignature: "false",
tokenUrl: `${kc}/realms/master/protocol/openid-connect/token`, tokenUrl: `${baseUrl}/realms/master/protocol/openid-connect/token`,
jwksUrl: `${kc}/realms/master/protocol/openid-connect/certs`, jwksUrl: `${baseUrl}/realms/master/protocol/openid-connect/certs`,
issuer: `${kc}/realms/master`, issuer: `${baseUrl}/realms/master`,
authorizationUrl: `${kc}/realms/master/protocol/openid-connect/auth`, authorizationUrl: `${baseUrl}/realms/master/protocol/openid-connect/auth`,
logoutUrl: `${kc}/realms/master/protocol/openid-connect/logout`, logoutUrl: `${baseUrl}/realms/master/protocol/openid-connect/logout`,
userInfoUrl: `${kc}/realms/master/protocol/openid-connect/userinfo`, userInfoUrl: `${baseUrl}/realms/master/protocol/openid-connect/userinfo`,
}, },
}; };

View file

@ -5,9 +5,12 @@ import RealmRepresentation from "@keycloak/keycloak-admin-client/lib/defs/realmR
import type { UserProfileConfig } from "@keycloak/keycloak-admin-client/lib/defs/userProfileMetadata"; import type { UserProfileConfig } from "@keycloak/keycloak-admin-client/lib/defs/userProfileMetadata";
import UserRepresentation from "@keycloak/keycloak-admin-client/lib/defs/userRepresentation"; import UserRepresentation from "@keycloak/keycloak-admin-client/lib/defs/userRepresentation";
import { DEFAULT_REALM } from "../src/constants";
import { getBaseUrl } from "./utils";
const adminClient = new KeycloakAdminClient({ const adminClient = new KeycloakAdminClient({
baseUrl: process.env.KEYCLOAK_SERVER || "http://127.0.0.1:8080", baseUrl: getBaseUrl(),
realmName: "master", realmName: DEFAULT_REALM,
}); });
await adminClient.auth({ await adminClient.auth({
@ -18,9 +21,12 @@ await adminClient.auth({
}); });
export async function useTheme() { export async function useTheme() {
const masterRealm = await adminClient.realms.findOne({ realm: "master" }); const masterRealm = await adminClient.realms.findOne({
realm: DEFAULT_REALM,
});
await adminClient.realms.update( await adminClient.realms.update(
{ realm: "master" }, { realm: DEFAULT_REALM },
{ ...masterRealm, accountTheme: "keycloak.v3" }, { ...masterRealm, accountTheme: "keycloak.v3" },
); );
} }
@ -76,7 +82,7 @@ export async function importUserProfile(
await adminClient.users.updateProfile({ ...userProfile, realm }); await adminClient.users.updateProfile({ ...userProfile, realm });
} }
export async function enableLocalization(realm: string) { export async function enableLocalization(realm = DEFAULT_REALM) {
const realmRepresentation = await adminClient.realms.findOne({ realm }); const realmRepresentation = await adminClient.realms.findOne({ realm });
await adminClient.realms.update( await adminClient.realms.update(
{ realm }, { realm },
@ -121,9 +127,9 @@ export async function getUserByUsername(username: string, realm: string) {
export async function deleteUser(username: string) { export async function deleteUser(username: string) {
try { try {
const users = await adminClient.users.find({ username, realm }); const users = await adminClient.users.find({ username });
const { id } = users[0]; const { id } = users[0];
await adminClient.users.del({ id: id!, realm }); await adminClient.users.del({ id: id! });
} catch (error) { } catch (error) {
console.error(error); console.error(error);
} }

View file

@ -1,10 +1,12 @@
import { expect, test } from "@playwright/test"; import { expect, test } from "@playwright/test";
import { getRootPath } from "../src/utils/getRootPath";
import { login } from "./login"; import { login } from "./login";
import { getAccountUrl, getAdminUrl } from "./utils";
test.describe("Applications test", () => { test.describe("Applications test", () => {
test.beforeEach(async ({ page }) => { test.beforeEach(async ({ page }) => {
// Sign out all devices before each test // Sign out all devices before each test
await login(page, "admin", "admin", "master"); await login(page, "admin", "admin");
await page.getByTestId("accountSecurity").click(); await page.getByTestId("accountSecurity").click();
await page.getByTestId("account-security/device-activity").click(); await page.getByTestId("account-security/device-activity").click();
@ -19,7 +21,7 @@ test.describe("Applications test", () => {
}); });
test("Single application", async ({ page }) => { test("Single application", async ({ page }) => {
await login(page, "admin", "admin", "master"); await login(page, "admin", "admin");
await page.getByTestId("applications").click(); await page.getByTestId("applications").click();
@ -39,8 +41,8 @@ test.describe("Applications test", () => {
const page1 = await context1.newPage(); const page1 = await context1.newPage();
const page2 = await context2.newPage(); const page2 = await context2.newPage();
await login(page1, "admin", "admin", "master"); await login(page1, "admin", "admin");
await login(page2, "admin", "admin", "master"); await login(page2, "admin", "admin");
await page1.getByTestId("applications").click(); await page1.getByTestId("applications").click();
@ -62,15 +64,15 @@ test.describe("Applications test", () => {
"Skip this test if not running with regular Keycloak", "Skip this test if not running with regular Keycloak",
); );
await login(page, "admin", "admin", "master"); await login(page, "admin", "admin");
// go to admin console // go to admin console
await page.goto("/"); await page.goto("/");
await expect(page).toHaveURL("http://localhost:8080/admin/master/console/"); await expect(page).toHaveURL(getAdminUrl());
await page.waitForURL("http://localhost:8080/admin/master/console/"); await page.waitForURL(getAdminUrl());
await page.goto("/realms/master/account"); await page.goto(getRootPath());
await page.waitForURL("http://localhost:8080/realms/master/account/"); await page.waitForURL(getAccountUrl());
await page.getByTestId("applications").click(); await page.getByTestId("applications").click();

View file

@ -1,15 +1,16 @@
import { Page } from "@playwright/test"; import { Page } from "@playwright/test";
import { DEFAULT_REALM } from "../src/constants";
import { getRootPath } from "../src/utils/getRootPath";
export const login = async ( export const login = async (
page: Page, page: Page,
username: string, username: string,
password: string, password: string,
realm?: string, realm = DEFAULT_REALM,
) => { ) => {
if (realm) const rootPath = getRootPath(realm);
await page.goto(
process.env.CI ? `/realms/${realm}/account` : `/?realm=${realm}`, await page.goto(rootPath);
);
await page.getByLabel("Username").fill(username); await page.getByLabel("Username").fill(username);
await page.getByLabel("Password", { exact: true }).fill(password); await page.getByLabel("Password", { exact: true }).fill(password);
await page.getByRole("button", { name: "Sign In" }).click(); await page.getByRole("button", { name: "Sign In" }).click();

View file

@ -18,7 +18,7 @@ test.describe("Personal info page", () => {
test("sets basic information", async ({ page }) => { test("sets basic information", async ({ page }) => {
user = await createRandomUserWithPassword("user-" + randomUUID(), "pwd"); user = await createRandomUserWithPassword("user-" + randomUUID(), "pwd");
await login(page, user, "pwd", "master"); await login(page, user, "pwd");
await page.getByTestId("email").fill(`${user}@somewhere.com`); await page.getByTestId("email").fill(`${user}@somewhere.com`);
await page.getByTestId("firstName").fill("Erik"); await page.getByTestId("firstName").fill("Erik");
@ -84,7 +84,7 @@ test.describe("Personal info with userprofile enabled", async () => {
// skip currently the locale is not part of the response // skip currently the locale is not part of the response
test.describe.skip("Realm localization", async () => { test.describe.skip("Realm localization", async () => {
test.beforeAll(() => enableLocalization("master")); test.beforeAll(() => enableLocalization());
test("change locale", async ({ page }) => { test("change locale", async ({ page }) => {
const user = await createRandomUserWithPassword( const user = await createRandomUserWithPassword(
@ -92,7 +92,7 @@ test.describe.skip("Realm localization", async () => {
"pwd", "pwd",
); );
await login(page, user, "pwd", "master"); await login(page, user, "pwd");
await page await page
.locator("div") .locator("div")
.filter({ hasText: /^Deutsch$/ }) .filter({ hasText: /^Deutsch$/ })

View file

@ -0,0 +1,13 @@
import { getRootPath } from "../src/utils/getRootPath";
export function getBaseUrl(): string {
return process.env.KEYCLOAK_SERVER ?? "http://localhost:8080";
}
export function getAccountUrl() {
return getBaseUrl() + getRootPath();
}
export function getAdminUrl() {
return getBaseUrl() + "/admin/master/console/";
}

View file

@ -4,6 +4,8 @@ import { defineConfig, loadEnv } from "vite";
import { checker } from "vite-plugin-checker"; import { checker } from "vite-plugin-checker";
import dts from "vite-plugin-dts"; import dts from "vite-plugin-dts";
import { getRootPath } from "./src/utils/getRootPath";
// https://vitejs.dev/config/ // https://vitejs.dev/config/
export default defineConfig(({ mode }) => { export default defineConfig(({ mode }) => {
const env = loadEnv(mode, process.cwd(), ""); const env = loadEnv(mode, process.cwd(), "");
@ -26,6 +28,7 @@ export default defineConfig(({ mode }) => {
base: "", base: "",
server: { server: {
port: 8080, port: 8080,
open: getRootPath(),
}, },
build: { build: {
...lib, ...lib,

View file

@ -18,7 +18,7 @@ describe("Masthead tests", () => {
it("Go to account console and back to admin console", () => { it("Go to account console and back to admin console", () => {
sidebarPage.waitForPageLoad(); sidebarPage.waitForPageLoad();
masthead.accountManagement(); masthead.accountManagement();
cy.url().should("contain", "/realms/master/account/"); cy.url().should("contain", "/realms/master/account");
}); });
it("Sign out reachs to log in screen", () => { it("Sign out reachs to log in screen", () => {

View file

@ -92,76 +92,72 @@ public class AccountConsole implements AccountResourceProvider {
@GET @GET
@NoCache @NoCache
@Path("{any:.*}")
public Response getMainPage() throws IOException, FreeMarkerException { public Response getMainPage() throws IOException, FreeMarkerException {
UriInfo uriInfo = session.getContext().getUri(UrlType.FRONTEND); UriInfo uriInfo = session.getContext().getUri(UrlType.FRONTEND);
URI accountBaseUrl = uriInfo.getBaseUriBuilder().path(RealmsResource.class).path(realm.getName()) URI accountBaseUrl = uriInfo.getBaseUriBuilder().path(RealmsResource.class).path(realm.getName())
.path(Constants.ACCOUNT_MANAGEMENT_CLIENT_ID).path("/").build(realm); .path(Constants.ACCOUNT_MANAGEMENT_CLIENT_ID).path("/").build(realm);
if (!session.getContext().getUri().getRequestUri().getPath().endsWith("/")) { Map<String, Object> map = new HashMap<>();
UriBuilder redirectUri = session.getContext().getUri().getRequestUriBuilder().uri(accountBaseUrl);
return Response.status(302).location(redirectUri.build()).build();
} else {
Map<String, Object> map = new HashMap<>();
URI adminBaseUri = session.getContext().getUri(UrlType.ADMIN).getBaseUri(); URI adminBaseUri = session.getContext().getUri(UrlType.ADMIN).getBaseUri();
URI authUrl = uriInfo.getBaseUri(); URI authUrl = uriInfo.getBaseUri();
map.put("authUrl", authUrl.getPath().endsWith("/") ? authUrl : authUrl + "/"); map.put("authUrl", authUrl.getPath().endsWith("/") ? authUrl : authUrl + "/");
map.put("baseUrl", accountBaseUrl); map.put("baseUrl", accountBaseUrl);
map.put("realm", realm); map.put("realm", realm);
map.put("clientId", Constants.ACCOUNT_CONSOLE_CLIENT_ID); map.put("clientId", Constants.ACCOUNT_CONSOLE_CLIENT_ID);
map.put("resourceUrl", Urls.themeRoot(authUrl).getPath() + "/" + Constants.ACCOUNT_MANAGEMENT_CLIENT_ID + "/" + theme.getName()); map.put("resourceUrl", Urls.themeRoot(authUrl).getPath() + "/" + Constants.ACCOUNT_MANAGEMENT_CLIENT_ID + "/" + theme.getName());
map.put("resourceCommonUrl", Urls.themeRoot(adminBaseUri).getPath() + "/common/keycloak"); map.put("resourceCommonUrl", Urls.themeRoot(adminBaseUri).getPath() + "/common/keycloak");
map.put("resourceVersion", Version.RESOURCES_VERSION); map.put("resourceVersion", Version.RESOURCES_VERSION);
String[] referrer = getReferrer();
if (referrer != null) {
map.put("referrer", referrer[0]);
map.put("referrerName", referrer[1]);
map.put("referrer_uri", referrer[2]);
}
UserModel user = null;
if (auth != null) user = auth.getUser();
Locale locale = session.getContext().resolveLocale(user);
map.put("locale", locale.toLanguageTag());
Properties messages = theme.getEnhancedMessages(realm, locale);
map.put("msg", new MessageFormatterMethod(locale, messages));
map.put("msgJSON", messagesToJsonString(messages));
map.put("supportedLocales", supportedLocales(messages));
map.put("properties", theme.getProperties());
map.put("theme", (Function<String, String>) file -> {
try {
final InputStream resource = theme.getResourceAsStream(file);
return new Scanner(resource, "UTF-8").useDelimiter("\\A").next();
} catch (IOException e) {
throw new RuntimeException("could not load file", e);
}
});
map.put("isAuthorizationEnabled", Profile.isFeatureEnabled(Profile.Feature.AUTHORIZATION)); String[] referrer = getReferrer();
if (referrer != null) {
boolean deleteAccountAllowed = false; map.put("referrer", referrer[0]);
boolean isViewGroupsEnabled= false; map.put("referrerName", referrer[1]);
if (user != null) { map.put("referrer_uri", referrer[2]);
RoleModel deleteAccountRole = realm.getClientByClientId(Constants.ACCOUNT_MANAGEMENT_CLIENT_ID).getRole(AccountRoles.DELETE_ACCOUNT);
deleteAccountAllowed = deleteAccountRole != null && user.hasRole(deleteAccountRole) && realm.getRequiredActionProviderByAlias(DeleteAccount.PROVIDER_ID).isEnabled();
RoleModel viewGrouRole = realm.getClientByClientId(Constants.ACCOUNT_MANAGEMENT_CLIENT_ID).getRole(AccountRoles.VIEW_GROUPS);
isViewGroupsEnabled = viewGrouRole != null && user.hasRole(viewGrouRole);
}
map.put("deleteAccountAllowed", deleteAccountAllowed);
map.put("isViewGroupsEnabled", isViewGroupsEnabled);
map.put("updateEmailFeatureEnabled", Profile.isFeatureEnabled(Profile.Feature.UPDATE_EMAIL));
RequiredActionProviderModel updateEmailActionProvider = realm.getRequiredActionProviderByAlias(UserModel.RequiredAction.UPDATE_EMAIL.name());
map.put("updateEmailActionEnabled", updateEmailActionProvider != null && updateEmailActionProvider.isEnabled());
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);
return builder.build();
} }
UserModel user = null;
if (auth != null) user = auth.getUser();
Locale locale = session.getContext().resolveLocale(user);
map.put("locale", locale.toLanguageTag());
Properties messages = theme.getEnhancedMessages(realm, locale);
map.put("msg", new MessageFormatterMethod(locale, messages));
map.put("msgJSON", messagesToJsonString(messages));
map.put("supportedLocales", supportedLocales(messages));
map.put("properties", theme.getProperties());
map.put("theme", (Function<String, String>) file -> {
try {
final InputStream resource = theme.getResourceAsStream(file);
return new Scanner(resource, "UTF-8").useDelimiter("\\A").next();
} catch (IOException e) {
throw new RuntimeException("could not load file", e);
}
});
map.put("isAuthorizationEnabled", Profile.isFeatureEnabled(Profile.Feature.AUTHORIZATION));
boolean deleteAccountAllowed = false;
boolean isViewGroupsEnabled= false;
if (user != null) {
RoleModel deleteAccountRole = realm.getClientByClientId(Constants.ACCOUNT_MANAGEMENT_CLIENT_ID).getRole(AccountRoles.DELETE_ACCOUNT);
deleteAccountAllowed = deleteAccountRole != null && user.hasRole(deleteAccountRole) && realm.getRequiredActionProviderByAlias(DeleteAccount.PROVIDER_ID).isEnabled();
RoleModel viewGrouRole = realm.getClientByClientId(Constants.ACCOUNT_MANAGEMENT_CLIENT_ID).getRole(AccountRoles.VIEW_GROUPS);
isViewGroupsEnabled = viewGrouRole != null && user.hasRole(viewGrouRole);
}
map.put("deleteAccountAllowed", deleteAccountAllowed);
map.put("isViewGroupsEnabled", isViewGroupsEnabled);
map.put("updateEmailFeatureEnabled", Profile.isFeatureEnabled(Profile.Feature.UPDATE_EMAIL));
RequiredActionProviderModel updateEmailActionProvider = realm.getRequiredActionProviderByAlias(UserModel.RequiredAction.UPDATE_EMAIL.name());
map.put("updateEmailActionEnabled", updateEmailActionProvider != null && updateEmailActionProvider.isEnabled());
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);
return builder.build();
} }
private Map<String, String> supportedLocales(Properties messages) { private Map<String, String> supportedLocales(Properties messages) {