From 290bee0787cd4877519037930cbfccee0cf6ef3f Mon Sep 17 00:00:00 2001 From: Pedro Igor Date: Fri, 6 Oct 2023 10:15:39 -0300 Subject: [PATCH] Resolve several usability issues around User Profile (#23537) Closes #23507, #23584, #23740, #23774 Co-authored-by: Jon Koops --- .../UserProfileAttributeGroupMetadata.java | 54 ++++++ .../idm/UserProfileAttributeMetadata.java | 8 +- .../idm/UserProfileMetadata.java | 30 ++- .../client/resource/UserProfileResource.java | 8 + js/apps/account-ui/test/admin-client.ts | 2 +- .../test/personal-info/personal-info.spec.ts | 6 +- .../admin-ui/cypress/e2e/i18n_test.spec.ts | 4 +- .../cypress/support/util/AdminClient.ts | 4 +- .../src/components/users/UserDataTable.tsx | 14 +- .../UserDataTableAttributeSearchForm.tsx | 2 +- .../users/UserDataTableToolbarItems.tsx | 7 +- .../realm-settings/NewAttributeSettings.tsx | 6 +- .../user-profile/AttributesTab.tsx | 20 +- .../user-profile/UserProfileContext.tsx | 2 +- .../attribute/AttributeGeneralSettings.tsx | 2 +- js/apps/admin-ui/src/user/CreateUser.tsx | 71 +++++--- js/apps/admin-ui/src/user/EditUser.tsx | 88 ++++----- js/apps/admin-ui/src/user/UserAttributes.tsx | 11 +- js/apps/admin-ui/src/user/UserForm.tsx | 122 +++++++------ .../admin-ui/src/user/UserProfileFields.tsx | 172 +++++++++++++----- .../src/user/components/OptionsComponent.tsx | 33 ++-- .../src/user/components/SelectComponent.tsx | 46 +++-- .../src/user/components/TextAreaComponent.tsx | 35 ++-- .../src/user/components/TextComponent.tsx | 34 ++-- .../src/user/components/UserProfileGroup.tsx | 35 ++-- js/apps/admin-ui/src/user/form-state.ts | 20 +- .../RequiredActionMultiSelect.tsx | 42 +++-- .../ResetCredentialDialog.tsx | 11 +- js/apps/admin-ui/src/user/utils.ts | 12 +- .../admin-ui/src/user/utils/user-profile.ts | 33 ++++ .../src/defs/userProfileConfig.ts | 6 +- .../src/defs/userProfileMetadata.ts | 24 +++ .../src/defs/userRepresentation.ts | 6 +- .../src/resources/users.ts | 26 ++- .../userprofile/AttributeContext.java | 8 +- .../userprofile/DefaultAttributes.java | 4 +- .../userprofile/UserProfileProvider.java | 9 + .../FreeMarkerLoginFormsProvider.java | 2 +- .../resources/account/AccountRestService.java | 35 +--- .../resources/admin/UserProfileResource.java | 86 +++++++++ .../resources/admin/UserResource.java | 34 +--- .../AbstractUserProfileProvider.java | 23 +-- .../DeclarativeUserProfileProvider.java | 51 ++++-- .../ImmutableAttributeValidator.java | 3 +- .../validator/UsernameMutationValidator.java | 5 +- .../account/AccountRestServiceTest.java | 91 ++++++--- .../admin/UserTestWithUserProfile.java | 22 +-- .../userprofile/UserProfileAdminTest.java | 116 +++++++++++- .../user/profile/UserProfileTest.java | 70 +++++++ 49 files changed, 1067 insertions(+), 488 deletions(-) create mode 100644 core/src/main/java/org/keycloak/representations/idm/UserProfileAttributeGroupMetadata.java create mode 100644 js/apps/admin-ui/src/user/utils/user-profile.ts create mode 100644 js/libs/keycloak-admin-client/src/defs/userProfileMetadata.ts diff --git a/core/src/main/java/org/keycloak/representations/idm/UserProfileAttributeGroupMetadata.java b/core/src/main/java/org/keycloak/representations/idm/UserProfileAttributeGroupMetadata.java new file mode 100644 index 0000000000..2462e38b67 --- /dev/null +++ b/core/src/main/java/org/keycloak/representations/idm/UserProfileAttributeGroupMetadata.java @@ -0,0 +1,54 @@ +/* + * Copyright 2021 Red Hat, Inc. and/or its affiliates + * and other contributors as indicated by the @author tags. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.keycloak.representations.idm; + +import java.util.Map; + +public class UserProfileAttributeGroupMetadata { + + private String name; + private String displayHeader; + private String displayDescription; + private Map annotations; + + public UserProfileAttributeGroupMetadata() { + } + + public UserProfileAttributeGroupMetadata(String name, String displayHeader, String displayDescription, Map annotations) { + this.name = name; + this.displayHeader = displayHeader; + this.displayDescription = displayDescription; + this.annotations = annotations; + } + + public String getName() { + return name; + } + + public String getDisplayHeader() { + return displayHeader; + } + + + public String getDisplayDescription() { + return displayDescription; + } + + public Map getAnnotations() { + return annotations; + } +} \ No newline at end of file diff --git a/core/src/main/java/org/keycloak/representations/idm/UserProfileAttributeMetadata.java b/core/src/main/java/org/keycloak/representations/idm/UserProfileAttributeMetadata.java index ef7723523c..2c46aca7b0 100644 --- a/core/src/main/java/org/keycloak/representations/idm/UserProfileAttributeMetadata.java +++ b/core/src/main/java/org/keycloak/representations/idm/UserProfileAttributeMetadata.java @@ -29,12 +29,13 @@ public class UserProfileAttributeMetadata { private boolean readOnly; private Map annotations; private Map> validators; + private String group; public UserProfileAttributeMetadata() { } - public UserProfileAttributeMetadata(String name, String displayName, boolean required, boolean readOnly, Map annotations, + public UserProfileAttributeMetadata(String name, String displayName, boolean required, boolean readOnly, String group, Map annotations, Map> validators) { this.name = name; this.displayName = displayName; @@ -42,6 +43,7 @@ public class UserProfileAttributeMetadata { this.readOnly = readOnly; this.annotations = annotations; this.validators = validators; + this.group = group; } public String getName() { @@ -63,6 +65,10 @@ public class UserProfileAttributeMetadata { return readOnly; } + public String getGroup() { + return group; + } + /** * Get info about attribute annotations loaded from UserProfile configuration. */ diff --git a/core/src/main/java/org/keycloak/representations/idm/UserProfileMetadata.java b/core/src/main/java/org/keycloak/representations/idm/UserProfileMetadata.java index fb8bd1ec40..13e2fd77e2 100644 --- a/core/src/main/java/org/keycloak/representations/idm/UserProfileMetadata.java +++ b/core/src/main/java/org/keycloak/representations/idm/UserProfileMetadata.java @@ -16,7 +16,10 @@ */ package org.keycloak.representations.idm; +import static java.util.Collections.emptyList; + import java.util.List; +import java.util.Optional; /** * @author Vlastimil Elias @@ -24,22 +27,47 @@ import java.util.List; public class UserProfileMetadata { private List attributes; + private List groups; public UserProfileMetadata() { } - public UserProfileMetadata(List attributes) { + public UserProfileMetadata(List attributes, List groups) { super(); this.attributes = attributes; + this.groups = groups; } public List getAttributes() { return attributes; } + public List getGroups() { + return groups; + } + public void setAttributes(List attributes) { this.attributes = attributes; } + public UserProfileAttributeMetadata getAttributeMetadata(String name) { + for (UserProfileAttributeMetadata m : Optional.ofNullable(getAttributes()).orElse(emptyList())) { + if (m.getName().equals(name)) { + return m; + } + } + + return null; + } + + public UserProfileAttributeGroupMetadata getAttributeGroupMetadata(String name) { + for (UserProfileAttributeGroupMetadata m : Optional.ofNullable(getGroups()).orElse(emptyList())) { + if (m.getName().equals(name)) { + return m; + } + } + + return null; + } } diff --git a/integration/admin-client-jee/src/main/java/org/keycloak/admin/client/resource/UserProfileResource.java b/integration/admin-client-jee/src/main/java/org/keycloak/admin/client/resource/UserProfileResource.java index a9475a058f..b89b2b4d0a 100644 --- a/integration/admin-client-jee/src/main/java/org/keycloak/admin/client/resource/UserProfileResource.java +++ b/integration/admin-client-jee/src/main/java/org/keycloak/admin/client/resource/UserProfileResource.java @@ -19,10 +19,13 @@ package org.keycloak.admin.client.resource; import javax.ws.rs.Consumes; import javax.ws.rs.GET; import javax.ws.rs.PUT; +import javax.ws.rs.Path; import javax.ws.rs.Produces; import javax.ws.rs.core.MediaType; import javax.ws.rs.core.Response; +import org.keycloak.representations.idm.UserProfileMetadata; + /** * @author Vlastimil Elias */ @@ -34,6 +37,11 @@ public interface UserProfileResource { @Consumes(MediaType.APPLICATION_JSON) String getConfiguration(); + @GET + @Path("/metadata") + @Consumes(jakarta.ws.rs.core.MediaType.APPLICATION_JSON) + UserProfileMetadata getMetadata(); + @PUT @Produces(MediaType.APPLICATION_JSON) Response update(String text); diff --git a/js/apps/account-ui/test/admin-client.ts b/js/apps/account-ui/test/admin-client.ts index 7b6ffb2c8d..c6c4fb8d77 100644 --- a/js/apps/account-ui/test/admin-client.ts +++ b/js/apps/account-ui/test/admin-client.ts @@ -1,6 +1,6 @@ import KeycloakAdminClient from "@keycloak/keycloak-admin-client"; import RealmRepresentation from "@keycloak/keycloak-admin-client/lib/defs/realmRepresentation"; -import type UserProfileConfig from "@keycloak/keycloak-admin-client/lib/defs/userProfileConfig"; +import type { UserProfileConfig } from "@keycloak/keycloak-admin-client/lib/defs/userProfileConfig"; import UserRepresentation from "@keycloak/keycloak-admin-client/lib/defs/userRepresentation"; const adminClient = new KeycloakAdminClient({ diff --git a/js/apps/account-ui/test/personal-info/personal-info.spec.ts b/js/apps/account-ui/test/personal-info/personal-info.spec.ts index 66daa00a18..7ec277a09a 100644 --- a/js/apps/account-ui/test/personal-info/personal-info.spec.ts +++ b/js/apps/account-ui/test/personal-info/personal-info.spec.ts @@ -1,10 +1,10 @@ -import type UserProfileConfig from "@keycloak/keycloak-admin-client/lib/defs/userProfileConfig"; +import type { UserProfileConfig } from "@keycloak/keycloak-admin-client/lib/defs/userProfileConfig"; import { expect, test } from "@playwright/test"; import { - enableLocalization, - importUserProfile, createUser, deleteUser, + enableLocalization, + importUserProfile, } from "../admin-client"; import { login } from "../login"; import userProfileConfig from "./user-profile.json" assert { type: "json" }; diff --git a/js/apps/admin-ui/cypress/e2e/i18n_test.spec.ts b/js/apps/admin-ui/cypress/e2e/i18n_test.spec.ts index 0443fc23e7..c464d90e01 100644 --- a/js/apps/admin-ui/cypress/e2e/i18n_test.spec.ts +++ b/js/apps/admin-ui/cypress/e2e/i18n_test.spec.ts @@ -1,9 +1,9 @@ +import RealmRepresentation from "@keycloak/keycloak-admin-client/lib/defs/realmRepresentation"; import LoginPage from "../support/pages/LoginPage"; import SidebarPage from "../support/pages/admin-ui/SidebarPage"; +import ProviderPage from "../support/pages/admin-ui/manage/providers/ProviderPage"; import adminClient from "../support/util/AdminClient"; import { keycloakBefore } from "../support/util/keycloak_hooks"; -import ProviderPage from "../support/pages/admin-ui/manage/providers/ProviderPage"; -import RealmRepresentation from "libs/keycloak-admin-client/lib/defs/realmRepresentation"; const loginPage = new LoginPage(); const sidebarPage = new SidebarPage(); diff --git a/js/apps/admin-ui/cypress/support/util/AdminClient.ts b/js/apps/admin-ui/cypress/support/util/AdminClient.ts index 506276e366..4b7130359e 100644 --- a/js/apps/admin-ui/cypress/support/util/AdminClient.ts +++ b/js/apps/admin-ui/cypress/support/util/AdminClient.ts @@ -1,11 +1,11 @@ import KeycloakAdminClient from "@keycloak/keycloak-admin-client"; -import type UserRepresentation from "@keycloak/keycloak-admin-client/lib/defs/userRepresentation"; import type ClientRepresentation from "@keycloak/keycloak-admin-client/lib/defs/clientRepresentation"; import type ClientScopeRepresentation from "@keycloak/keycloak-admin-client/lib/defs/clientScopeRepresentation"; import type RealmRepresentation from "@keycloak/keycloak-admin-client/lib/defs/realmRepresentation"; -import type UserProfileConfig from "@keycloak/keycloak-admin-client/lib/defs/userProfileConfig"; import type RoleRepresentation from "@keycloak/keycloak-admin-client/lib/defs/roleRepresentation"; import type { RoleMappingPayload } from "@keycloak/keycloak-admin-client/lib/defs/roleRepresentation"; +import type { UserProfileConfig } from "@keycloak/keycloak-admin-client/lib/defs/userProfileConfig"; +import type UserRepresentation from "@keycloak/keycloak-admin-client/lib/defs/userRepresentation"; import { merge } from "lodash-es"; class AdminClient { diff --git a/js/apps/admin-ui/src/components/users/UserDataTable.tsx b/js/apps/admin-ui/src/components/users/UserDataTable.tsx index 875276b3fa..79fbfc1c40 100644 --- a/js/apps/admin-ui/src/components/users/UserDataTable.tsx +++ b/js/apps/admin-ui/src/components/users/UserDataTable.tsx @@ -1,6 +1,6 @@ import type ComponentRepresentation from "@keycloak/keycloak-admin-client/lib/defs/componentRepresentation"; import type RealmRepresentation from "@keycloak/keycloak-admin-client/lib/defs/realmRepresentation"; -import type UserProfileConfig from "@keycloak/keycloak-admin-client/lib/defs/userProfileConfig"; +import type { UserProfileConfig } from "@keycloak/keycloak-admin-client/lib/defs/userProfileConfig"; import type UserRepresentation from "@keycloak/keycloak-admin-client/lib/defs/userRepresentation"; import { AlertVariant, @@ -29,19 +29,19 @@ import { useTranslation } from "react-i18next"; import { Link, useNavigate } from "react-router-dom"; import { adminClient } from "../../admin-client"; +import { useRealm } from "../../context/realm-context/RealmContext"; +import { SearchType } from "../../user/details/SearchFilter"; +import { toAddUser } from "../../user/routes/AddUser"; +import { toUser } from "../../user/routes/User"; +import { emptyFormatter } from "../../util"; +import { useFetch } from "../../utils/useFetch"; import { useAlerts } from "../alert/Alerts"; import { useConfirmDialog } from "../confirm-dialog/ConfirmDialog"; import { KeycloakSpinner } from "../keycloak-spinner/KeycloakSpinner"; import { ListEmptyState } from "../list-empty-state/ListEmptyState"; import { BruteUser, findUsers } from "../role-mapping/resource"; import { KeycloakDataTable } from "../table-toolbar/KeycloakDataTable"; -import { useRealm } from "../../context/realm-context/RealmContext"; -import { emptyFormatter } from "../../util"; -import { useFetch } from "../../utils/useFetch"; -import { toAddUser } from "../../user/routes/AddUser"; -import { toUser } from "../../user/routes/User"; import { UserDataTableToolbarItems } from "./UserDataTableToolbarItems"; -import { SearchType } from "../../user/details/SearchFilter"; export type UserAttribute = { name: string; diff --git a/js/apps/admin-ui/src/components/users/UserDataTableAttributeSearchForm.tsx b/js/apps/admin-ui/src/components/users/UserDataTableAttributeSearchForm.tsx index 6bc0ec7001..2046ebcf0c 100644 --- a/js/apps/admin-ui/src/components/users/UserDataTableAttributeSearchForm.tsx +++ b/js/apps/admin-ui/src/components/users/UserDataTableAttributeSearchForm.tsx @@ -1,4 +1,4 @@ -import type UserProfileConfig from "@keycloak/keycloak-admin-client/lib/defs/userProfileConfig"; +import type { UserProfileConfig } from "@keycloak/keycloak-admin-client/lib/defs/userProfileConfig"; import { ActionGroup, Alert, diff --git a/js/apps/admin-ui/src/components/users/UserDataTableToolbarItems.tsx b/js/apps/admin-ui/src/components/users/UserDataTableToolbarItems.tsx index 2162990674..217d5e91a8 100644 --- a/js/apps/admin-ui/src/components/users/UserDataTableToolbarItems.tsx +++ b/js/apps/admin-ui/src/components/users/UserDataTableToolbarItems.tsx @@ -1,5 +1,5 @@ import type RealmRepresentation from "@keycloak/keycloak-admin-client/lib/defs/realmRepresentation"; -import type UserProfileConfig from "@keycloak/keycloak-admin-client/lib/defs/userProfileConfig"; +import type { UserProfileConfig } from "@keycloak/keycloak-admin-client/lib/defs/userProfileConfig"; import { Button, ButtonVariant, @@ -11,13 +11,14 @@ import { SearchInput, ToolbarItem, } from "@patternfly/react-core"; +import { ArrowRightIcon } from "@patternfly/react-icons"; import { ReactNode, useState } from "react"; import { useTranslation } from "react-i18next"; + import { useAccess } from "../../context/access/Access"; -import { UserDataTableAttributeSearchForm } from "./UserDataTableAttributeSearchForm"; -import { ArrowRightIcon } from "@patternfly/react-icons"; import { SearchDropdown, SearchType } from "../../user/details/SearchFilter"; import { UserAttribute } from "./UserDataTable"; +import { UserDataTableAttributeSearchForm } from "./UserDataTableAttributeSearchForm"; type UserDataTableToolbarItemsProps = { realm: RealmRepresentation; diff --git a/js/apps/admin-ui/src/realm-settings/NewAttributeSettings.tsx b/js/apps/admin-ui/src/realm-settings/NewAttributeSettings.tsx index 2d127dd430..7d1bfef6fa 100644 --- a/js/apps/admin-ui/src/realm-settings/NewAttributeSettings.tsx +++ b/js/apps/admin-ui/src/realm-settings/NewAttributeSettings.tsx @@ -1,5 +1,7 @@ -import type UserProfileConfig from "@keycloak/keycloak-admin-client/lib/defs/userProfileConfig"; -import type { UserProfileAttribute } from "@keycloak/keycloak-admin-client/lib/defs/userProfileConfig"; +import type { + UserProfileAttribute, + UserProfileConfig, +} from "@keycloak/keycloak-admin-client/lib/defs/userProfileConfig"; import { AlertVariant, Button, diff --git a/js/apps/admin-ui/src/realm-settings/user-profile/AttributesTab.tsx b/js/apps/admin-ui/src/realm-settings/user-profile/AttributesTab.tsx index 21546d687e..e56a56e3f1 100644 --- a/js/apps/admin-ui/src/realm-settings/user-profile/AttributesTab.tsx +++ b/js/apps/admin-ui/src/realm-settings/user-profile/AttributesTab.tsx @@ -1,5 +1,4 @@ -import { useState } from "react"; -import { useTranslation } from "react-i18next"; +import type { UserProfileAttribute } from "@keycloak/keycloak-admin-client/lib/defs/userProfileConfig"; import { Button, ButtonVariant, @@ -12,17 +11,18 @@ import { ToolbarItem, } from "@patternfly/react-core"; import { FilterIcon } from "@patternfly/react-icons"; - -import { KeycloakSpinner } from "../../components/keycloak-spinner/KeycloakSpinner"; -import { DraggableTable } from "../../authentication/components/DraggableTable"; +import { useState } from "react"; +import { useTranslation } from "react-i18next"; import { Link, useNavigate } from "react-router-dom"; -import { toAddAttribute } from "../routes/AddAttribute"; -import { useRealm } from "../../context/realm-context/RealmContext"; -import { useUserProfile } from "./UserProfileContext"; + +import { DraggableTable } from "../../authentication/components/DraggableTable"; import { useConfirmDialog } from "../../components/confirm-dialog/ConfirmDialog"; -import { toAttribute } from "../routes/Attribute"; -import type { UserProfileAttribute } from "@keycloak/keycloak-admin-client/lib/defs/userProfileConfig"; +import { KeycloakSpinner } from "../../components/keycloak-spinner/KeycloakSpinner"; +import { useRealm } from "../../context/realm-context/RealmContext"; import useToggle from "../../utils/useToggle"; +import { toAddAttribute } from "../routes/AddAttribute"; +import { toAttribute } from "../routes/Attribute"; +import { useUserProfile } from "./UserProfileContext"; const RESTRICTED_ATTRIBUTES = ["username", "email"]; diff --git a/js/apps/admin-ui/src/realm-settings/user-profile/UserProfileContext.tsx b/js/apps/admin-ui/src/realm-settings/user-profile/UserProfileContext.tsx index 08e0c00769..2f1eb80553 100644 --- a/js/apps/admin-ui/src/realm-settings/user-profile/UserProfileContext.tsx +++ b/js/apps/admin-ui/src/realm-settings/user-profile/UserProfileContext.tsx @@ -1,4 +1,4 @@ -import type UserProfileConfig from "@keycloak/keycloak-admin-client/lib/defs/userProfileConfig"; +import type { UserProfileConfig } from "@keycloak/keycloak-admin-client/lib/defs/userProfileConfig"; import { AlertVariant } from "@patternfly/react-core"; import { PropsWithChildren, useState } from "react"; import { useTranslation } from "react-i18next"; diff --git a/js/apps/admin-ui/src/realm-settings/user-profile/attribute/AttributeGeneralSettings.tsx b/js/apps/admin-ui/src/realm-settings/user-profile/attribute/AttributeGeneralSettings.tsx index 1c433822b5..3df5c78765 100644 --- a/js/apps/admin-ui/src/realm-settings/user-profile/attribute/AttributeGeneralSettings.tsx +++ b/js/apps/admin-ui/src/realm-settings/user-profile/attribute/AttributeGeneralSettings.tsx @@ -1,5 +1,5 @@ import type ClientScopeRepresentation from "@keycloak/keycloak-admin-client/lib/defs/clientScopeRepresentation"; -import type UserProfileConfig from "@keycloak/keycloak-admin-client/lib/defs/userProfileConfig"; +import type { UserProfileConfig } from "@keycloak/keycloak-admin-client/lib/defs/userProfileConfig"; import { Divider, FormGroup, diff --git a/js/apps/admin-ui/src/user/CreateUser.tsx b/js/apps/admin-ui/src/user/CreateUser.tsx index 50608be459..1d835ada81 100644 --- a/js/apps/admin-ui/src/user/CreateUser.tsx +++ b/js/apps/admin-ui/src/user/CreateUser.tsx @@ -1,15 +1,19 @@ import type GroupRepresentation from "@keycloak/keycloak-admin-client/lib/defs/groupRepresentation"; +import RealmRepresentation from "@keycloak/keycloak-admin-client/lib/defs/realmRepresentation"; +import type { UserProfileMetadata } from "@keycloak/keycloak-admin-client/lib/defs/userProfileMetadata"; import { AlertVariant, PageSection } from "@patternfly/react-core"; import { useState } from "react"; -import { FormProvider, useForm } from "react-hook-form"; +import { useForm } from "react-hook-form"; import { useTranslation } from "react-i18next"; import { useNavigate } from "react-router-dom"; import { adminClient } from "../admin-client"; import { useAlerts } from "../components/alert/Alerts"; +import { KeycloakSpinner } from "../components/keycloak-spinner/KeycloakSpinner"; import { ViewHeader } from "../components/view-header/ViewHeader"; import { useRealm } from "../context/realm-context/RealmContext"; -import { UserProfileProvider } from "../realm-settings/user-profile/UserProfileContext"; +import { useFetch } from "../utils/useFetch"; +import useIsFeatureEnabled, { Feature } from "../utils/useIsFeatureEnabled"; import { UserForm } from "./UserForm"; import { isUserProfileError, @@ -19,23 +23,40 @@ import { UserFormFields, toUserRepresentation } from "./form-state"; import { toUser } from "./routes/User"; import "./user-section.css"; -import RealmRepresentation from "@keycloak/keycloak-admin-client/lib/defs/realmRepresentation"; -import { useFetch } from "../utils/useFetch"; export default function CreateUser() { const { t } = useTranslation(); const { addAlert, addError } = useAlerts(); const navigate = useNavigate(); - const { realm } = useRealm(); - const userForm = useForm({ mode: "onChange" }); + const { realm: realmName } = useRealm(); + const isFeatureEnabled = useIsFeatureEnabled(); + const form = useForm({ mode: "onChange" }); const [addedGroups, setAddedGroups] = useState([]); - - const [realmRepresentation, setRealmRepresentation] = - useState(); + const [realm, setRealm] = useState(); + const [userProfileMetadata, setUserProfileMetadata] = + useState(); useFetch( - () => adminClient.realms.findOne({ realm }), - (result) => setRealmRepresentation(result), + () => + Promise.all([ + adminClient.realms.findOne({ realm: realmName }), + adminClient.users.getProfileMetadata({ realm: realmName }), + ]), + ([realm, userProfileMetadata]) => { + if (!realm) { + throw new Error(t("notFound")); + } + + setRealm(realm); + + const isUserProfileEnabled = + isFeatureEnabled(Feature.DeclarativeUserProfile) && + realm.attributes?.userProfileEnabled === "true"; + + setUserProfileMetadata( + isUserProfileEnabled ? userProfileMetadata : undefined, + ); + }, [], ); @@ -48,7 +69,9 @@ export default function CreateUser() { }); addAlert(t("userCreated"), AlertVariant.success); - navigate(toUser({ id: createdUser.id, realm, tab: "settings" })); + navigate( + toUser({ id: createdUser.id, realm: realmName, tab: "settings" }), + ); } catch (error) { if (isUserProfileError(error)) { addError(userProfileErrorToString(error), error); @@ -58,24 +81,24 @@ export default function CreateUser() { } }; + if (!realm) { + return ; + } + return ( <> - - - - - - - - + + ); diff --git a/js/apps/admin-ui/src/user/EditUser.tsx b/js/apps/admin-ui/src/user/EditUser.tsx index b7f2181abd..c9366fb216 100644 --- a/js/apps/admin-ui/src/user/EditUser.tsx +++ b/js/apps/admin-ui/src/user/EditUser.tsx @@ -1,3 +1,4 @@ +import type { UserProfileMetadata } from "@keycloak/keycloak-admin-client/lib/defs//userProfileMetadata"; import RealmRepresentation from "@keycloak/keycloak-admin-client/lib/defs/realmRepresentation"; import type UserRepresentation from "@keycloak/keycloak-admin-client/lib/defs/userRepresentation"; import { @@ -12,6 +13,7 @@ import { useState } from "react"; import { FormProvider, useForm } from "react-hook-form"; import { useTranslation } from "react-i18next"; import { useNavigate } from "react-router-dom"; + import { adminClient } from "../admin-client"; import { useAlerts } from "../components/alert/Alerts"; import { useConfirmDialog } from "../components/confirm-dialog/ConfirmDialog"; @@ -50,27 +52,25 @@ import { toUsers } from "./routes/Users"; import "./user-section.css"; export default function EditUser() { - const { realm } = useRealm(); - const { id } = useParams(); const { t } = useTranslation(); - const [user, setUser] = useState(); - const [bruteForced, setBruteForced] = useState(); - const [refreshCount, setRefreshCount] = useState(0); - const refresh = () => setRefreshCount((count) => count + 1); - const [isUserProfileEnabled, setIsUserProfileEnabled] = useState(false); - const [realmRepresentation, setRealmRepresentation] = - useState(); - const isFeatureEnabled = useIsFeatureEnabled(); const { addAlert, addError } = useAlerts(); const navigate = useNavigate(); const { hasAccess } = useAccess(); - const userForm = useForm({ - mode: "onChange", - }); + const { id } = useParams(); + const { realm: realmName } = useRealm(); + const isFeatureEnabled = useIsFeatureEnabled(); + const form = useForm({ mode: "onChange" }); + const [realm, setRealm] = useState(); + const [user, setUser] = useState(); + const [bruteForced, setBruteForced] = useState(); + const [userProfileMetadata, setUserProfileMetadata] = + useState(); + const [refreshCount, setRefreshCount] = useState(0); + const refresh = () => setRefreshCount((count) => count + 1); const toTab = (tab: UserTab) => toUser({ - realm, + realm: realmName, id: user?.id || "", tab, }); @@ -87,35 +87,34 @@ export default function EditUser() { const sessionsTab = useTab("sessions"); useFetch( - async () => { - const [user, currentRealm, attackDetection] = await Promise.all([ + async () => + Promise.all([ + adminClient.realms.findOne({ realm: realmName }), adminClient.users.findOne({ id: id!, userProfileMetadata: true }), - adminClient.realms.findOne({ realm }), adminClient.attackDetection.findOne({ id: id! }), - ]); - - if (!user || !currentRealm || !attackDetection) { + ]), + ([realm, user, attackDetection]) => { + if (!user || !realm || !attackDetection) { throw new Error(t("notFound")); } - const isBruteForceProtected = currentRealm.bruteForceProtected; + setRealm(realm); + setUser(user); + + const isBruteForceProtected = realm.bruteForceProtected; const isLocked = isBruteForceProtected && attackDetection.disabled; - return { - user, - bruteForced: { isBruteForceProtected, isLocked }, - currentRealm, - }; - }, - ({ user, bruteForced, currentRealm }) => { - setUser(user); + setBruteForced({ isBruteForceProtected, isLocked }); + const isUserProfileEnabled = isFeatureEnabled(Feature.DeclarativeUserProfile) && - currentRealm.attributes?.userProfileEnabled === "true"; - userForm.reset(isUserProfileEnabled ? user : toUserFormFields(user)); - setIsUserProfileEnabled(isUserProfileEnabled); - setRealmRepresentation(currentRealm); - setBruteForced(bruteForced); + realm.attributes?.userProfileEnabled === "true"; + + setUserProfileMetadata( + isUserProfileEnabled ? user.userProfileMetadata : undefined, + ); + + form.reset(toUserFormFields(user, isUserProfileEnabled)); }, [refreshCount], ); @@ -146,7 +145,7 @@ export default function EditUser() { try { await adminClient.users.del({ id: user!.id! }); addAlert(t("userDeletedSuccess"), AlertVariant.success); - navigate(toUsers({ realm })); + navigate(toUsers({ realm: realmName })); } catch (error) { addError("userDeletedError", error); } @@ -161,7 +160,7 @@ export default function EditUser() { try { const data = await adminClient.users.impersonation( { id: user!.id! }, - { user: user!.id!, realm }, + { user: user!.id!, realm: realmName }, ); if (data.sameRealm) { window.location = data.redirect; @@ -174,7 +173,7 @@ export default function EditUser() { }, }); - if (!user || !bruteForced) { + if (!realm || !user || !bruteForced) { return ; } @@ -203,14 +202,17 @@ export default function EditUser() { , ]} onToggle={(value) => - save({ ...toUserFormFields(user), enabled: value }) + save({ + ...toUserFormFields(user, !!userProfileMetadata), + enabled: value, + }) } isEnabled={user.enabled} /> - + - {!isUserProfileEnabled && ( + {!userProfileMetadata && ( {t("attributes")}} diff --git a/js/apps/admin-ui/src/user/UserAttributes.tsx b/js/apps/admin-ui/src/user/UserAttributes.tsx index aaa7505840..22b0404cce 100644 --- a/js/apps/admin-ui/src/user/UserAttributes.tsx +++ b/js/apps/admin-ui/src/user/UserAttributes.tsx @@ -1,8 +1,11 @@ import type UserRepresentation from "@keycloak/keycloak-admin-client/lib/defs/userRepresentation"; import { PageSection, PageSectionVariants } from "@patternfly/react-core"; -import { useFormContext } from "react-hook-form"; +import { UseFormReturn, useFormContext } from "react-hook-form"; -import { AttributesForm } from "../components/key-value-form/AttributeForm"; +import { + AttributeForm, + AttributesForm, +} from "../components/key-value-form/AttributeForm"; import { UserFormFields, toUserFormFields } from "./form-state"; type UserAttributesProps = { @@ -16,13 +19,13 @@ export const UserAttributes = ({ user, save }: UserAttributesProps) => { return ( } save={save} fineGrainedAccess={user.access?.manage} reset={() => form.reset({ ...form.getValues(), - attributes: toUserFormFields(user).attributes, + attributes: toUserFormFields(user, false).attributes, }) } /> diff --git a/js/apps/admin-ui/src/user/UserForm.tsx b/js/apps/admin-ui/src/user/UserForm.tsx index 2458839643..ec53ac1437 100644 --- a/js/apps/admin-ui/src/user/UserForm.tsx +++ b/js/apps/admin-ui/src/user/UserForm.tsx @@ -1,5 +1,6 @@ import type GroupRepresentation from "@keycloak/keycloak-admin-client/lib/defs/groupRepresentation"; import type RealmRepresentation from "@keycloak/keycloak-admin-client/lib/defs/realmRepresentation"; +import { UserProfileMetadata } from "@keycloak/keycloak-admin-client/lib/defs/userProfileMetadata"; import type UserRepresentation from "@keycloak/keycloak-admin-client/lib/defs/userRepresentation"; import { ActionGroup, @@ -12,9 +13,9 @@ import { Switch, } from "@patternfly/react-core"; import { useState } from "react"; -import { Controller, useFormContext } from "react-hook-form"; +import { Controller, UseFormReturn } from "react-hook-form"; import { useTranslation } from "react-i18next"; -import { useNavigate } from "react-router-dom"; +import { Link } from "react-router-dom"; import { HelpItem } from "ui-shared"; import { adminClient } from "../admin-client"; @@ -23,13 +24,12 @@ import { FormAccess } from "../components/form/FormAccess"; import { GroupPickerDialog } from "../components/group/GroupPickerDialog"; import { KeycloakTextInput } from "../components/keycloak-text-input/KeycloakTextInput"; import { useAccess } from "../context/access/Access"; -import { useRealm } from "../context/realm-context/RealmContext"; import { emailRegexPattern } from "../util"; import useFormatDate from "../utils/useFormatDate"; -import useIsFeatureEnabled, { Feature } from "../utils/useIsFeatureEnabled"; import { FederatedUserLink } from "./FederatedUserLink"; import { UserProfileFields } from "./UserProfileFields"; -import { UserFormFields } from "./form-state"; +import { UserFormFields, toUserFormFields } from "./form-state"; +import { toUsers } from "./routes/Users"; import { RequiredActionMultiSelect } from "./user-credentials/RequiredActionMultiSelect"; export type BruteForced = { @@ -38,63 +38,29 @@ export type BruteForced = { }; export type UserFormProps = { + form: UseFormReturn; + realm: RealmRepresentation; user?: UserRepresentation; bruteForce?: BruteForced; - realm?: RealmRepresentation; + userProfileMetadata?: UserProfileMetadata; save: (user: UserFormFields) => void; onGroupsUpdate?: (groups: GroupRepresentation[]) => void; }; -const EmailVerified = () => { - const { t } = useTranslation(); - const { control } = useFormContext(); - return ( - - } - > - ( - field.onChange(value)} - isChecked={field.value} - label={t("yes")} - labelOff={t("no")} - /> - )} - /> - - ); -}; - export const UserForm = ({ - user, + form, realm, + user, bruteForce: { isBruteForceProtected, isLocked } = { isBruteForceProtected: false, isLocked: false, }, + userProfileMetadata, save, onGroupsUpdate, }: UserFormProps) => { const { t } = useTranslation(); - const { realm: realmName } = useRealm(); const formatDate = useFormatDate(); - const isFeatureEnabled = useIsFeatureEnabled(); - - const navigate = useNavigate(); const { addAlert, addError } = useAlerts(); const { hasAccess } = useAccess(); const isManager = hasAccess("manage-users"); @@ -107,7 +73,7 @@ export const UserForm = ({ control, reset, formState: { errors }, - } = useFormContext(); + } = form; const watchUsernameInput = watch("username"); const [selectedGroups, setSelectedGroups] = useState( [], @@ -154,10 +120,6 @@ export const UserForm = ({ setOpen(!open); }; - const isUserProfileEnabled = - isFeatureEnabled(Feature.DeclarativeUserProfile) && - realm?.attributes?.userProfileEnabled === "true"; - return ( )} )} - {isUserProfileEnabled && user?.userProfileMetadata ? ( - + {userProfileMetadata ? ( + ) : ( <> - {!realm?.registrationEmailAsUsername && ( + {!realm.registrationEmailAsUsername && ( @@ -261,7 +228,33 @@ export const UserForm = ({ })} /> - + + } + > + ( + field.onChange(value)} + isChecked={field.value} + label={t("yes")} + labelOff={t("no")} + /> + )} + /> + diff --git a/js/apps/admin-ui/src/user/UserProfileFields.tsx b/js/apps/admin-ui/src/user/UserProfileFields.tsx index 572ffa7a54..e8c590a28c 100644 --- a/js/apps/admin-ui/src/user/UserProfileFields.tsx +++ b/js/apps/admin-ui/src/user/UserProfileFields.tsx @@ -1,8 +1,11 @@ -import type { UserProfileAttribute } from "@keycloak/keycloak-admin-client/lib/defs/userProfileConfig"; -import UserProfileConfig from "@keycloak/keycloak-admin-client/lib/defs/userProfileConfig"; +import type { + UserProfileAttributeGroupMetadata, + UserProfileAttributeMetadata, + UserProfileMetadata, +} from "@keycloak/keycloak-admin-client/lib/defs/userProfileMetadata"; import { Text } from "@patternfly/react-core"; -import { Fragment } from "react"; -import { useFormContext } from "react-hook-form"; +import { useMemo } from "react"; +import { FieldPath, UseFormReturn } from "react-hook-form"; import { useTranslation } from "react-i18next"; import { ScrollForm } from "../components/scroll-form/ScrollForm"; @@ -10,19 +13,15 @@ import { OptionComponent } from "./components/OptionsComponent"; import { SelectComponent } from "./components/SelectComponent"; import { TextAreaComponent } from "./components/TextAreaComponent"; import { TextComponent } from "./components/TextComponent"; -import { fieldName } from "./utils"; - -type UserProfileFieldsProps = { - config: UserProfileConfig; - roles?: string[]; -}; +import { UserFormFields } from "./form-state"; +import { fieldName, isRootAttribute } from "./utils"; export type UserProfileError = { responseData: { errors?: { errorMessage: string }[] }; }; export type Options = { - options: string[] | undefined; + options?: string[]; }; export function isUserProfileError(error: unknown): error is UserProfileError { @@ -35,7 +34,7 @@ export function userProfileErrorToString(error: UserProfileError) { ); } -const FieldTypes = [ +const INPUT_TYPES = [ "text", "textarea", "select", @@ -53,10 +52,17 @@ const FieldTypes = [ "html5-time", ] as const; -export type Field = (typeof FieldTypes)[number]; +export type InputType = (typeof INPUT_TYPES)[number]; + +export type UserProfileFieldProps = { + form: UseFormReturn; + inputType: InputType; + attribute: UserProfileAttributeMetadata; + roles: string[]; +}; export const FIELDS: { - [index in Field]: (props: any) => JSX.Element; + [type in InputType]: (props: UserProfileFieldProps) => JSX.Element; } = { text: TextComponent, textarea: TextAreaComponent, @@ -75,51 +81,129 @@ export const FIELDS: { "html5-time": TextComponent, } as const; -export const isValidComponentType = (value: string): value is Field => - value in FIELDS; +export type UserProfileFieldsProps = { + form: UseFormReturn; + userProfileMetadata: UserProfileMetadata; + roles?: string[]; + hideReadOnly?: boolean; +}; + +type GroupWithAttributes = { + group: UserProfileAttributeGroupMetadata; + attributes: UserProfileAttributeMetadata[]; +}; export const UserProfileFields = ({ - config, + form, + userProfileMetadata, roles = ["admin"], + hideReadOnly = false, }: UserProfileFieldsProps) => { const { t } = useTranslation(); + // Group attributes by group, for easier rendering. + const groupsWithAttributes = useMemo(() => { + // If there are no attributes, there is no need to group them. + if (!userProfileMetadata.attributes) { + return []; + } + + // Hide read-only attributes if 'hideReadOnly' is enabled. + const attributes = hideReadOnly + ? userProfileMetadata.attributes.filter(({ readOnly }) => !readOnly) + : userProfileMetadata.attributes; + + return [ + // Insert an empty group for attributes without a group. + { name: undefined }, + ...(userProfileMetadata.groups ?? []), + ].map((group) => ({ + group, + attributes: attributes.filter( + (attribute) => attribute.group === group.name, + ), + })); + }, [ + hideReadOnly, + userProfileMetadata.groups, + userProfileMetadata.attributes, + ]); + + if (groupsWithAttributes.length === 0) { + return null; + } return ( ({ - title: g.displayHeader || g.name || t("general"), - panel: ( -
- {g.displayDescription && ( - {g.displayDescription} - )} - {config.attributes?.map((attribute) => ( - - {(attribute.group || "") === g.name && ( - - )} - - ))} -
- ), - }))} + sections={groupsWithAttributes + .filter((group) => group.attributes.length > 0) + .map(({ group, attributes }) => ({ + title: group.displayHeader || group.name || t("general"), + panel: ( +
+ {group.displayDescription && ( + {group.displayDescription} + )} + {attributes.map((attribute) => ( + + ))} +
+ ), + }))} /> ); }; type FormFieldProps = { - attribute: UserProfileAttribute; + form: UseFormReturn; + attribute: UserProfileAttributeMetadata; roles: string[]; }; -const FormField = ({ attribute, roles }: FormFieldProps) => { - const { watch } = useFormContext(); - const value = watch(fieldName(attribute)); +const FormField = ({ form, attribute, roles }: FormFieldProps) => { + const value = form.watch(fieldName(attribute) as FieldPath); + const inputType = determineInputType(attribute, value); + const Component = FIELDS[inputType]; - const componentType = (attribute.annotations?.["inputType"] || - (Array.isArray(value) ? "multiselect" : "text")) as Field; - - const Component = FIELDS[componentType]; - - return ; + return ( + + ); }; + +const DEFAULT_INPUT_TYPE = "multiselect" satisfies InputType; + +function determineInputType( + attribute: UserProfileAttributeMetadata, + value: string | string[], +): InputType { + // Always treat the root attributes as a text field. + if (isRootAttribute(attribute.name)) { + return "text"; + } + + const inputType = attribute.annotations?.inputType; + + // If the attribute has no valid input type, it is always multi-valued. + if (!isValidInputType(inputType)) { + return DEFAULT_INPUT_TYPE; + } + + // An attribute with multiple values is always multi-valued, even if an input type is provided. + if (Array.isArray(value) && value.length > 1) { + return DEFAULT_INPUT_TYPE; + } + + return inputType; +} + +const isValidInputType = (value: unknown): value is InputType => + typeof value === "string" && value in FIELDS; diff --git a/js/apps/admin-ui/src/user/components/OptionsComponent.tsx b/js/apps/admin-ui/src/user/components/OptionsComponent.tsx index df003c4928..ab6c6e54d1 100644 --- a/js/apps/admin-ui/src/user/components/OptionsComponent.tsx +++ b/js/apps/admin-ui/src/user/components/OptionsComponent.tsx @@ -1,23 +1,27 @@ -import { UserProfileAttribute } from "@keycloak/keycloak-admin-client/lib/defs/userProfileConfig"; import { Checkbox, Radio } from "@patternfly/react-core"; -import { Controller, useFormContext } from "react-hook-form"; -import { UserProfileGroup } from "./UserProfileGroup"; -import { Options } from "../UserProfileFields"; +import { Controller, FieldPath } from "react-hook-form"; +import { isRequiredAttribute } from "../utils/user-profile"; + +import { Options, UserProfileFieldProps } from "../UserProfileFields"; +import { UserFormFields } from "../form-state"; import { fieldName } from "../utils"; +import { UserProfileGroup } from "./UserProfileGroup"; -export const OptionComponent = (attr: UserProfileAttribute) => { - const { control } = useFormContext(); - const type = attr.annotations?.["inputType"] as string; - const isMultiSelect = type.includes("multiselect"); +export const OptionComponent = ({ + form, + inputType, + attribute, +}: UserProfileFieldProps) => { + const isRequired = isRequiredAttribute(attribute); + const isMultiSelect = inputType.startsWith("multiselect"); const Component = isMultiSelect ? Checkbox : Radio; - - const options = (attr.validators?.options as Options).options || []; + const options = (attribute.validators?.options as Options).options || []; return ( - + } + control={form.control} defaultValue="" render={({ field }) => ( <> @@ -42,7 +46,8 @@ export const OptionComponent = (attr: UserProfileAttribute) => { field.onChange([option]); } }} - readOnly={attr.readOnly} + readOnly={attribute.readOnly} + isRequired={isRequired} /> ))} diff --git a/js/apps/admin-ui/src/user/components/SelectComponent.tsx b/js/apps/admin-ui/src/user/components/SelectComponent.tsx index 0c636ee7fb..0cf83e9f43 100644 --- a/js/apps/admin-ui/src/user/components/SelectComponent.tsx +++ b/js/apps/admin-ui/src/user/components/SelectComponent.tsx @@ -1,35 +1,30 @@ import { Select, SelectOption } from "@patternfly/react-core"; import { useState } from "react"; -import { - Controller, - useFormContext, - ControllerRenderProps, - FieldValues, -} from "react-hook-form"; +import { Controller, ControllerRenderProps, FieldPath } from "react-hook-form"; import { useTranslation } from "react-i18next"; -import { Options } from "../UserProfileFields"; +import { Options, UserProfileFieldProps } from "../UserProfileFields"; +import { UserFormFields } from "../form-state"; import { fieldName, unWrap } from "../utils"; -import { UserProfileFieldsProps, UserProfileGroup } from "./UserProfileGroup"; +import { UserProfileGroup } from "./UserProfileGroup"; +import { isRequiredAttribute } from "../utils/user-profile"; type OptionLabel = Record | undefined; -export const SelectComponent = (attribute: UserProfileFieldsProps) => { +export const SelectComponent = ({ + form, + inputType, + attribute, +}: UserProfileFieldProps) => { const { t } = useTranslation(); - const { control } = useFormContext(); const [open, setOpen] = useState(false); - - const isMultiValue = (field: ControllerRenderProps) => { - return ( - attribute.annotations?.["inputType"] === "multiselect" || - Array.isArray(field.value) - ); - }; + const isRequired = isRequiredAttribute(attribute); + const isMultiValue = inputType === "multiselect"; const setValue = ( value: string, - field: ControllerRenderProps, + field: ControllerRenderProps, ) => { - if (isMultiValue(field)) { + if (isMultiValue) { if (field.value.includes(value)) { field.onChange(field.value.filter((item: string) => item !== value)); } else { @@ -50,11 +45,11 @@ export const SelectComponent = (attribute: UserProfileFieldsProps) => { optionLabel ? t(unWrap(optionLabel[label])) : label; return ( - + } defaultValue="" - control={control} + control={form.control} render={({ field }) => ( setOpen(open)} isOpen={open} - selections={field.value} - onSelect={(_, selectedValue) => + selections={field.value as string[]} + onSelect={(_, selectedValue) => { + const value: string[] = field.value; field.onChange( - field.value.find((o: string) => o === selectedValue) - ? field.value.filter((item: string) => item !== selectedValue) - : [...field.value, selectedValue], - ) - } + value.find((item) => item === selectedValue) + ? value.filter((item) => item !== selectedValue) + : [...value, selectedValue], + ); + }} onClear={(event) => { event.stopPropagation(); field.onChange([]); diff --git a/js/apps/admin-ui/src/user/user-credentials/ResetCredentialDialog.tsx b/js/apps/admin-ui/src/user/user-credentials/ResetCredentialDialog.tsx index ebc3422d32..a898bf2874 100644 --- a/js/apps/admin-ui/src/user/user-credentials/ResetCredentialDialog.tsx +++ b/js/apps/admin-ui/src/user/user-credentials/ResetCredentialDialog.tsx @@ -82,12 +82,13 @@ export const ResetCredentialDialog = ({ isHorizontal data-testid="credential-reset-modal" > + - diff --git a/js/apps/admin-ui/src/user/utils.ts b/js/apps/admin-ui/src/user/utils.ts index a374902136..82168718e7 100644 --- a/js/apps/admin-ui/src/user/utils.ts +++ b/js/apps/admin-ui/src/user/utils.ts @@ -1,19 +1,21 @@ -import { UserProfileAttribute } from "@keycloak/keycloak-admin-client/lib/defs/userProfileConfig"; +import { UserProfileAttributeMetadata } from "@keycloak/keycloak-admin-client/lib/defs/userProfileMetadata"; import { TFunction } from "i18next"; export const isBundleKey = (displayName?: string) => displayName?.includes("${"); export const unWrap = (key: string) => key.substring(2, key.length - 1); -export const label = (attribute: UserProfileAttribute, t: TFunction) => +export const label = (attribute: UserProfileAttributeMetadata, t: TFunction) => (isBundleKey(attribute.displayName) ? t(unWrap(attribute.displayName!)) : attribute.displayName) || attribute.name; const ROOT_ATTRIBUTES = ["username", "firstName", "lastName", "email"]; -const isRootAttribute = (attr?: string) => +export const isRootAttribute = (attr?: string) => attr && ROOT_ATTRIBUTES.includes(attr); -export const fieldName = (attribute: UserProfileAttribute) => - `${isRootAttribute(attribute.name) ? "" : "attributes."}${attribute.name}`; +export const fieldName = (attribute: UserProfileAttributeMetadata) => + isRootAttribute(attribute.name) + ? attribute.name + : `attributes.${attribute.name}`; diff --git a/js/apps/admin-ui/src/user/utils/user-profile.ts b/js/apps/admin-ui/src/user/utils/user-profile.ts new file mode 100644 index 0000000000..7a030c0494 --- /dev/null +++ b/js/apps/admin-ui/src/user/utils/user-profile.ts @@ -0,0 +1,33 @@ +import { UserProfileAttributeMetadata } from "@keycloak/keycloak-admin-client/lib/defs/userProfileMetadata"; + +export function isRequiredAttribute({ + required, + validators, +}: UserProfileAttributeMetadata): boolean { + // Check if required is true or if the validators include a validation that would make the attribute implicitly required. + return required || hasRequiredValidators(validators); +} + +/** + * Checks whether the given validators include a validation that would make the attribute implicitly required. + */ +function hasRequiredValidators( + validators?: UserProfileAttributeMetadata["validators"], +): boolean { + // If we don't have any validators, the attribute is not required. + if (!validators) { + return false; + } + + // If the 'length' validator is defined and has a minimal length greater than zero the attribute is implicitly required. + // We have to do a lot of defensive coding here, because we don't have type information for the validators. + if ( + "length" in validators && + "min" in validators.length && + typeof validators.length.min === "number" + ) { + return validators.length.min > 0; + } + + return false; +} diff --git a/js/libs/keycloak-admin-client/src/defs/userProfileConfig.ts b/js/libs/keycloak-admin-client/src/defs/userProfileConfig.ts index 27f37445d9..17b4aab0d3 100644 --- a/js/libs/keycloak-admin-client/src/defs/userProfileConfig.ts +++ b/js/libs/keycloak-admin-client/src/defs/userProfileConfig.ts @@ -1,5 +1,5 @@ // See: https://github.com/keycloak/keycloak/blob/main/services/src/main/java/org/keycloak/userprofile/config/UPConfig.java -export default interface UserProfileConfig { +export interface UserProfileConfig { attributes?: UserProfileAttribute[]; groups?: UserProfileGroup[]; } @@ -7,11 +7,9 @@ export default interface UserProfileConfig { // See: https://github.com/keycloak/keycloak/blob/main/services/src/main/java/org/keycloak/userprofile/config/UPAttribute.java export interface UserProfileAttribute { name?: string; - validations?: Record; - validators?: Record; + validations?: Record>; annotations?: Record; required?: UserProfileAttributeRequired; - readOnly?: boolean; permissions?: UserProfileAttributePermissions; selector?: UserProfileAttributeSelector; displayName?: string; diff --git a/js/libs/keycloak-admin-client/src/defs/userProfileMetadata.ts b/js/libs/keycloak-admin-client/src/defs/userProfileMetadata.ts new file mode 100644 index 0000000000..ed10f5252d --- /dev/null +++ b/js/libs/keycloak-admin-client/src/defs/userProfileMetadata.ts @@ -0,0 +1,24 @@ +// See: https://github.com/keycloak/keycloak/blob/main/core/src/main/java/org/keycloak/representations/idm/UserProfileMetadata.java +export interface UserProfileMetadata { + attributes?: UserProfileAttributeMetadata[]; + groups?: UserProfileAttributeGroupMetadata[]; +} + +// See: https://github.com/keycloak/keycloak/blob/main/core/src/main/java/org/keycloak/representations/idm/UserProfileAttributeMetadata.java +export interface UserProfileAttributeMetadata { + name?: string; + displayName?: string; + required?: boolean; + readOnly?: boolean; + group?: string; + annotations?: Record; + validators?: Record>; +} + +// See: https://github.com/keycloak/keycloak/blob/main/core/src/main/java/org/keycloak/representations/idm/UserProfileAttributeGroupMetadata.java +export interface UserProfileAttributeGroupMetadata { + name?: string; + displayHeader?: string; + displayDescription?: string; + annotations?: Record; +} diff --git a/js/libs/keycloak-admin-client/src/defs/userRepresentation.ts b/js/libs/keycloak-admin-client/src/defs/userRepresentation.ts index aa6dd967f4..da34e603e7 100644 --- a/js/libs/keycloak-admin-client/src/defs/userRepresentation.ts +++ b/js/libs/keycloak-admin-client/src/defs/userRepresentation.ts @@ -1,8 +1,8 @@ -import type UserConsentRepresentation from "./userConsentRepresentation.js"; import type CredentialRepresentation from "./credentialRepresentation.js"; import type FederatedIdentityRepresentation from "./federatedIdentityRepresentation.js"; import type { RequiredActionAlias } from "./requiredActionProviderRepresentation.js"; -import type UserProfileConfig from "./userProfileConfig.js"; +import type UserConsentRepresentation from "./userConsentRepresentation.js"; +import type { UserProfileMetadata } from "./userProfileMetadata.js"; export default interface UserRepresentation { id?: string; @@ -31,5 +31,5 @@ export default interface UserRepresentation { realmRoles?: string[]; self?: string; serviceAccountClientId?: string; - userProfileMetadata?: UserProfileConfig; + userProfileMetadata?: UserProfileMetadata; } diff --git a/js/libs/keycloak-admin-client/src/resources/users.ts b/js/libs/keycloak-admin-client/src/resources/users.ts index 6c98f1d3bf..7cb50fd750 100644 --- a/js/libs/keycloak-admin-client/src/resources/users.ts +++ b/js/libs/keycloak-admin-client/src/resources/users.ts @@ -1,16 +1,17 @@ -import Resource from "./resource.js"; -import type UserRepresentation from "../defs/userRepresentation.js"; -import type UserConsentRepresentation from "../defs/userConsentRepresentation.js"; -import type UserSessionRepresentation from "../defs/userSessionRepresentation.js"; import type { KeycloakAdminClient } from "../client.js"; -import type MappingsRepresentation from "../defs/mappingsRepresentation.js"; -import type RoleRepresentation from "../defs/roleRepresentation.js"; -import type { RoleMappingPayload } from "../defs/roleRepresentation.js"; -import type { RequiredActionAlias } from "../defs/requiredActionProviderRepresentation.js"; +import type CredentialRepresentation from "../defs/credentialRepresentation.js"; import type FederatedIdentityRepresentation from "../defs/federatedIdentityRepresentation.js"; import type GroupRepresentation from "../defs/groupRepresentation.js"; -import type CredentialRepresentation from "../defs/credentialRepresentation.js"; -import type UserProfileConfig from "../defs/userProfileConfig.js"; +import type MappingsRepresentation from "../defs/mappingsRepresentation.js"; +import type { RequiredActionAlias } from "../defs/requiredActionProviderRepresentation.js"; +import type RoleRepresentation from "../defs/roleRepresentation.js"; +import type { RoleMappingPayload } from "../defs/roleRepresentation.js"; +import type UserConsentRepresentation from "../defs/userConsentRepresentation.js"; +import type { UserProfileConfig } from "../defs/userProfileConfig.js"; +import type { UserProfileMetadata } from "../defs/userProfileMetadata.js"; +import type UserRepresentation from "../defs/userRepresentation.js"; +import type UserSessionRepresentation from "../defs/userSessionRepresentation.js"; +import Resource from "./resource.js"; interface SearchQuery { search?: string; @@ -90,6 +91,11 @@ export class Users extends Resource<{ realm?: string }> { }, ); + public getProfileMetadata = this.makeRequest<{}, UserProfileMetadata>({ + method: "GET", + path: "/profile/metadata", + }); + /** * role mappings */ diff --git a/server-spi-private/src/main/java/org/keycloak/userprofile/AttributeContext.java b/server-spi-private/src/main/java/org/keycloak/userprofile/AttributeContext.java index 362feca23a..6533c54b64 100644 --- a/server-spi-private/src/main/java/org/keycloak/userprofile/AttributeContext.java +++ b/server-spi-private/src/main/java/org/keycloak/userprofile/AttributeContext.java @@ -34,15 +34,17 @@ public final class AttributeContext { private final Map.Entry> attribute; private final UserModel user; private final AttributeMetadata metadata; + private final Attributes attributes; private UserProfileContext context; public AttributeContext(UserProfileContext context, KeycloakSession session, Map.Entry> attribute, - UserModel user, AttributeMetadata metadata) { + UserModel user, AttributeMetadata metadata, Attributes attributes) { this.context = context; this.session = session; this.attribute = attribute; this.user = user; this.metadata = metadata; + this.attributes = attributes; } public KeycloakSession getSession() { @@ -64,4 +66,8 @@ public final class AttributeContext { public AttributeMetadata getMetadata() { return metadata; } + + public Attributes getAttributes() { + return attributes; + } } diff --git a/server-spi-private/src/main/java/org/keycloak/userprofile/DefaultAttributes.java b/server-spi-private/src/main/java/org/keycloak/userprofile/DefaultAttributes.java index fffa8294df..07c1df8625 100644 --- a/server-spi-private/src/main/java/org/keycloak/userprofile/DefaultAttributes.java +++ b/server-spi-private/src/main/java/org/keycloak/userprofile/DefaultAttributes.java @@ -232,11 +232,11 @@ public class DefaultAttributes extends HashMap> implements } private AttributeContext createAttributeContext(Entry> attribute, AttributeMetadata metadata) { - return new AttributeContext(context, session, attribute, user, metadata); + return new AttributeContext(context, session, attribute, user, metadata, this); } private AttributeContext createAttributeContext(String attributeName, AttributeMetadata metadata) { - return new AttributeContext(context, session, createAttribute(attributeName), user, metadata); + return new AttributeContext(context, session, createAttribute(attributeName), user, metadata, this); } protected AttributeContext createAttributeContext(AttributeMetadata metadata) { diff --git a/server-spi-private/src/main/java/org/keycloak/userprofile/UserProfileProvider.java b/server-spi-private/src/main/java/org/keycloak/userprofile/UserProfileProvider.java index 718f8390c7..842291b1a7 100644 --- a/server-spi-private/src/main/java/org/keycloak/userprofile/UserProfileProvider.java +++ b/server-spi-private/src/main/java/org/keycloak/userprofile/UserProfileProvider.java @@ -19,6 +19,7 @@ package org.keycloak.userprofile; import java.util.Map; +import org.keycloak.models.RealmModel; import org.keycloak.models.UserModel; import org.keycloak.provider.Provider; @@ -87,4 +88,12 @@ public interface UserProfileProvider extends Provider { * @see #getConfiguration() */ void setConfiguration(String configuration); + + /** + * Returns whether the declarative provider is enabled to a realm + * + * @deprecated should be removed once {@link DeclarativeUserProfileProvider} becomes the default. + * @return {@code true} if the declarative provider is enabled. Otherwise, {@code false}. + */ + boolean isEnabled(RealmModel realm); } diff --git a/services/src/main/java/org/keycloak/forms/login/freemarker/FreeMarkerLoginFormsProvider.java b/services/src/main/java/org/keycloak/forms/login/freemarker/FreeMarkerLoginFormsProvider.java index 4dde78160e..ed4d414d59 100755 --- a/services/src/main/java/org/keycloak/forms/login/freemarker/FreeMarkerLoginFormsProvider.java +++ b/services/src/main/java/org/keycloak/forms/login/freemarker/FreeMarkerLoginFormsProvider.java @@ -306,7 +306,7 @@ public class FreeMarkerLoginFormsProvider implements LoginFormsProvider { } private boolean isDynamicUserProfile() { - return session.getProvider(UserProfileProvider.class).getConfiguration() != null; + return session.getProvider(UserProfileProvider.class).isEnabled(realm); } /** diff --git a/services/src/main/java/org/keycloak/services/resources/account/AccountRestService.java b/services/src/main/java/org/keycloak/services/resources/account/AccountRestService.java index bc1d40df10..f39fe66250 100755 --- a/services/src/main/java/org/keycloak/services/resources/account/AccountRestService.java +++ b/services/src/main/java/org/keycloak/services/resources/account/AccountRestService.java @@ -80,11 +80,11 @@ import org.keycloak.services.managers.Auth; import org.keycloak.services.managers.UserConsentManager; import org.keycloak.services.messages.Messages; import org.keycloak.services.resources.account.resources.ResourcesService; +import org.keycloak.services.resources.admin.UserProfileResource; import org.keycloak.services.util.ResolveRelative; import org.keycloak.storage.ReadOnlyException; import org.keycloak.theme.Theme; import org.keycloak.userprofile.AttributeMetadata; -import org.keycloak.userprofile.AttributeValidatorMetadata; import org.keycloak.userprofile.Attributes; import org.keycloak.userprofile.UserProfile; import org.keycloak.userprofile.UserProfileContext; @@ -153,7 +153,7 @@ public class AccountRestService { addReadableBuiltinAttributes(user, rep, profile.getAttributes().getReadable(true).keySet()); if(userProfileMetadata == null || userProfileMetadata.booleanValue()) - rep.setUserProfileMetadata(createUserProfileMetadata(profile)); + rep.setUserProfileMetadata(UserProfileResource.createUserProfileMetadata(session, profile)); return rep; } @@ -173,37 +173,6 @@ public class AccountRestService { } } - private UserProfileMetadata createUserProfileMetadata(final UserProfile profile) { - Map> am = profile.getAttributes().getReadable(); - - if(am == null) - return null; - - List attributes = am.keySet().stream() - .map(name -> profile.getAttributes().getMetadata(name)) - .filter(Objects::nonNull) - .sorted((a,b) -> Integer.compare(a.getGuiOrder(), b.getGuiOrder())) - .map(sam -> toRestMetadata(sam, profile)) - .collect(Collectors.toList()); - return new UserProfileMetadata(attributes); - } - - private UserProfileAttributeMetadata toRestMetadata(AttributeMetadata am, UserProfile profile) { - return new UserProfileAttributeMetadata(am.getName(), - am.getAttributeDisplayName(), - profile.getAttributes().isRequired(am.getName()), - profile.getAttributes().isReadOnly(am.getName()), - am.getAnnotations(), - toValidatorMetadata(am)); - } - - private Map> toValidatorMetadata(AttributeMetadata am){ - // we return only validators which are instance of ConfiguredProvider. Others are expected as internal. - return am.getValidators() == null ? null : am.getValidators().stream() - .filter(avm -> (Validators.validator(session, avm.getValidatorId()) instanceof ConfiguredProvider)) - .collect(Collectors.toMap(AttributeValidatorMetadata::getValidatorId, AttributeValidatorMetadata::getValidatorConfig)); - } - @Path("/") @POST @Consumes(MediaType.APPLICATION_JSON) diff --git a/services/src/main/java/org/keycloak/services/resources/admin/UserProfileResource.java b/services/src/main/java/org/keycloak/services/resources/admin/UserProfileResource.java index 83e64e39b4..7967902de4 100644 --- a/services/src/main/java/org/keycloak/services/resources/admin/UserProfileResource.java +++ b/services/src/main/java/org/keycloak/services/resources/admin/UserProfileResource.java @@ -16,6 +16,15 @@ */ package org.keycloak.services.resources.admin; +import java.util.Collections; +import java.util.Comparator; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.function.Function; +import java.util.stream.Collectors; + +import jakarta.ws.rs.Path; import org.eclipse.microprofile.openapi.annotations.Operation; import jakarta.ws.rs.Consumes; import jakarta.ws.rs.GET; @@ -29,10 +38,22 @@ import org.eclipse.microprofile.openapi.annotations.tags.Tag; import org.keycloak.component.ComponentValidationException; import org.keycloak.models.KeycloakSession; import org.keycloak.models.RealmModel; +import org.keycloak.provider.ConfiguredProvider; +import org.keycloak.representations.idm.UserProfileAttributeGroupMetadata; +import org.keycloak.representations.idm.UserProfileAttributeMetadata; +import org.keycloak.representations.idm.UserProfileMetadata; import org.keycloak.services.ErrorResponse; import org.keycloak.services.resources.KeycloakOpenAPI; import org.keycloak.services.resources.admin.permissions.AdminPermissionEvaluator; +import org.keycloak.userprofile.AttributeMetadata; +import org.keycloak.userprofile.AttributeValidatorMetadata; +import org.keycloak.userprofile.UserProfile; +import org.keycloak.userprofile.UserProfileContext; import org.keycloak.userprofile.UserProfileProvider; +import org.keycloak.userprofile.config.UPConfig; +import org.keycloak.userprofile.config.UPGroup; +import org.keycloak.util.JsonSerialization; +import org.keycloak.validate.Validators; /** * @author Vlastimil Elias @@ -60,6 +81,17 @@ public class UserProfileResource { return session.getProvider(UserProfileProvider.class).getConfiguration(); } + @GET + @Path("/metadata") + @Produces(MediaType.APPLICATION_JSON) + @Tag(name = KeycloakOpenAPI.Admin.Tags.USERS) + @Operation() + public UserProfileMetadata getMetadata() { + auth.requireAnyAdminRole(); + UserProfile profile = session.getProvider(UserProfileProvider.class).create(UserProfileContext.USER_API, Collections.emptyMap()); + return createUserProfileMetadata(session, profile); + } + @PUT @Consumes(MediaType.APPLICATION_JSON) @Tag(name = KeycloakOpenAPI.Admin.Tags.USERS) @@ -78,4 +110,58 @@ public class UserProfileResource { return Response.ok(t.getConfiguration()).type(MediaType.APPLICATION_JSON).build(); } + public static UserProfileMetadata createUserProfileMetadata(KeycloakSession session, UserProfile profile) { + Map> am = profile.getAttributes().getReadable(); + + if(am == null) + return null; + + List attributes = am.keySet().stream() + .map(name -> profile.getAttributes().getMetadata(name)) + .filter(Objects::nonNull) + .sorted(Comparator.comparingInt(AttributeMetadata::getGuiOrder)) + .map(sam -> toRestMetadata(sam, session, profile)) + .collect(Collectors.toList()); + + UserProfileProvider provider = session.getProvider(UserProfileProvider.class); + UPConfig config; + + try { + config = JsonSerialization.readValue(provider.getConfiguration(), UPConfig.class); + } catch (Exception cause) { + throw new RuntimeException("Failed to parse configuration", cause); + } + + List groups = config.getGroups().stream().map(new Function() { + @Override + public UserProfileAttributeGroupMetadata apply(UPGroup upGroup) { + return new UserProfileAttributeGroupMetadata(upGroup.getName(), upGroup.getDisplayHeader(), upGroup.getDisplayDescription(), upGroup.getAnnotations()); + } + }).collect(Collectors.toList()); + + return new UserProfileMetadata(attributes, groups); + } + + private static UserProfileAttributeMetadata toRestMetadata(AttributeMetadata am, KeycloakSession session, UserProfile profile) { + String group = null; + + if (am.getAttributeGroupMetadata() != null) { + group = am.getAttributeGroupMetadata().getName(); + } + + return new UserProfileAttributeMetadata(am.getName(), + am.getAttributeDisplayName(), + profile.getAttributes().isRequired(am.getName()), + profile.getAttributes().isReadOnly(am.getName()), + group, + am.getAnnotations(), + toValidatorMetadata(am, session)); + } + + private static Map> toValidatorMetadata(AttributeMetadata am, KeycloakSession session){ + // we return only validators which are instance of ConfiguredProvider. Others are expected as internal. + return am.getValidators() == null ? null : am.getValidators().stream() + .filter(avm -> (Validators.validator(session, avm.getValidatorId()) instanceof ConfiguredProvider)) + .collect(Collectors.toMap(AttributeValidatorMetadata::getValidatorId, AttributeValidatorMetadata::getValidatorConfig)); + } } diff --git a/services/src/main/java/org/keycloak/services/resources/admin/UserResource.java b/services/src/main/java/org/keycloak/services/resources/admin/UserResource.java index 84d63f413c..64edfe885f 100755 --- a/services/src/main/java/org/keycloak/services/resources/admin/UserResource.java +++ b/services/src/main/java/org/keycloak/services/resources/admin/UserResource.java @@ -127,6 +127,7 @@ import java.util.stream.Stream; import static org.keycloak.models.ImpersonationSessionNote.IMPERSONATOR_ID; import static org.keycloak.models.ImpersonationSessionNote.IMPERSONATOR_USERNAME; +import static org.keycloak.services.resources.admin.UserProfileResource.createUserProfileMetadata; import static org.keycloak.userprofile.UserProfileContext.USER_API; import static org.keycloak.utils.LockObjectsForModification.lockUserSessionsForModification; @@ -336,7 +337,7 @@ public class UserResource { } if (userProfileMetadata) { - rep.setUserProfileMetadata(createUserProfileMetadata(profile)); + rep.setUserProfileMetadata(createUserProfileMetadata(session, profile)); } return rep; @@ -1068,35 +1069,4 @@ public class UserResource { rep.setLastAccess(Time.toMillis(clientSession.getTimestamp())); return rep; } - - private UserProfileMetadata createUserProfileMetadata(final UserProfile profile) { - Map> am = profile.getAttributes().getReadable(); - - if(am == null) - return null; - - List attributes = am.keySet().stream() - .map(name -> profile.getAttributes().getMetadata(name)) - .filter(Objects::nonNull) - .sorted((a,b) -> Integer.compare(a.getGuiOrder(), b.getGuiOrder())) - .map(sam -> toRestMetadata(sam, profile)) - .collect(Collectors.toList()); - return new UserProfileMetadata(attributes); - } - - private UserProfileAttributeMetadata toRestMetadata(AttributeMetadata am, UserProfile profile) { - return new UserProfileAttributeMetadata(am.getName(), - am.getAttributeDisplayName(), - profile.getAttributes().isRequired(am.getName()), - profile.getAttributes().isReadOnly(am.getName()), - am.getAnnotations(), - toValidatorMetadata(am)); - } - - private Map> toValidatorMetadata(AttributeMetadata am){ - // we return only validators which are instance of ConfiguredProvider. Others are expected as internal. - return am.getValidators() == null ? null : am.getValidators().stream() - .filter(avm -> (Validators.validator(session, avm.getValidatorId()) instanceof ConfiguredProvider)) - .collect(Collectors.toMap(AttributeValidatorMetadata::getValidatorId, AttributeValidatorMetadata::getValidatorConfig)); - } } diff --git a/services/src/main/java/org/keycloak/userprofile/AbstractUserProfileProvider.java b/services/src/main/java/org/keycloak/userprofile/AbstractUserProfileProvider.java index 940049a3e7..472a5ac0a4 100644 --- a/services/src/main/java/org/keycloak/userprofile/AbstractUserProfileProvider.java +++ b/services/src/main/java/org/keycloak/userprofile/AbstractUserProfileProvider.java @@ -87,6 +87,11 @@ public abstract class AbstractUserProfileProvider if (realm.isRegistrationEmailAsUsername()) { return false; } + + if (isNewUser(c)) { + // when creating a user the username is always editable + return true; + } } return realm.isEditUsernameAllowed(); @@ -116,23 +121,15 @@ public abstract class AbstractUserProfileProvider private static boolean editEmailCondition(AttributeContext c) { RealmModel realm = c.getSession().getContext().getRealm(); - if (REGISTRATION_PROFILE.equals(c.getContext())) { + if (REGISTRATION_PROFILE.equals(c.getContext()) || USER_API.equals(c.getContext())) { return true; } - if (USER_API.equals(c.getContext())) { - if (realm.isRegistrationEmailAsUsername()) { - return true; - } - } - if (Profile.isFeatureEnabled(Feature.UPDATE_EMAIL)) { return !(UPDATE_PROFILE.equals(c.getContext()) || ACCOUNT.equals(c.getContext())); } - UserModel user = c.getUser(); - - if (user != null && realm.isRegistrationEmailAsUsername() && !realm.isEditUsernameAllowed()) { + if (!isNewUser(c) && realm.isRegistrationEmailAsUsername() && !realm.isEditUsernameAllowed()) { return false; } @@ -142,7 +139,7 @@ public abstract class AbstractUserProfileProvider private static boolean readEmailCondition(AttributeContext c) { UserProfileContext context = c.getContext(); - if (REGISTRATION_PROFILE.equals(context)) { + if (REGISTRATION_PROFILE.equals(context) || USER_API.equals(c.getContext())) { return true; } @@ -183,6 +180,10 @@ public abstract class AbstractUserProfileProvider return realm.isInternationalizationEnabled(); } + private static boolean isNewUser(AttributeContext c) { + return c.getUser() == null; + } + /** * There are the declarations for creating the built-in validations for read-only attributes. Regardless of the context where * user profiles are used. They are related to internal attributes with hard conditions on them in terms of management. diff --git a/services/src/main/java/org/keycloak/userprofile/DeclarativeUserProfileProvider.java b/services/src/main/java/org/keycloak/userprofile/DeclarativeUserProfileProvider.java index f41de37ea7..7d357f71a9 100644 --- a/services/src/main/java/org/keycloak/userprofile/DeclarativeUserProfileProvider.java +++ b/services/src/main/java/org/keycloak/userprofile/DeclarativeUserProfileProvider.java @@ -126,7 +126,9 @@ public class DeclarativeUserProfileProvider extends AbstractUserProfileProvider< @Override protected Attributes createAttributes(UserProfileContext context, Map attributes, UserModel user, UserProfileMetadata metadata) { - if (isEnabled(session)) { + RealmModel realm = session.getContext().getRealm(); + + if (isEnabled(realm)) { if (user != null && user.getServiceAccountClientLink() != null) { return new LegacyAttributes(context, attributes, user, metadata, session); } @@ -139,8 +141,9 @@ public class DeclarativeUserProfileProvider extends AbstractUserProfileProvider< protected UserProfileMetadata configureUserProfile(UserProfileMetadata metadata, KeycloakSession session) { UserProfileContext context = metadata.getContext(); UserProfileMetadata decoratedMetadata = metadata.clone(); + RealmModel realm = session.getContext().getRealm(); - if (!isEnabled(session)) { + if (!isEnabled(realm)) { if(!context.equals(UserProfileContext.USER_API) && !context.equals(UserProfileContext.REGISTRATION_USER_CREATION) && !context.equals(UserProfileContext.UPDATE_EMAIL)) { @@ -194,8 +197,10 @@ public class DeclarativeUserProfileProvider extends AbstractUserProfileProvider< @Override public String getConfiguration() { - if (!isEnabled(session)) { - return null; + RealmModel realm = session.getContext().getRealm(); + + if (!isEnabled(realm)) { + return defaultRawConfig; } String cfg = getConfigJsonFromComponentModel(getComponentModel()); @@ -349,7 +354,31 @@ public class DeclarativeUserProfileProvider extends AbstractUserProfileProvider< } if (UserModel.USERNAME.equals(attributeName)) { - required = AttributeMetadata.ALWAYS_TRUE; + required = new Predicate() { + @Override + public boolean test(AttributeContext context) { + RealmModel realm = context.getSession().getContext().getRealm(); + return !realm.isRegistrationEmailAsUsername(); + } + }; + } + + if (UserModel.EMAIL.equals(attributeName)) { + if (UserProfileContext.USER_API.equals(context)) { + required = new Predicate() { + @Override + public boolean test(AttributeContext context) { + UserModel user = context.getUser(); + + if (user != null && user.getServiceAccountClientLink() != null) { + return false; + } + + RealmModel realm = context.getSession().getContext().getRealm(); + return realm.isRegistrationEmailAsUsername(); + } + }; + } } // Add ImmutableAttributeValidator to ensure that attributes that are configured @@ -505,14 +534,8 @@ public class DeclarativeUserProfileProvider extends AbstractUserProfileProvider< model.getConfig().remove(UP_PIECES_COUNT_COMPONENT_CONFIG_KEY); } - /** - * Returns whether the declarative provider is enabled to a realm - * - * @deprecated should be removed once {@link DeclarativeUserProfileProvider} becomes the default. - * @param session the session - * @return {@code true} if the declarative provider is enabled. Otherwise, {@code false}. - */ - private Boolean isEnabled(KeycloakSession session) { - return isDeclarativeConfigurationEnabled && session.getContext().getRealm().getAttribute(REALM_USER_PROFILE_ENABLED, false); + @Override + public boolean isEnabled(RealmModel realm) { + return isDeclarativeConfigurationEnabled && realm.getAttribute(REALM_USER_PROFILE_ENABLED, false); } } diff --git a/services/src/main/java/org/keycloak/userprofile/validator/ImmutableAttributeValidator.java b/services/src/main/java/org/keycloak/userprofile/validator/ImmutableAttributeValidator.java index 98d7d7f5a6..11e24610fb 100644 --- a/services/src/main/java/org/keycloak/userprofile/validator/ImmutableAttributeValidator.java +++ b/services/src/main/java/org/keycloak/userprofile/validator/ImmutableAttributeValidator.java @@ -19,6 +19,7 @@ package org.keycloak.userprofile.validator; import static org.keycloak.validate.Validators.notBlankValidator; import java.util.List; +import java.util.Objects; import java.util.stream.Collectors; import org.keycloak.common.util.CollectionUtil; @@ -57,7 +58,7 @@ public class ImmutableAttributeValidator implements SimpleValidator { return context; } - List currentValue = user.getAttributeStream(inputHint).collect(Collectors.toList()); + List currentValue = user.getAttributeStream(inputHint).filter(Objects::nonNull).collect(Collectors.toList()); List values = (List) input; if (!CollectionUtil.collectionEquals(currentValue, values) && isReadOnly(attributeContext)) { diff --git a/services/src/main/java/org/keycloak/userprofile/validator/UsernameMutationValidator.java b/services/src/main/java/org/keycloak/userprofile/validator/UsernameMutationValidator.java index cf194ed205..f06da402d3 100644 --- a/services/src/main/java/org/keycloak/userprofile/validator/UsernameMutationValidator.java +++ b/services/src/main/java/org/keycloak/userprofile/validator/UsernameMutationValidator.java @@ -24,8 +24,8 @@ import org.keycloak.models.utils.KeycloakModelUtils; import org.keycloak.services.messages.Messages; import org.keycloak.services.validation.Validation; import org.keycloak.userprofile.AttributeContext; +import org.keycloak.userprofile.Attributes; import org.keycloak.userprofile.UserProfileAttributeValidationContext; -import org.keycloak.userprofile.UserProfileContext; import org.keycloak.validate.SimpleValidator; import org.keycloak.validate.ValidationContext; import org.keycloak.validate.ValidationError; @@ -69,7 +69,8 @@ public class UsernameMutationValidator implements SimpleValidator { if (! KeycloakModelUtils.isUsernameCaseSensitive(realm)) value = value.toLowerCase(); if (!realm.isEditUsernameAllowed() && user != null && !value.equals(user.getFirstAttribute(UserModel.USERNAME))) { - if (realm.isRegistrationEmailAsUsername() && UserProfileContext.UPDATE_PROFILE.equals(attributeContext.getContext())) { + Attributes attributes = attributeContext.getAttributes(); + if (realm.isRegistrationEmailAsUsername() && value.equals(attributes.getFirstValue(UserModel.EMAIL))) { // if username changed is because email as username is allowed so no validation should happen for update profile // it is expected that username changes when attributes are normalized by the provider return context; diff --git a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/account/AccountRestServiceTest.java b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/account/AccountRestServiceTest.java index a33c047257..0d6032b960 100755 --- a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/account/AccountRestServiceTest.java +++ b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/account/AccountRestServiceTest.java @@ -113,12 +113,14 @@ public class AccountRestServiceTest extends AbstractRestServiceTest { realmRep.setEditUsernameAllowed(true); realm.update(realmRep); user = getUser(); - assertNotNull(user.getUserProfileMetadata()); - // can write both username and email - assertUserProfileAttributeMetadata(user, "username", "${username}", true, false); - assertUserProfileAttributeMetadata(user, "email", "${email}", true, false); - assertUserProfileAttributeMetadata(user, "firstName", "${firstName}", true, false); - assertUserProfileAttributeMetadata(user, "lastName", "${lastName}", true, false); + if (isDeclarativeUserProfile()) { + assertNotNull(user.getUserProfileMetadata()); + // can write both username and email + assertUserProfileAttributeMetadata(user, "username", "${username}", true, false); + assertUserProfileAttributeMetadata(user, "email", "${email}", true, false); + assertUserProfileAttributeMetadata(user, "firstName", "${firstName}", true, false); + assertUserProfileAttributeMetadata(user, "lastName", "${lastName}", true, false); + } user.setUsername("changed-username"); user.setEmail("changed-email@keycloak.org"); user = updateAndGet(user); @@ -129,10 +131,12 @@ public class AccountRestServiceTest extends AbstractRestServiceTest { realmRep.setEditUsernameAllowed(false); realm.update(realmRep); user = getUser(); - assertNotNull(user.getUserProfileMetadata()); - // username is readonly but email is writable - assertUserProfileAttributeMetadata(user, "username", "${username}", true, true); - assertUserProfileAttributeMetadata(user, "email", "${email}", true, false); + if (isDeclarativeUserProfile()) { + assertNotNull(user.getUserProfileMetadata()); + // username is readonly but email is writable + assertUserProfileAttributeMetadata(user, "username", "${username}", true, true); + assertUserProfileAttributeMetadata(user, "email", "${email}", true, false); + } user.setUsername("should-not-change"); user.setEmail("changed-email@keycloak.org"); updateError(user, 400, Messages.READ_ONLY_USERNAME); @@ -141,10 +145,12 @@ public class AccountRestServiceTest extends AbstractRestServiceTest { realmRep.setEditUsernameAllowed(true); realm.update(realmRep); user = getUser(); - assertNotNull(user.getUserProfileMetadata()); - // username is read-only and is the same as email, but email is writable - assertUserProfileAttributeMetadata(user, "username", "${username}", true, true); - assertUserProfileAttributeMetadata(user, "email", "${email}", true, false); + if (isDeclarativeUserProfile()) { + assertNotNull(user.getUserProfileMetadata()); + // username is read-only and is the same as email, but email is writable + assertUserProfileAttributeMetadata(user, "username", "${username}", true, true); + assertUserProfileAttributeMetadata(user, "email", "${email}", true, false); + } user.setUsername("should-be-the-email"); user.setEmail("user@keycloak.org"); user = updateAndGet(user); @@ -155,15 +161,36 @@ public class AccountRestServiceTest extends AbstractRestServiceTest { realmRep.setEditUsernameAllowed(false); realm.update(realmRep); user = getUser(); - assertNotNull(user.getUserProfileMetadata()); - // username is read-only and is the same as email, but email is read-only - assertUserProfileAttributeMetadata(user, "username", "${username}", true, true); - assertUserProfileAttributeMetadata(user, "email", "${email}", true, true); + if (isDeclarativeUserProfile()) { + assertNotNull(user.getUserProfileMetadata()); + // username is read-only and is the same as email, but email is read-only + assertUserProfileAttributeMetadata(user, "username", "${username}", true, true); + assertUserProfileAttributeMetadata(user, "email", "${email}", true, true); + } user.setUsername("should-be-the-email"); user.setEmail("should-not-change@keycloak.org"); user = updateAndGet(user); assertEquals("user@keycloak.org", user.getUsername()); assertEquals("user@keycloak.org", user.getEmail()); + + realmRep.setRegistrationEmailAsUsername(false); + realmRep.setEditUsernameAllowed(true); + realm.update(realmRep); + user = getUser(); + user.setUsername("different-than-email"); + user.setEmail("user@keycloak.org"); + user = updateAndGet(user); + assertEquals("different-than-email", user.getUsername()); + assertEquals("user@keycloak.org", user.getEmail()); + + realmRep.setRegistrationEmailAsUsername(true); + realmRep.setEditUsernameAllowed(false); + realm.update(realmRep); + user = getUser(); + user.setEmail("should-not-change@keycloak.org"); + user = updateAndGet(user); + assertEquals("different-than-email", user.getUsername()); + assertEquals("user@keycloak.org", user.getEmail()); } finally { realmRep.setRegistrationEmailAsUsername(registrationEmailAsUsername); realmRep.setEditUsernameAllowed(editUsernameAllowed); @@ -189,24 +216,28 @@ public class AccountRestServiceTest extends AbstractRestServiceTest { realm.update(realmRep); UserRepresentation user = getUser(); - assertNotNull(user.getUserProfileMetadata()); - UserProfileAttributeMetadata upm = assertUserProfileAttributeMetadata(user, "username", "${username}", true, true); - //makes sure internal validators are not exposed - Assert.assertEquals(0, upm.getValidators().size()); + if (isDeclarativeUserProfile()) { + assertNotNull(user.getUserProfileMetadata()); + UserProfileAttributeMetadata upm = assertUserProfileAttributeMetadata(user, "username", "${username}", true, true); + //makes sure internal validators are not exposed + Assert.assertEquals(0, upm.getValidators().size()); - upm = assertUserProfileAttributeMetadata(user, "email", "${email}", true, false); - Assert.assertEquals(1, upm.getValidators().size()); - Assert.assertTrue(upm.getValidators().containsKey(EmailValidator.ID)); + upm = assertUserProfileAttributeMetadata(user, "email", "${email}", true, false); + Assert.assertEquals(1, upm.getValidators().size()); + Assert.assertTrue(upm.getValidators().containsKey(EmailValidator.ID)); + } realmRep.setRegistrationEmailAsUsername(true); realm.update(realmRep); user = getUser(); - upm = assertUserProfileAttributeMetadata(user, "email", "${email}", true, true); - Assert.assertEquals(1, upm.getValidators().size()); - Assert.assertTrue(upm.getValidators().containsKey(EmailValidator.ID)); + if (isDeclarativeUserProfile()) { + UserProfileAttributeMetadata upm = assertUserProfileAttributeMetadata(user, "email", "${email}", true, true); + Assert.assertEquals(1, upm.getValidators().size()); + Assert.assertTrue(upm.getValidators().containsKey(EmailValidator.ID)); - assertUserProfileAttributeMetadata(user, "firstName", "${firstName}", true, false); - assertUserProfileAttributeMetadata(user, "lastName", "${lastName}", true, false); + assertUserProfileAttributeMetadata(user, "firstName", "${firstName}", true, false); + assertUserProfileAttributeMetadata(user, "lastName", "${lastName}", true, false); + } } finally { RealmRepresentation realmRep = testRealm().toRepresentation(); realmRep.setEditUsernameAllowed(true); diff --git a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/admin/UserTestWithUserProfile.java b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/admin/UserTestWithUserProfile.java index 333b198bfe..7341cfd2b7 100755 --- a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/admin/UserTestWithUserProfile.java +++ b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/admin/UserTestWithUserProfile.java @@ -71,7 +71,7 @@ public class UserTestWithUserProfile extends UserTest { assertNotNull(metadata); for (String name : managedAttributes) { - assertNotNull(getAttributeMetadata(metadata, name)); + assertNotNull(metadata.getAttributeMetadata(name)); } } @@ -83,10 +83,10 @@ public class UserTestWithUserProfile extends UserTest { UserRepresentation user = realm.users().get(userId).toRepresentation(true); UserProfileMetadata metadata = user.getUserProfileMetadata(); assertNotNull(metadata); - UserProfileAttributeMetadata username = getAttributeMetadata(metadata, UserModel.USERNAME); + UserProfileAttributeMetadata username = metadata.getAttributeMetadata(UserModel.USERNAME); assertNotNull(username); assertTrue(username.isReadOnly()); - UserProfileAttributeMetadata email = getAttributeMetadata(metadata, UserModel.EMAIL); + UserProfileAttributeMetadata email = metadata.getAttributeMetadata(UserModel.EMAIL); assertNotNull(email); assertFalse(email.isReadOnly()); } @@ -101,26 +101,14 @@ public class UserTestWithUserProfile extends UserTest { UserRepresentation user = realm.users().get(userId).toRepresentation(true); UserProfileMetadata metadata = user.getUserProfileMetadata(); assertNotNull(metadata); - UserProfileAttributeMetadata username = getAttributeMetadata(metadata, UserModel.USERNAME); + UserProfileAttributeMetadata username = metadata.getAttributeMetadata(UserModel.USERNAME); assertNotNull(username); assertTrue(username.isReadOnly()); - UserProfileAttributeMetadata email = getAttributeMetadata(metadata, UserModel.EMAIL); + UserProfileAttributeMetadata email = metadata.getAttributeMetadata(UserModel.EMAIL); assertNotNull(email); assertFalse(email.isReadOnly()); } - @Nullable - private static UserProfileAttributeMetadata getAttributeMetadata(UserProfileMetadata metadata, String name) { - UserProfileAttributeMetadata attrMetadata = null; - - for (UserProfileAttributeMetadata m : metadata.getAttributes()) { - if (name.equals(m.getName())) { - attrMetadata = m; - } - } - return attrMetadata; - } - private UPAttribute createAttributeMetadata(String name) { UPAttribute attribute = new UPAttribute(); attribute.setName(name); diff --git a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/admin/userprofile/UserProfileAdminTest.java b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/admin/userprofile/UserProfileAdminTest.java index 5700f08142..6541d1f04c 100644 --- a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/admin/userprofile/UserProfileAdminTest.java +++ b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/admin/userprofile/UserProfileAdminTest.java @@ -20,18 +20,31 @@ package org.keycloak.testsuite.admin.userprofile; import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.assertTrue; import static org.keycloak.userprofile.DeclarativeUserProfileProvider.REALM_USER_PROFILE_ENABLED; import static org.keycloak.userprofile.config.UPConfigUtils.readDefaultConfig; import java.io.IOException; import java.util.HashMap; +import java.util.List; +import java.util.Map; import org.junit.Test; +import org.keycloak.admin.client.resource.RealmResource; import org.keycloak.admin.client.resource.UserProfileResource; import org.keycloak.common.Profile; +import org.keycloak.models.UserModel; import org.keycloak.representations.idm.RealmRepresentation; +import org.keycloak.representations.idm.UserProfileAttributeGroupMetadata; +import org.keycloak.representations.idm.UserProfileMetadata; import org.keycloak.testsuite.admin.AbstractAdminTest; import org.keycloak.testsuite.arquillian.annotation.EnableFeature; +import org.keycloak.userprofile.config.UPAttribute; +import org.keycloak.userprofile.config.UPConfig; +import org.keycloak.userprofile.config.UPGroup; +import org.keycloak.util.JsonSerialization; /** * @author Pedro Igor @@ -53,12 +66,111 @@ public class UserProfileAdminTest extends AbstractAdminTest { } @Test - public void testSetDefaultConfig() throws IOException { + public void testSetDefaultConfig() { String rawConfig = "{\"attributes\": [{\"name\": \"test\"}]}"; UserProfileResource userProfile = testRealm().users().userProfile(); - userProfile.update(rawConfig); + getCleanup().addCleanup(() -> testRealm().users().userProfile().update(null)); assertEquals(rawConfig, userProfile.getConfiguration()); } + + @Test + public void testEmailRequiredIfEmailAsUsernameEnabled() { + RealmResource realm = testRealm(); + RealmRepresentation realmRep = realm.toRepresentation(); + Boolean registrationEmailAsUsername = realmRep.isRegistrationEmailAsUsername(); + realmRep.setRegistrationEmailAsUsername(true); + realm.update(realmRep); + getCleanup().addCleanup(() -> { + realmRep.setRegistrationEmailAsUsername(registrationEmailAsUsername); + realm.update(realmRep); + }); + UserProfileResource userProfile = realm.users().userProfile(); + UserProfileMetadata metadata = userProfile.getMetadata(); + assertTrue(metadata.getAttributeMetadata(UserModel.EMAIL).isRequired()); + } + + @Test + public void testEmailNotRequiredIfEmailAsUsernameDisabled() { + RealmResource realm = testRealm(); + RealmRepresentation realmRep = realm.toRepresentation(); + Boolean registrationEmailAsUsername = realmRep.isRegistrationEmailAsUsername(); + realmRep.setRegistrationEmailAsUsername(false); + realm.update(realmRep); + getCleanup().addCleanup(() -> { + realmRep.setRegistrationEmailAsUsername(registrationEmailAsUsername); + realm.update(realmRep); + }); + UserProfileResource userProfile = realm.users().userProfile(); + UserProfileMetadata metadata = userProfile.getMetadata(); + assertFalse(metadata.getAttributeMetadata(UserModel.EMAIL).isRequired()); + } + + @Test + public void testUsernameRequiredIfEmailAsUsernameDisabled() { + RealmResource realm = testRealm(); + RealmRepresentation realmRep = realm.toRepresentation(); + Boolean registrationEmailAsUsername = realmRep.isRegistrationEmailAsUsername(); + realmRep.setRegistrationEmailAsUsername(false); + realm.update(realmRep); + getCleanup().addCleanup(() -> { + realmRep.setRegistrationEmailAsUsername(registrationEmailAsUsername); + realm.update(realmRep); + }); + UserProfileResource userProfile = realm.users().userProfile(); + UserProfileMetadata metadata = userProfile.getMetadata(); + assertTrue(metadata.getAttributeMetadata(UserModel.USERNAME).isRequired()); + } + + @Test + public void testUsernameNotRequiredIfEmailAsUsernameEnabled() { + RealmResource realm = testRealm(); + RealmRepresentation realmRep = realm.toRepresentation(); + Boolean registrationEmailAsUsername = realmRep.isRegistrationEmailAsUsername(); + realmRep.setRegistrationEmailAsUsername(true); + realm.update(realmRep); + getCleanup().addCleanup(() -> { + realmRep.setRegistrationEmailAsUsername(registrationEmailAsUsername); + realm.update(realmRep); + }); + UserProfileResource userProfile = realm.users().userProfile(); + UserProfileMetadata metadata = userProfile.getMetadata(); + assertFalse(metadata.getAttributeMetadata(UserModel.USERNAME).isRequired()); + } + + @Test + public void testGroupsMetadata() throws IOException { + UPConfig config = JsonSerialization.readValue(testRealm().users().userProfile().getConfiguration(), UPConfig.class); + + for (int i = 0; i < 3; i++) { + UPGroup group = new UPGroup(); + group.setName("name-" + i); + group.setDisplayHeader("displayHeader-" + i); + group.setDisplayDescription("displayDescription-" + i); + group.setAnnotations(Map.of("k1", "v1", "k2", "v2", "k3", "v3")); + config.addGroup(group); + } + + UPAttribute firstName = config.getAttribute(UserModel.FIRST_NAME); + firstName.setGroup(config.getGroups().get(0).getName()); + UserProfileResource userProfile = testRealm().users().userProfile(); + userProfile.update(JsonSerialization.writeValueAsString(config)); + getCleanup().addCleanup(() -> testRealm().users().userProfile().update(null)); + + UserProfileMetadata metadata = testRealm().users().userProfile().getMetadata(); + List groups = metadata.getGroups(); + assertNotNull(groups); + assertFalse(groups.isEmpty()); + assertEquals(config.getGroups().size(), groups.size()); + for (UPGroup group : config.getGroups()) { + UserProfileAttributeGroupMetadata mGroup = metadata.getAttributeGroupMetadata(group.getName()); + assertNotNull(mGroup); + assertEquals(group.getName(), mGroup.getName()); + assertEquals(group.getDisplayHeader(), mGroup.getDisplayHeader()); + assertEquals(group.getDisplayDescription(), mGroup.getDisplayDescription()); + assertEquals(group.getAnnotations().size(), mGroup.getAnnotations().size()); + } + assertEquals(config.getGroups().get(0).getName(), metadata.getAttributeMetadata(UserModel.FIRST_NAME).getGroup()); + } } diff --git a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/user/profile/UserProfileTest.java b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/user/profile/UserProfileTest.java index 7ffc0efe1d..c794e84b05 100644 --- a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/user/profile/UserProfileTest.java +++ b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/user/profile/UserProfileTest.java @@ -1314,6 +1314,76 @@ public class UserProfileTest extends AbstractUserProfileTest { profile.validate(); } + @Test + public void testIgnoreReadOnlyAttribute() { + getTestingClient().server(TEST_REALM_NAME).run((RunOnServer) UserProfileTest::testIgnoreReadOnlyAttribute); + } + + private static void testIgnoreReadOnlyAttribute(KeycloakSession session) throws IOException { + DeclarativeUserProfileProvider provider = getDynamicUserProfileProvider(session); + UPConfig config = new UPConfig(); + UPAttribute firstName = new UPAttribute(); + + firstName.setName(UserModel.FIRST_NAME); + + UPAttribute address = new UPAttribute(); + + address.setName(ATT_ADDRESS); + + UPAttributeRequired requirements = new UPAttributeRequired(); + requirements.setRoles(Collections.singleton(UPConfigUtils.ROLE_USER)); + address.setRequired(requirements); + firstName.setRequired(requirements); + + UPAttributePermissions permissions = new UPAttributePermissions(); + permissions.setEdit(Collections.singleton(UPConfigUtils.ROLE_USER)); + permissions.setView(Collections.singleton(ROLE_ADMIN)); + address.setPermissions(permissions); + firstName.setPermissions(permissions); + + config.addAttribute(address); + config.addAttribute(firstName); + + provider.setConfiguration(JsonSerialization.writeValueAsString(config)); + + Map attributes = new HashMap<>(); + + attributes.put(UserModel.USERNAME, org.keycloak.models.utils.KeycloakModelUtils.generateId()); + + // Fails on USER context + UserProfile profile = provider.create(UserProfileContext.UPDATE_PROFILE, attributes); + try { + profile.validate(); + fail("Should fail validation"); + } catch (ValidationException ve) { + assertTrue(ve.isAttributeOnError(ATT_ADDRESS)); + } + + // attribute ignored for admin when not provided and creating user + profile = provider.create(UserProfileContext.USER_API, attributes); + profile.validate(); + + // attribute ignored for admin when empty and creating user + attributes.put(ATT_ADDRESS, List.of("")); + attributes.put(UserModel.FIRST_NAME, List.of("")); + profile = provider.create(UserProfileContext.USER_API, attributes); + UserModel user = profile.create(); + + // attribute ignored for admin when empty and updating user + profile = provider.create(UserProfileContext.USER_API, attributes, user); + profile.validate(); + + // attribute not ignored for admin when empty and updating user + user.setFirstName("alice"); + profile = provider.create(UserProfileContext.USER_API, attributes, user); + try { + profile.validate(); + fail("Should fail validation"); + } catch (ValidationException ve) { + assertTrue(ve.isAttributeOnError(UserModel.FIRST_NAME)); + } + } + @Test public void testReadOnlyInternalAttributeValidation() { getTestingClient().server(TEST_REALM_NAME).run((RunOnServer) UserProfileTest::testReadOnlyInternalAttributeValidation);