Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ public class PasswordPolicyTypeRepresentation {
private String configType;
private String defaultValue;
private boolean multipleSupported;
private String helpText;

public String getId() {
return id;
Expand Down Expand Up @@ -67,4 +68,12 @@ public boolean isMultipleSupported() {
public void setMultipleSupported(boolean multipleSupported) {
this.multipleSupported = multipleSupported;
}

public String getHelpText() {
return helpText;
}

public void setHelpText(String helpText) {
this.helpText = helpText;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -154,14 +154,16 @@ See https://www.keycloak.org/ui-customization/themes[Working with themes: Theme

=== Providing help text for the Admin Console

The Admin Console shows a tooltip for each policy row, resolved from the `admin-messages` bundle using the key `passwordPoliciesHelp.<policy id>`. Ship the key from the provider JAR in a `theme-resources/messages/admin-messages_en.properties` file:
The Admin Console shows a tooltip for each policy row, resolved from the `admin-messages` bundle using the key `passwordPoliciesHelp.<policy id>`. A translation defined by the theme or realm takes precedence over help text supplied by the provider. Ship the key from the provider JAR in a `theme-resources/messages/admin-messages_en.properties` file:

[source]
----
passwordPoliciesHelp.maxAcme=Maximum number of times the string "acme" may occur in the password.
----

Without this key the Admin Console shows the raw message key as the tooltip. Add an `admin-messages_<locale>.properties` file per supported locale.
Add an `admin-messages_<locale>.properties` file per supported locale.

As a non-localized fallback, the factory can implement `org.keycloak.provider.ConfiguredProvider` and return the help text from `getHelpText()`. The Admin Console uses this value only when no translation for `passwordPoliciesHelp.<policy id>` is available. If neither a translation nor non-empty provider help text is available, the Admin Console shows the raw message key as the tooltip.

=== Registering the provider

Expand Down
78 changes: 78 additions & 0 deletions js/apps/admin-ui/src/authentication/policies/PolicyRow.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
import { cleanup, render, screen } from "@testing-library/react";
import i18n, { type i18n as I18nInstance } from "i18next";
import type { ReactNode } from "react";
import { FormProvider, useForm } from "react-hook-form";
import { I18nextProvider, initReactI18next } from "react-i18next";
import { afterEach, beforeAll, describe, expect, it, vi } from "vitest";

import { PolicyRow } from "./PolicyRow";

vi.mock("@keycloak/keycloak-ui-shared", async (importOriginal) => ({
...(await importOriginal()),
HelpItem: ({ helpText }: { helpText: string }) => (
<span data-testid="policy-help-text">{helpText}</span>
),
}));

let testI18n: I18nInstance;

afterEach(cleanup);

beforeAll(async () => {
testI18n = i18n.createInstance();
await testI18n.use(initReactI18next).init({
lng: "en",
resources: {
en: {
translation: {
passwordPoliciesHelp: {
translatedPolicy: "Translated policy help text.",
},
},
},
},
});
});

const TestForm = ({ children }: { children: ReactNode }) => {
const form = useForm();
return <FormProvider {...form}>{children}</FormProvider>;
};

const renderPolicy = (id: string, helpText: string) =>
render(
<I18nextProvider i18n={testI18n}>
<TestForm>
<PolicyRow
policy={{ id, displayName: id, helpText }}
onRemove={vi.fn()}
/>
</TestForm>
</I18nextProvider>,
);

describe("PolicyRow help text", () => {
it("falls back to the provider help text when no translation exists", () => {
renderPolicy("custom-policy", "Provider help text.");

expect(screen.getByTestId("policy-help-text").textContent).toBe(
"Provider help text.",
);
});

it("prefers an existing translation over the provider help text", () => {
renderPolicy("translatedPolicy", "Provider help text.");

expect(screen.getByTestId("policy-help-text").textContent).toBe(
"Translated policy help text.",
);
});

it("falls back to the raw message key when provider help text is empty", () => {
renderPolicy("empty-help-text-policy", "");

expect(screen.getByTestId("policy-help-text").textContent).toBe(
"passwordPoliciesHelp.empty-help-text-policy",
);
});
});
6 changes: 4 additions & 2 deletions js/apps/admin-ui/src/authentication/policies/PolicyRow.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ type PolicyRowProps = {
};

export const PolicyRow = ({
policy: { id, configType, defaultValue, displayName },
policy: { id, configType, defaultValue, displayName, helpText },
onRemove,
}: PolicyRowProps) => {
const { t } = useTranslation();
Expand All @@ -41,7 +41,9 @@ export const PolicyRow = ({
isRequired
labelIcon={
<HelpItem
helpText={t(`passwordPoliciesHelp.${id}`)}
helpText={t(`passwordPoliciesHelp.${id}`, {
defaultValue: helpText || `passwordPoliciesHelp.${id}`,
})}
Comment thread
enoquefcd marked this conversation as resolved.
Resolved
fieldLabelId={id!}
/>
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,4 +7,5 @@ export default interface PasswordPolicyTypeRepresentation {
configType?: string;
defaultValue?: string;
multipleSupported?: boolean;
helpText?: string;
}
Original file line number Diff line number Diff line change
Expand Up @@ -398,6 +398,9 @@ private void setPasswordPolicies(ServerInfoRepresentation info) {
rep.setConfigType(factory.getConfigType());
rep.setDefaultValue(factory.getDefaultConfigValue());
rep.setMultipleSupported(factory.isMultiplSupported());
if (factory instanceof ConfiguredProvider configured) {
rep.setHelpText(configured.getHelpText());
}
return rep;
})
.collect(Collectors.toList());
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
package org.keycloak.tests.admin;

import org.keycloak.admin.client.Keycloak;
import org.keycloak.representations.idm.PasswordPolicyTypeRepresentation;
import org.keycloak.representations.info.ServerInfoRepresentation;
import org.keycloak.testframework.annotations.InjectAdminClient;
import org.keycloak.testframework.annotations.KeycloakIntegrationTest;
import org.keycloak.tests.common.CustomProvidersServerConfig;
import org.keycloak.tests.providers.policy.TestConfiguredPasswordPolicyProviderFactory;

import org.junit.jupiter.api.Assertions;
import org.junit.jupiter.api.Test;

import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertNotNull;

@KeycloakIntegrationTest(config = CustomProvidersServerConfig.class)
public class ServerInfoPasswordPolicyTest {

@InjectAdminClient
Keycloak adminClient;

@Test
public void testPasswordPolicyHelpText() {
ServerInfoRepresentation info = adminClient.serverInfo().getInfo();
assertNotNull(info.getPasswordPolicies());

PasswordPolicyTypeRepresentation configuredPolicy = findPolicy(info, TestConfiguredPasswordPolicyProviderFactory.ID);
assertEquals(TestConfiguredPasswordPolicyProviderFactory.HELP_TEXT, configuredPolicy.getHelpText());
assertEquals("Test Configured Policy", configuredPolicy.getDisplayName());

PasswordPolicyTypeRepresentation lengthPolicy = findPolicy(info, "length");
Assertions.assertNull(lengthPolicy.getHelpText());
}

private PasswordPolicyTypeRepresentation findPolicy(ServerInfoRepresentation info, String id) {
return info.getPasswordPolicies().stream()
.filter(policy -> id.equals(policy.getId()))
.findFirst().orElseThrow(() -> new RuntimeException("Not found password policy with ID '" + id + "'"));
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,108 @@
package org.keycloak.tests.providers.policy;

import java.util.List;

import org.keycloak.Config;
import org.keycloak.models.KeycloakSession;
import org.keycloak.models.KeycloakSessionFactory;
import org.keycloak.models.RealmModel;
import org.keycloak.models.UserModel;
import org.keycloak.policy.PasswordPolicyProvider;
import org.keycloak.policy.PasswordPolicyProviderFactory;
import org.keycloak.policy.PolicyError;
import org.keycloak.provider.ConfiguredProvider;
import org.keycloak.provider.ProviderConfigProperty;
import org.keycloak.provider.ProviderConfigurationBuilder;

/**
* Custom password policy provider implementing {@link ConfiguredProvider}, used to verify that the
* provider-supplied help text is exposed through the server info endpoint.
*/
public class TestConfiguredPasswordPolicyProviderFactory implements PasswordPolicyProviderFactory, ConfiguredProvider {

public static final String ID = "test-configured-policy";

public static final String HELP_TEXT = "Test policy help text provided by the factory.";

@Override
public String getId() {
return ID;
}

@Override
public PasswordPolicyProvider create(KeycloakSession session) {
return new TestConfiguredPasswordPolicyProvider();
}

@Override
public void init(Config.Scope config) {
}

@Override
public void postInit(KeycloakSessionFactory factory) {
}

@Override
public String getDisplayName() {
return "Test Configured Policy";
}

@Override
public String getConfigType() {
return PasswordPolicyProvider.STRING_CONFIG_TYPE;
}

@Override
public String getDefaultConfigValue() {
return "default-value";
}

@Override
public boolean isMultiplSupported() {
return false;
}

@Override
public String getHelpText() {
return HELP_TEXT;
}

@Override
public List<ProviderConfigProperty> getConfigProperties() {
return ProviderConfigurationBuilder.create()
.property()
.name("value")
.label("Value")
.helpText("Value of the test policy.")
.type(ProviderConfigProperty.STRING_TYPE)
.defaultValue("default-value")
.add()
.build();
}

@Override
public void close() {
}

private static class TestConfiguredPasswordPolicyProvider implements PasswordPolicyProvider {

@Override
public PolicyError validate(RealmModel realm, UserModel user, String password) {
return null;
}

@Override
public PolicyError validate(String user, String password) {
return null;
}

@Override
public Object parseConfig(String value) {
return value;
}

@Override
public void close() {
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
org.keycloak.tests.providers.policy.TestConfiguredPasswordPolicyProviderFactory
Loading