diff --git a/js/apps/admin-ui/src/PageHeader.tsx b/js/apps/admin-ui/src/PageHeader.tsx index 3bc964a720d..957cf2e99d5 100644 --- a/js/apps/admin-ui/src/PageHeader.tsx +++ b/js/apps/admin-ui/src/PageHeader.tsx @@ -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 ( { + 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"); + }); +}); diff --git a/js/apps/admin-ui/src/utils/useIsFeatureEnabled.ts b/js/apps/admin-ui/src/utils/useIsFeatureEnabled.ts index be22af73ce0..d26a38d7f7c 100644 --- a/js/apps/admin-ui/src/utils/useIsFeatureEnabled.ts +++ b/js/apps/admin-ui/src/utils/useIsFeatureEnabled.ts @@ -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), + ); + }; +} diff --git a/server-spi-private/src/main/java/org/keycloak/services/resource/AccountResourceProviderFactory.java b/server-spi-private/src/main/java/org/keycloak/services/resource/AccountResourceProviderFactory.java index 8a9c0ce2fde..804b3afdaea 100644 --- a/server-spi-private/src/main/java/org/keycloak/services/resource/AccountResourceProviderFactory.java +++ b/server-spi-private/src/main/java/org/keycloak/services/resource/AccountResourceProviderFactory.java @@ -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; /** *

A factory that creates {@link AccountResourceProvider} instances. */ -public interface AccountResourceProviderFactory extends ProviderFactory { +public interface AccountResourceProviderFactory extends ProviderFactory, EnvironmentDependentProviderFactory { + + @Override + default boolean isSupported(Config.Scope config) { + return Profile.isAnyVersionOfFeatureEnabled(Profile.Feature.ACCOUNT_V3); + } } diff --git a/services/src/main/java/org/keycloak/services/resources/account/AccountLoader.java b/services/src/main/java/org/keycloak/services/resources/account/AccountLoader.java index 129d3cf2848..c6050d3da47 100644 --- a/services/src/main/java/org/keycloak/services/resources/account/AccountLoader.java +++ b/services/src/main/java/org/keycloak/services/resources/account/AccountLoader.java @@ -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); } } diff --git a/services/src/main/java/org/keycloak/theme/DefaultThemeManager.java b/services/src/main/java/org/keycloak/theme/DefaultThemeManager.java index eb72ceb06bb..2b353f42ade 100755 --- a/services/src/main/java/org/keycloak/theme/DefaultThemeManager.java +++ b/services/src/main/java/org/keycloak/theme/DefaultThemeManager.java @@ -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); } diff --git a/test-framework/core/src/main/java/org/keycloak/testframework/server/KeycloakServerConfigBuilder.java b/test-framework/core/src/main/java/org/keycloak/testframework/server/KeycloakServerConfigBuilder.java index 42da79b6370..0fd2e923d63 100644 --- a/test-framework/core/src/main/java/org/keycloak/testframework/server/KeycloakServerConfigBuilder.java +++ b/test-framework/core/src/main/java/org/keycloak/testframework/server/KeycloakServerConfigBuilder.java @@ -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; } diff --git a/tests/base/src/test/java/org/keycloak/tests/account/AccountConsoleDisabledTest.java b/tests/base/src/test/java/org/keycloak/tests/account/AccountConsoleDisabledTest.java new file mode 100644 index 00000000000..9df041a666b --- /dev/null +++ b/tests/base/src/test/java/org/keycloak/tests/account/AccountConsoleDisabledTest.java @@ -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); + } + } +}