Add toggle for user profile to realm settings (#1717)

This commit is contained in:
Jon Koops 2022-01-03 16:00:03 +01:00 committed by GitHub
parent 88601cf118
commit f0fc136672
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
13 changed files with 236 additions and 158 deletions

View file

@ -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 }}

View file

@ -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);

View file

@ -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");

View file

@ -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(

View file

@ -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 = ({
)}
/>
</FormGroup>
{isFeatureEnabled(Feature.DeclarativeUserProfile) && (
<FormGroup
hasNoPaddingTop
label={t("userProfileEnabled")}
labelIcon={
<HelpItem
helpText="realm-settings-help:userProfileEnabled"
fieldLabelId="realm-settings:userProfileEnabled"
/>
}
fieldId="kc-user-profile-enabled"
>
<Controller
name="attributes.userProfileEnabled"
control={control}
defaultValue={false}
render={({ onChange, value }) => (
<Switch
id="kc-user-profile-enabled"
data-testid="user-profile-enabled-switch"
label={t("common:on")}
labelOff={t("common:off")}
isChecked={value === "true"}
onChange={(value) => onChange(value.toString())}
/>
)}
/>
</FormGroup>
)}
<FormGroup
label={t("endpoints")}
labelIcon={

View file

@ -1,6 +1,6 @@
import React, { useEffect, useState } from "react";
import { useHistory } from "react-router-dom";
import { Controller, FormProvider, useForm } from "react-hook-form";
import { Controller, FormProvider, useForm, useWatch } from "react-hook-form";
import { useTranslation } from "react-i18next";
import {
AlertVariant,
@ -51,8 +51,9 @@ import { HelpItem } from "../components/help-enabler/HelpItem";
import { UserRegistration } from "./UserRegistration";
import { toDashboard } from "../dashboard/routes/Dashboard";
import environment from "../environment";
import { UserProfileTab } from "./UserProfileTab";
import helpUrls from "../help-urls";
import { UserProfileTab } from "./user-profile/UserProfileTab";
import useIsFeatureEnabled, { Feature } from "../utils/useIsFeatureEnabled";
type RealmSettingsHeaderProps = {
onChange: (value: boolean) => 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 (
<>
<Controller
@ -239,7 +247,7 @@ export const RealmSettingsTabs = ({
/>
<PageSection variant="light" className="pf-u-p-0">
<FormProvider {...form}>
<KeycloakTabs isBox>
<KeycloakTabs isBox mountOnEnter>
<Tab
eventKey="general"
title={<TabTitleText>{t("general")}</TabTitleText>}
@ -423,15 +431,20 @@ export const RealmSettingsTabs = ({
</Tab>
</Tabs>
</Tab>
{isFeatureEnabled(Feature.DeclarativeUserProfile) &&
userProfileEnabled === "true" && (
<Tab
eventKey="userProfile"
data-testid="rs-user-profile-tab"
title={
<TabTitleText>{t("realm-settings:userProfile")}</TabTitleText>
<TabTitleText>
{t("realm-settings:userProfile")}
</TabTitleText>
}
>
<UserProfileTab />
</Tab>
)}
<Tab
eventKey="userRegistration"
title={<TabTitleText>{t("userRegistration")}</TabTitleText>}

View file

@ -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<ClientProfilesRepresentation>();
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 (
<Tabs
activeKey={activeTab}
onSelect={(_, key) => setActiveTab(key.toString())}
mountOnEnter
>
<Tab
eventKey="attributes"
title={<TabTitleText>{t("attributes")}</TabTitleText>}
></Tab>
<Tab
eventKey="attributesGroup"
title={<TabTitleText>{t("attributesGroup")}</TabTitleText>}
></Tab>
<Tab
eventKey="jsonEditor"
title={<TabTitleText>{t("jsonEditor")}</TabTitleText>}
>
<JsonEditorTab
profiles={profiles}
onSave={onSave}
isSaving={isSaving}
/>
</Tab>
</Tabs>
);
};
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<editor.IStandaloneCodeEditor>();
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 (
<PageSection variant="light">
<CodeEditor
language={Language.json}
height="30rem"
onEditorDidMount={(editor) => setEditor(editor)}
isLanguageLabelVisible
/>
<Form>
<ActionGroup>
<Button variant="primary" onClick={save} isDisabled={isSaving}>
{t("common:save")}
</Button>
<Button variant="link" onClick={resetCode} isDisabled={isSaving}>
{t("common:revert")}
</Button>
</ActionGroup>
</Form>
</PageSection>
);
};

View file

@ -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.",

View file

@ -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",

View file

@ -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<editor.IStandaloneCodeEditor>();
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 (
<PageSection variant="light">
<CodeEditor
language={Language.json}
height="30rem"
onEditorDidMount={(editor) => setEditor(editor)}
isLanguageLabelVisible
/>
<Form>
<ActionGroup>
<Button variant="primary" onClick={save} isDisabled={isSaving}>
{t("common:save")}
</Button>
<Button variant="link" onClick={resetCode} isDisabled={isSaving}>
{t("common:revert")}
</Button>
</ActionGroup>
</Form>
</PageSection>
);
};

View file

@ -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<UserProfileConfig>();
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 (
<Tabs
activeKey={activeTab}
onSelect={(_, key) => setActiveTab(key.toString())}
mountOnEnter
>
<Tab
eventKey="attributes"
title={<TabTitleText>{t("attributes")}</TabTitleText>}
></Tab>
<Tab
eventKey="attributesGroup"
title={<TabTitleText>{t("attributesGroup")}</TabTitleText>}
></Tab>
<Tab
eventKey="jsonEditor"
title={<TabTitleText>{t("jsonEditor")}</TabTitleText>}
>
<JsonEditorTab config={config} onSave={onSave} isSaving={isSaving} />
</Tab>
</Tabs>
);
};

View file

@ -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);
};
}

View file

@ -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) => {