Disable the Account UI when the ACCOUNT feature is disabled (#48807)

Closes #48806

Signed-off-by: Martin Bartoš <mabartos@redhat.com>
This commit is contained in:
Martin Bartoš
2026-05-25 15:37:15 +02:00
committed by GitHub
parent 577bddb8e5
commit 629e86afd2
8 changed files with 118 additions and 9 deletions
+7 -1
View File
@@ -14,12 +14,18 @@ import { useRealm } from "./context/realm-context/RealmContext";
import { toDashboard } from "./dashboard/routes/Dashboard";
import { usePreviewLogo } from "./realm-settings/themes/LogoContext";
import { joinPath } from "./utils/joinPath";
import { useIsFeatureDisabled, Feature } from "./utils/useIsFeatureEnabled";
import useToggle from "./utils/useToggle";
const ManageAccountDropdownItem = () => {
const { keycloak } = useEnvironment();
const { t } = useTranslation();
const isFeatureDisabled = useIsFeatureDisabled();
if (isFeatureDisabled(Feature.AccountV3)) {
return null;
}
return (
<DropdownItem
key="manage account"
@@ -0,0 +1,26 @@
import { describe, expect, it } from "vitest";
import { unversionedName } from "./useIsFeatureEnabled";
describe("unversionedName", () => {
it("strips version suffix from feature names", () => {
expect(unversionedName("ACCOUNT_V3")).toBe("ACCOUNT");
expect(unversionedName("ADMIN_FINE_GRAINED_AUTHZ_V2")).toBe(
"ADMIN_FINE_GRAINED_AUTHZ",
);
expect(unversionedName("TOKEN_EXCHANGE_STANDARD_V2")).toBe(
"TOKEN_EXCHANGE_STANDARD",
);
});
it("returns name unchanged when there is no version suffix", () => {
expect(unversionedName("ACCOUNT_API")).toBe("ACCOUNT_API");
expect(unversionedName("ORGANIZATION")).toBe("ORGANIZATION");
expect(unversionedName("DPOP")).toBe("DPOP");
expect(unversionedName("CLIENT_POLICIES")).toBe("CLIENT_POLICIES");
});
it("only strips trailing version suffix", () => {
expect(unversionedName("V2_SOMETHING")).toBe("V2_SOMETHING");
expect(unversionedName("FEATURE_V2_EXTRA")).toBe("FEATURE_V2_EXTRA");
});
});
@@ -2,6 +2,7 @@ import { useServerInfo } from "../context/server-info/ServerInfoProvider";
import { useAccess } from "../context/access/Access";
export enum Feature {
AccountV3 = "ACCOUNT_V3",
AdminFineGrainedAuthz = "ADMIN_FINE_GRAINED_AUTHZ",
AdminFineGrainedAuthzV2 = "ADMIN_FINE_GRAINED_AUTHZ_V2",
ClientPolicies = "CLIENT_POLICIES",
@@ -26,6 +27,8 @@ export enum Feature {
IdentityBrokeringAPIV2 = "IDENTITY_BROKERING_API_V2",
}
export const unversionedName = (name: string) => name.replace(/_V\d+$/, "");
export default function useIsFeatureEnabled() {
const { features } = useServerInfo();
const { hasAccess } = useAccess();
@@ -51,3 +54,16 @@ export default function useIsFeatureEnabled() {
.includes(feature);
};
}
export function useIsFeatureDisabled() {
const { features } = useServerInfo();
return function isFeatureDisabled(feature: Feature) {
if (!features) {
return true;
}
return !features.some(
(f) => f.enabled && unversionedName(f.name!) === unversionedName(feature),
);
};
}
@@ -1,9 +1,17 @@
package org.keycloak.services.resource;
import org.keycloak.Config;
import org.keycloak.common.Profile;
import org.keycloak.provider.EnvironmentDependentProviderFactory;
import org.keycloak.provider.ProviderFactory;
/**
* <p>A factory that creates {@link AccountResourceProvider} instances.
*/
public interface AccountResourceProviderFactory extends ProviderFactory<AccountResourceProvider> {
public interface AccountResourceProviderFactory extends ProviderFactory<AccountResourceProvider>, EnvironmentDependentProviderFactory {
@Override
default boolean isSupported(Config.Scope config) {
return Profile.isAnyVersionOfFeatureEnabled(Profile.Feature.ACCOUNT_V3);
}
}
@@ -168,11 +168,12 @@ public class AccountLoader {
}
private AccountResourceProvider getAccountResourceProvider(Theme theme) {
try {
if (theme.getProperties().containsKey(Theme.ACCOUNT_RESOURCE_PROVIDER_KEY)) {
return session.getProvider(AccountResourceProvider.class, theme.getProperties().getProperty(Theme.ACCOUNT_RESOURCE_PROVIDER_KEY));
try {
if (theme != null && theme.getProperties().containsKey(Theme.ACCOUNT_RESOURCE_PROVIDER_KEY)) {
return session.getProvider(AccountResourceProvider.class, theme.getProperties().getProperty(Theme.ACCOUNT_RESOURCE_PROVIDER_KEY));
}
} catch (IOException ignore) {
}
} catch (IOException ignore) {}
return session.getProvider(AccountResourceProvider.class);
return session.getProvider(AccountResourceProvider.class);
}
}
@@ -75,7 +75,11 @@ public class DefaultThemeManager implements ThemeManager {
if (theme == null) {
String defaultThemeName = session.getProvider(ThemeSelectorProvider.class).getDefaultThemeName(type);
theme = loadTheme(defaultThemeName, type, realm);
log.errorv("Failed to find {0} theme {1}, using built-in themes", type, name);
if (theme == null) {
log.warnv("Failed to find {0} theme {1}, as it might be unavailable because a required feature is disabled", type, name);
} else {
log.errorv("Failed to find {0} theme {1}, using built-in themes", type, name);
}
} else {
theme = factory.addCachedTheme(name, type, theme);
}
@@ -132,7 +132,9 @@ public class KeycloakServerConfigBuilder {
* @return
*/
public KeycloakServerConfigBuilder featuresDisabled(Profile.Feature... features) {
this.featuresDisabled.addAll(toFeatureStrings(features));
this.featuresDisabled.addAll(Arrays.stream(features)
.map(Profile.Feature::getUnversionedKey)
.collect(Collectors.toSet()));
return this;
}
@@ -0,0 +1,46 @@
package org.keycloak.tests.account;
import java.io.IOException;
import org.keycloak.common.Profile;
import org.keycloak.testframework.annotations.InjectHttpClient;
import org.keycloak.testframework.annotations.InjectRealm;
import org.keycloak.testframework.annotations.KeycloakIntegrationTest;
import org.keycloak.testframework.realm.ManagedRealm;
import org.keycloak.testframework.server.KeycloakServerConfig;
import org.keycloak.testframework.server.KeycloakServerConfigBuilder;
import org.apache.http.client.methods.CloseableHttpResponse;
import org.apache.http.client.methods.HttpGet;
import org.apache.http.impl.client.CloseableHttpClient;
import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.assertEquals;
@KeycloakIntegrationTest(config = AccountConsoleDisabledTest.ServerConfig.class)
public class AccountConsoleDisabledTest {
@InjectRealm
ManagedRealm realm;
@InjectHttpClient
CloseableHttpClient httpClient;
@Test
public void accountConsoleReturns404WhenDisabled() throws IOException {
HttpGet request = new HttpGet(realm.getBaseUrl() + "/account/");
try (CloseableHttpResponse response = httpClient.execute(request)) {
assertEquals(404, response.getStatusLine().getStatusCode(),
"Account console should return 404 when ACCOUNT_V3 feature is disabled");
}
}
public static class ServerConfig implements KeycloakServerConfig {
@Override
public KeycloakServerConfigBuilder configure(KeycloakServerConfigBuilder config) {
return config.featuresDisabled(Profile.Feature.ACCOUNT_V3);
}
}
}