diff --git a/.github/workflows/cypress.yml b/.github/workflows/cypress.yml index 3f63d09c36..857de9996d 100644 --- a/.github/workflows/cypress.yml +++ b/.github/workflows/cypress.yml @@ -53,7 +53,7 @@ jobs: working-directory: ${{ env.KEYCLOAK_SERVER_BIN }} run: | ./add-user-keycloak.sh --user admin --password admin - ./standalone.sh -Dkeycloak.profile.feature.admin2=enabled & + ./standalone.sh -Dkeycloak.profile.feature.admin2=enabled -Dkeycloak.profile.feature.declarative_user_profile=enabled & - name: Configure Keycloak Server working-directory: ${{ env.KEYCLOAK_SERVER_BIN }} diff --git a/cypress/integration/realm_settings_events_test.spec.ts b/cypress/integration/realm_settings_events_test.spec.ts index 641e56f92a..efcbfbf72a 100644 --- a/cypress/integration/realm_settings_events_test.spec.ts +++ b/cypress/integration/realm_settings_events_test.spec.ts @@ -88,11 +88,13 @@ describe("Realm settings events tab tests", () => { }; it("Enable user events", () => { - cy.intercept("GET", `/auth/admin/realms/${realmName}/keys`).as("load"); + cy.intercept("GET", `/auth/admin/realms/${realmName}/events/config`).as( + "load" + ); sidebarPage.goToRealmSettings(); cy.findByTestId("rs-realm-events-tab").click(); cy.findByTestId("rs-events-tab").click(); - cy.wait(["@load"]); + cy.wait("@load"); realmSettingsPage .toggleSwitch(realmSettingsPage.enableEvents) .save(realmSettingsPage.eventsUserSave); diff --git a/cypress/integration/realm_settings_tabs_test.spec.ts b/cypress/integration/realm_settings_tabs_test.spec.ts index 6d803f22d8..445e8c7634 100644 --- a/cypress/integration/realm_settings_tabs_test.spec.ts +++ b/cypress/integration/realm_settings_tabs_test.spec.ts @@ -37,6 +37,15 @@ describe("Realm settings tabs tests", () => { masthead.checkNotificationMessage("Realm successfully updated"); }); + it("shows the 'user profile' tab if enabled", () => { + sidebarPage.goToRealmSettings(); + cy.findByTestId(realmSettingsPage.userProfileTab).should("not.exist"); + realmSettingsPage.toggleSwitch(realmSettingsPage.profileEnabledSwitch); + realmSettingsPage.save(realmSettingsPage.generalSaveBtn); + masthead.checkNotificationMessage("Realm successfully updated"); + cy.findByTestId(realmSettingsPage.userProfileTab).should("exist"); + }); + it("Go to login tab", () => { sidebarPage.goToRealmSettings(); cy.findByTestId("rs-login-tab").click(); @@ -79,10 +88,7 @@ describe("Realm settings tabs tests", () => { it("Go to themes tab", () => { sidebarPage.goToRealmSettings(); - cy.intercept(`/auth/admin/realms/${realmName}/keys`).as("load"); - cy.findByTestId("rs-themes-tab").click(); - cy.wait(["@load"]); realmSettingsPage.selectLoginThemeType("keycloak"); realmSettingsPage.selectAccountThemeType("keycloak"); diff --git a/cypress/support/pages/admin_console/manage/realm_settings/RealmSettingsPage.ts b/cypress/support/pages/admin_console/manage/realm_settings/RealmSettingsPage.ts index a70f7a655c..a73dfed2e1 100644 --- a/cypress/support/pages/admin_console/manage/realm_settings/RealmSettingsPage.ts +++ b/cypress/support/pages/admin_console/manage/realm_settings/RealmSettingsPage.ts @@ -5,6 +5,7 @@ export default class RealmSettingsPage { generalSaveBtn = "general-tab-save"; themesSaveBtn = "themes-tab-save"; loginTab = "rs-login-tab"; + userProfileTab = "rs-user-profile-tab"; selectLoginTheme = "#kc-login-theme"; loginThemeList = "#kc-login-theme + ul"; selectAccountTheme = "#kc-account-theme"; @@ -53,6 +54,7 @@ export default class RealmSettingsPage { supportedLocalesToggle = "#kc-l-supported-locales"; emailSaveBtn = "email-tab-save"; managedAccessSwitch = "user-managed-access-switch"; + profileEnabledSwitch = "user-profile-enabled-switch"; userRegSwitch = "user-reg-switch"; forgotPwdSwitch = "forgot-pw-switch"; rememberMeSwitch = "remember-me-switch"; @@ -769,7 +771,12 @@ export default class RealmSettingsPage { shouldCancelEditingExecutor() { cy.get(this.clientProfileTwo).click(); + + cy.intercept("/auth/admin/realms/master/client-policies/profiles*").as( + "profilesFetch" + ); cy.findByTestId(this.editExecutor).first().click(); + cy.wait("@profilesFetch"); cy.findByTestId(this.addExecutorCancelBtn).click(); cy.get('ul[class*="pf-c-data-list"]').should( diff --git a/src/realm-settings/GeneralTab.tsx b/src/realm-settings/GeneralTab.tsx index 98c3a48632..ae3c933e8f 100644 --- a/src/realm-settings/GeneralTab.tsx +++ b/src/realm-settings/GeneralTab.tsx @@ -18,6 +18,7 @@ import { import type RealmRepresentation from "@keycloak/keycloak-admin-client/lib/defs/realmRepresentation"; import { getBaseUrl } from "../util"; +import useIsFeatureEnabled, { Feature } from "../utils/useIsFeatureEnabled"; import { useAdminClient } from "../context/auth/AdminClient"; import { useRealm } from "../context/realm-context/RealmContext"; import { FormAccess } from "../components/form-access/FormAccess"; @@ -42,6 +43,7 @@ export const RealmSettingsGeneralTab = ({ handleSubmit, formState: { isDirty }, } = useFormContext(); + const isFeatureEnabled = useIsFeatureEnabled(); const [open, setOpen] = useState(false); const baseUrl = getBaseUrl(adminClient); @@ -166,6 +168,35 @@ export const RealmSettingsGeneralTab = ({ )} /> + {isFeatureEnabled(Feature.DeclarativeUserProfile) && ( + + } + fieldId="kc-user-profile-enabled" + > + ( + onChange(value.toString())} + /> + )} + /> + + )} void; @@ -175,6 +176,7 @@ export const RealmSettingsTabs = ({ const { realm: realmName } = useRealm(); const { refresh: refreshRealms } = useRealms(); const history = useHistory(); + const isFeatureEnabled = useIsFeatureEnabled(); const kpComponentTypes = useServerInfo().componentTypes?.[KEY_PROVIDER_TYPE] ?? []; @@ -221,6 +223,12 @@ export const RealmSettingsTabs = ({ } }; + const userProfileEnabled = useWatch({ + control, + name: "attributes.userProfileEnabled", + defaultValue: "false", + }); + return ( <> - + {t("general")}} @@ -423,15 +431,20 @@ export const RealmSettingsTabs = ({ - {t("realm-settings:userProfile")} - } - > - - + {isFeatureEnabled(Feature.DeclarativeUserProfile) && + userProfileEnabled === "true" && ( + + {t("realm-settings:userProfile")} + + } + > + + + )} {t("userRegistration")}} diff --git a/src/realm-settings/UserProfileTab.tsx b/src/realm-settings/UserProfileTab.tsx deleted file mode 100644 index 68121bb649..0000000000 --- a/src/realm-settings/UserProfileTab.tsx +++ /dev/null @@ -1,139 +0,0 @@ -import type ClientProfilesRepresentation from "@keycloak/keycloak-admin-client/lib/defs/clientProfilesRepresentation"; -import { CodeEditor, Language } from "@patternfly/react-code-editor"; -import { - ActionGroup, - AlertVariant, - Button, - Form, - PageSection, - Tab, - Tabs, - TabTitleText, -} from "@patternfly/react-core"; -import type { editor } from "monaco-editor"; -import React, { useEffect, useState } from "react"; -import { useTranslation } from "react-i18next"; -import { useAlerts } from "../components/alert/Alerts"; -import { useAdminClient, useFetch } from "../context/auth/AdminClient"; -import { useRealm } from "../context/realm-context/RealmContext"; -import { prettyPrintJSON } from "../util"; - -export const UserProfileTab = () => { - const adminClient = useAdminClient(); - const { realm } = useRealm(); - const { t } = useTranslation("realm-settings"); - const { addAlert, addError } = useAlerts(); - const [activeTab, setActiveTab] = useState("attributes"); - const [profiles, setProfiles] = useState(); - const [isSaving, setIsSaving] = useState(false); - const [refreshCount, setRefreshCount] = useState(0); - - useFetch( - () => - adminClient.clientPolicies.listProfiles({ - includeGlobalProfiles: true, - realm, - }), - (profiles) => setProfiles(profiles), - [refreshCount] - ); - - async function onSave(updatedProfiles: ClientProfilesRepresentation) { - setIsSaving(true); - - try { - await adminClient.clientPolicies.createProfiles({ - ...updatedProfiles, - realm, - }); - - setRefreshCount(refreshCount + 1); - addAlert(t("userProfileSuccess"), AlertVariant.success); - } catch (error) { - addError("realm-settings:userProfileError", error); - } - - setIsSaving(false); - } - - return ( - setActiveTab(key.toString())} - mountOnEnter - > - {t("attributes")}} - > - {t("attributesGroup")}} - > - {t("jsonEditor")}} - > - - - - ); -}; - -type JsonEditorTabProps = { - profiles?: ClientProfilesRepresentation; - onSave: (profiles: ClientProfilesRepresentation) => void; - isSaving: boolean; -}; - -const JsonEditorTab = ({ profiles, onSave, isSaving }: JsonEditorTabProps) => { - const { t } = useTranslation(); - const { addError } = useAlerts(); - const [editor, setEditor] = useState(); - - useEffect(() => resetCode(), [profiles, editor]); - - function resetCode() { - editor?.setValue(profiles ? prettyPrintJSON(profiles) : ""); - } - - function save() { - const value = editor?.getValue(); - - if (!value) { - return; - } - - try { - onSave(JSON.parse(value)); - } catch (error) { - addError("realm-settings:invalidJsonError", error); - return; - } - } - - return ( - - setEditor(editor)} - isLanguageLabelVisible - /> -
- - - - -
-
- ); -}; diff --git a/src/realm-settings/help.ts b/src/realm-settings/help.ts index a948027c0f..6d965aced4 100644 --- a/src/realm-settings/help.ts +++ b/src/realm-settings/help.ts @@ -12,6 +12,7 @@ export default { "Is HTTPS required? 'None' means HTTPS is not required for any client IP address. 'External requests' means localhost and private IP addresses can access without HTTPS. 'All requests' means HTTPS is required for all IP addresses.", userManagedAccess: "If enabled, users are allowed to manage their resources and permissions using the Account Management Console.", + userProfileEnabled: "If enabled, allows managing user profiles.", endpoints: "Shows the configuration of the protocol endpoints", loginTheme: "Select theme for login, OTP, grant, registration and forgot password pages.", diff --git a/src/realm-settings/messages.ts b/src/realm-settings/messages.ts index 367d8feb99..ead084d93f 100644 --- a/src/realm-settings/messages.ts +++ b/src/realm-settings/messages.ts @@ -162,6 +162,7 @@ export default { }, placeholderText: "Select one", userManagedAccess: "User-managed access", + userProfileEnabled: "User Profile Enabled", endpoints: "Endpoints", openIDEndpointConfiguration: "OpenID Endpoint Configuration", samlIdentityProviderMetadata: "SAML 2.0 Identity Provider Metadata", diff --git a/src/realm-settings/user-profile/JsonEditorTab.tsx b/src/realm-settings/user-profile/JsonEditorTab.tsx new file mode 100644 index 0000000000..b920cd3655 --- /dev/null +++ b/src/realm-settings/user-profile/JsonEditorTab.tsx @@ -0,0 +1,67 @@ +import type ClientProfilesRepresentation from "@keycloak/keycloak-admin-client/lib/defs/clientProfilesRepresentation"; +import type UserProfileConfig from "@keycloak/keycloak-admin-client/lib/defs/userProfileConfig"; +import { CodeEditor, Language } from "@patternfly/react-code-editor"; +import { ActionGroup, Button, Form, PageSection } from "@patternfly/react-core"; +import type { editor } from "monaco-editor"; +import React, { useEffect, useState } from "react"; +import { useTranslation } from "react-i18next"; +import { useAlerts } from "../../components/alert/Alerts"; +import { prettyPrintJSON } from "../../util"; + +type JsonEditorTabProps = { + config?: UserProfileConfig; + onSave: (profiles: ClientProfilesRepresentation) => void; + isSaving: boolean; +}; + +export const JsonEditorTab = ({ + config, + onSave, + isSaving, +}: JsonEditorTabProps) => { + const { t } = useTranslation(); + const { addError } = useAlerts(); + const [editor, setEditor] = useState(); + + useEffect(() => resetCode(), [config, editor]); + + function resetCode() { + editor?.setValue(config ? prettyPrintJSON(config) : ""); + } + + function save() { + const value = editor?.getValue(); + + if (!value) { + return; + } + + try { + onSave(JSON.parse(value)); + } catch (error) { + addError("realm-settings:invalidJsonError", error); + return; + } + } + + return ( + + setEditor(editor)} + isLanguageLabelVisible + /> +
+ + + + +
+
+ ); +}; diff --git a/src/realm-settings/user-profile/UserProfileTab.tsx b/src/realm-settings/user-profile/UserProfileTab.tsx new file mode 100644 index 0000000000..1af91ad1f7 --- /dev/null +++ b/src/realm-settings/user-profile/UserProfileTab.tsx @@ -0,0 +1,67 @@ +import type ClientProfilesRepresentation from "@keycloak/keycloak-admin-client/lib/defs/clientProfilesRepresentation"; +import type UserProfileConfig from "@keycloak/keycloak-admin-client/lib/defs/userProfileConfig"; +import { AlertVariant, Tab, Tabs, TabTitleText } from "@patternfly/react-core"; +import React, { useState } from "react"; +import { useTranslation } from "react-i18next"; +import { useAlerts } from "../../components/alert/Alerts"; +import { useAdminClient, useFetch } from "../../context/auth/AdminClient"; +import { useRealm } from "../../context/realm-context/RealmContext"; +import { JsonEditorTab } from "./JsonEditorTab"; + +export const UserProfileTab = () => { + const adminClient = useAdminClient(); + const { realm } = useRealm(); + const { t } = useTranslation("realm-settings"); + const { addAlert, addError } = useAlerts(); + const [activeTab, setActiveTab] = useState("attributes"); + const [config, setConfig] = useState(); + const [isSaving, setIsSaving] = useState(false); + const [refreshCount, setRefreshCount] = useState(0); + + useFetch( + () => adminClient.users.getProfile({ realm }), + (config) => setConfig(config), + [refreshCount] + ); + + async function onSave(updatedProfiles: ClientProfilesRepresentation) { + setIsSaving(true); + + try { + await adminClient.clientPolicies.createProfiles({ + ...updatedProfiles, + realm, + }); + + setRefreshCount(refreshCount + 1); + addAlert(t("userProfileSuccess"), AlertVariant.success); + } catch (error) { + addError("realm-settings:userProfileError", error); + } + + setIsSaving(false); + } + + return ( + setActiveTab(key.toString())} + mountOnEnter + > + {t("attributes")}} + > + {t("attributesGroup")}} + > + {t("jsonEditor")}} + > + + + + ); +}; diff --git a/src/utils/useIsFeatureEnabled.ts b/src/utils/useIsFeatureEnabled.ts new file mode 100644 index 0000000000..1b77695534 --- /dev/null +++ b/src/utils/useIsFeatureEnabled.ts @@ -0,0 +1,21 @@ +import { useServerInfo } from "../context/server-info/ServerInfoProvider"; + +export enum Feature { + DeclarativeUserProfile = "DECLARATIVE_USER_PROFILE", +} + +export default function useIsFeatureEnabled() { + const { profileInfo } = useServerInfo(); + + const experimentalFeatures = profileInfo?.experimentalFeatures ?? []; + const previewFeatures = profileInfo?.previewFeatures ?? []; + const disabledFilters = profileInfo?.disabledFeatures ?? []; + const allFeatures = [...experimentalFeatures, ...previewFeatures]; + const enabledFeatures = allFeatures.filter( + (feature) => !disabledFilters.includes(feature) + ); + + return function isFeatureEnabled(feature: Feature) { + return enabledFeatures.includes(feature); + }; +} diff --git a/start.mjs b/start.mjs index 6f28b187ba..77705a5758 100755 --- a/start.mjs +++ b/start.mjs @@ -66,7 +66,8 @@ const run = () => { addProc.on("exit", () => { const proc = spawn(path.join(serverPath, "bin", `standalone${extension}`), [ "-Djboss.socket.binding.port-offset=100", - "-Dprofile.feature.newadmin=enabled", + "-Dkeycloak.profile.feature.admin2=enabled", + "-Dkeycloak.profile.feature.declarative_user_profile=enabled", ...args, ]); proc.stdout.on("data", (data) => {