Resolve several usability issues around User Profile (#23537)

Closes #23507, #23584, #23740, #23774

Co-authored-by: Jon Koops <jonkoops@gmail.com>
This commit is contained in:
Pedro Igor 2023-10-06 10:15:39 -03:00 committed by GitHub
parent 890600c33c
commit 290bee0787
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
49 changed files with 1067 additions and 488 deletions

View file

@ -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<String, Object> annotations;
public UserProfileAttributeGroupMetadata() {
}
public UserProfileAttributeGroupMetadata(String name, String displayHeader, String displayDescription, Map<String, Object> 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<String, Object> getAnnotations() {
return annotations;
}
}

View file

@ -29,12 +29,13 @@ public class UserProfileAttributeMetadata {
private boolean readOnly; private boolean readOnly;
private Map<String, Object> annotations; private Map<String, Object> annotations;
private Map<String, Map<String, Object>> validators; private Map<String, Map<String, Object>> validators;
private String group;
public UserProfileAttributeMetadata() { public UserProfileAttributeMetadata() {
} }
public UserProfileAttributeMetadata(String name, String displayName, boolean required, boolean readOnly, Map<String, Object> annotations, public UserProfileAttributeMetadata(String name, String displayName, boolean required, boolean readOnly, String group, Map<String, Object> annotations,
Map<String, Map<String, Object>> validators) { Map<String, Map<String, Object>> validators) {
this.name = name; this.name = name;
this.displayName = displayName; this.displayName = displayName;
@ -42,6 +43,7 @@ public class UserProfileAttributeMetadata {
this.readOnly = readOnly; this.readOnly = readOnly;
this.annotations = annotations; this.annotations = annotations;
this.validators = validators; this.validators = validators;
this.group = group;
} }
public String getName() { public String getName() {
@ -63,6 +65,10 @@ public class UserProfileAttributeMetadata {
return readOnly; return readOnly;
} }
public String getGroup() {
return group;
}
/** /**
* Get info about attribute annotations loaded from UserProfile configuration. * Get info about attribute annotations loaded from UserProfile configuration.
*/ */

View file

@ -16,7 +16,10 @@
*/ */
package org.keycloak.representations.idm; package org.keycloak.representations.idm;
import static java.util.Collections.emptyList;
import java.util.List; import java.util.List;
import java.util.Optional;
/** /**
* @author Vlastimil Elias <velias@redhat.com> * @author Vlastimil Elias <velias@redhat.com>
@ -24,22 +27,47 @@ import java.util.List;
public class UserProfileMetadata { public class UserProfileMetadata {
private List<UserProfileAttributeMetadata> attributes; private List<UserProfileAttributeMetadata> attributes;
private List<UserProfileAttributeGroupMetadata> groups;
public UserProfileMetadata() { public UserProfileMetadata() {
} }
public UserProfileMetadata(List<UserProfileAttributeMetadata> attributes) { public UserProfileMetadata(List<UserProfileAttributeMetadata> attributes, List<UserProfileAttributeGroupMetadata> groups) {
super(); super();
this.attributes = attributes; this.attributes = attributes;
this.groups = groups;
} }
public List<UserProfileAttributeMetadata> getAttributes() { public List<UserProfileAttributeMetadata> getAttributes() {
return attributes; return attributes;
} }
public List<UserProfileAttributeGroupMetadata> getGroups() {
return groups;
}
public void setAttributes(List<UserProfileAttributeMetadata> attributes) { public void setAttributes(List<UserProfileAttributeMetadata> attributes) {
this.attributes = 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;
}
} }

View file

@ -19,10 +19,13 @@ package org.keycloak.admin.client.resource;
import javax.ws.rs.Consumes; import javax.ws.rs.Consumes;
import javax.ws.rs.GET; import javax.ws.rs.GET;
import javax.ws.rs.PUT; import javax.ws.rs.PUT;
import javax.ws.rs.Path;
import javax.ws.rs.Produces; import javax.ws.rs.Produces;
import javax.ws.rs.core.MediaType; import javax.ws.rs.core.MediaType;
import javax.ws.rs.core.Response; import javax.ws.rs.core.Response;
import org.keycloak.representations.idm.UserProfileMetadata;
/** /**
* @author Vlastimil Elias <velias@redhat.com> * @author Vlastimil Elias <velias@redhat.com>
*/ */
@ -34,6 +37,11 @@ public interface UserProfileResource {
@Consumes(MediaType.APPLICATION_JSON) @Consumes(MediaType.APPLICATION_JSON)
String getConfiguration(); String getConfiguration();
@GET
@Path("/metadata")
@Consumes(jakarta.ws.rs.core.MediaType.APPLICATION_JSON)
UserProfileMetadata getMetadata();
@PUT @PUT
@Produces(MediaType.APPLICATION_JSON) @Produces(MediaType.APPLICATION_JSON)
Response update(String text); Response update(String text);

View file

@ -1,6 +1,6 @@
import KeycloakAdminClient from "@keycloak/keycloak-admin-client"; import KeycloakAdminClient from "@keycloak/keycloak-admin-client";
import RealmRepresentation from "@keycloak/keycloak-admin-client/lib/defs/realmRepresentation"; 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"; import UserRepresentation from "@keycloak/keycloak-admin-client/lib/defs/userRepresentation";
const adminClient = new KeycloakAdminClient({ const adminClient = new KeycloakAdminClient({

View file

@ -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 { expect, test } from "@playwright/test";
import { import {
enableLocalization,
importUserProfile,
createUser, createUser,
deleteUser, deleteUser,
enableLocalization,
importUserProfile,
} from "../admin-client"; } from "../admin-client";
import { login } from "../login"; import { login } from "../login";
import userProfileConfig from "./user-profile.json" assert { type: "json" }; import userProfileConfig from "./user-profile.json" assert { type: "json" };

View file

@ -1,9 +1,9 @@
import RealmRepresentation from "@keycloak/keycloak-admin-client/lib/defs/realmRepresentation";
import LoginPage from "../support/pages/LoginPage"; import LoginPage from "../support/pages/LoginPage";
import SidebarPage from "../support/pages/admin-ui/SidebarPage"; 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 adminClient from "../support/util/AdminClient";
import { keycloakBefore } from "../support/util/keycloak_hooks"; 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 loginPage = new LoginPage();
const sidebarPage = new SidebarPage(); const sidebarPage = new SidebarPage();

View file

@ -1,11 +1,11 @@
import KeycloakAdminClient from "@keycloak/keycloak-admin-client"; 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 ClientRepresentation from "@keycloak/keycloak-admin-client/lib/defs/clientRepresentation";
import type ClientScopeRepresentation from "@keycloak/keycloak-admin-client/lib/defs/clientScopeRepresentation"; import type ClientScopeRepresentation from "@keycloak/keycloak-admin-client/lib/defs/clientScopeRepresentation";
import type RealmRepresentation from "@keycloak/keycloak-admin-client/lib/defs/realmRepresentation"; 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 RoleRepresentation from "@keycloak/keycloak-admin-client/lib/defs/roleRepresentation";
import type { RoleMappingPayload } 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"; import { merge } from "lodash-es";
class AdminClient { class AdminClient {

View file

@ -1,6 +1,6 @@
import type ComponentRepresentation from "@keycloak/keycloak-admin-client/lib/defs/componentRepresentation"; import type ComponentRepresentation from "@keycloak/keycloak-admin-client/lib/defs/componentRepresentation";
import type RealmRepresentation from "@keycloak/keycloak-admin-client/lib/defs/realmRepresentation"; 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 type UserRepresentation from "@keycloak/keycloak-admin-client/lib/defs/userRepresentation";
import { import {
AlertVariant, AlertVariant,
@ -29,19 +29,19 @@ import { useTranslation } from "react-i18next";
import { Link, useNavigate } from "react-router-dom"; import { Link, useNavigate } from "react-router-dom";
import { adminClient } from "../../admin-client"; 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 { useAlerts } from "../alert/Alerts";
import { useConfirmDialog } from "../confirm-dialog/ConfirmDialog"; import { useConfirmDialog } from "../confirm-dialog/ConfirmDialog";
import { KeycloakSpinner } from "../keycloak-spinner/KeycloakSpinner"; import { KeycloakSpinner } from "../keycloak-spinner/KeycloakSpinner";
import { ListEmptyState } from "../list-empty-state/ListEmptyState"; import { ListEmptyState } from "../list-empty-state/ListEmptyState";
import { BruteUser, findUsers } from "../role-mapping/resource"; import { BruteUser, findUsers } from "../role-mapping/resource";
import { KeycloakDataTable } from "../table-toolbar/KeycloakDataTable"; 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 { UserDataTableToolbarItems } from "./UserDataTableToolbarItems";
import { SearchType } from "../../user/details/SearchFilter";
export type UserAttribute = { export type UserAttribute = {
name: string; name: string;

View file

@ -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 { import {
ActionGroup, ActionGroup,
Alert, Alert,

View file

@ -1,5 +1,5 @@
import type RealmRepresentation from "@keycloak/keycloak-admin-client/lib/defs/realmRepresentation"; 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 { import {
Button, Button,
ButtonVariant, ButtonVariant,
@ -11,13 +11,14 @@ import {
SearchInput, SearchInput,
ToolbarItem, ToolbarItem,
} from "@patternfly/react-core"; } from "@patternfly/react-core";
import { ArrowRightIcon } from "@patternfly/react-icons";
import { ReactNode, useState } from "react"; import { ReactNode, useState } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { useAccess } from "../../context/access/Access"; import { useAccess } from "../../context/access/Access";
import { UserDataTableAttributeSearchForm } from "./UserDataTableAttributeSearchForm";
import { ArrowRightIcon } from "@patternfly/react-icons";
import { SearchDropdown, SearchType } from "../../user/details/SearchFilter"; import { SearchDropdown, SearchType } from "../../user/details/SearchFilter";
import { UserAttribute } from "./UserDataTable"; import { UserAttribute } from "./UserDataTable";
import { UserDataTableAttributeSearchForm } from "./UserDataTableAttributeSearchForm";
type UserDataTableToolbarItemsProps = { type UserDataTableToolbarItemsProps = {
realm: RealmRepresentation; realm: RealmRepresentation;

View file

@ -1,5 +1,7 @@
import type UserProfileConfig from "@keycloak/keycloak-admin-client/lib/defs/userProfileConfig"; import type {
import type { UserProfileAttribute } from "@keycloak/keycloak-admin-client/lib/defs/userProfileConfig"; UserProfileAttribute,
UserProfileConfig,
} from "@keycloak/keycloak-admin-client/lib/defs/userProfileConfig";
import { import {
AlertVariant, AlertVariant,
Button, Button,

View file

@ -1,5 +1,4 @@
import { useState } from "react"; import type { UserProfileAttribute } from "@keycloak/keycloak-admin-client/lib/defs/userProfileConfig";
import { useTranslation } from "react-i18next";
import { import {
Button, Button,
ButtonVariant, ButtonVariant,
@ -12,17 +11,18 @@ import {
ToolbarItem, ToolbarItem,
} from "@patternfly/react-core"; } from "@patternfly/react-core";
import { FilterIcon } from "@patternfly/react-icons"; import { FilterIcon } from "@patternfly/react-icons";
import { useState } from "react";
import { KeycloakSpinner } from "../../components/keycloak-spinner/KeycloakSpinner"; import { useTranslation } from "react-i18next";
import { DraggableTable } from "../../authentication/components/DraggableTable";
import { Link, useNavigate } from "react-router-dom"; import { Link, useNavigate } from "react-router-dom";
import { toAddAttribute } from "../routes/AddAttribute";
import { useRealm } from "../../context/realm-context/RealmContext"; import { DraggableTable } from "../../authentication/components/DraggableTable";
import { useUserProfile } from "./UserProfileContext";
import { useConfirmDialog } from "../../components/confirm-dialog/ConfirmDialog"; import { useConfirmDialog } from "../../components/confirm-dialog/ConfirmDialog";
import { toAttribute } from "../routes/Attribute"; import { KeycloakSpinner } from "../../components/keycloak-spinner/KeycloakSpinner";
import type { UserProfileAttribute } from "@keycloak/keycloak-admin-client/lib/defs/userProfileConfig"; import { useRealm } from "../../context/realm-context/RealmContext";
import useToggle from "../../utils/useToggle"; import useToggle from "../../utils/useToggle";
import { toAddAttribute } from "../routes/AddAttribute";
import { toAttribute } from "../routes/Attribute";
import { useUserProfile } from "./UserProfileContext";
const RESTRICTED_ATTRIBUTES = ["username", "email"]; const RESTRICTED_ATTRIBUTES = ["username", "email"];

View file

@ -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 { AlertVariant } from "@patternfly/react-core";
import { PropsWithChildren, useState } from "react"; import { PropsWithChildren, useState } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";

View file

@ -1,5 +1,5 @@
import type ClientScopeRepresentation from "@keycloak/keycloak-admin-client/lib/defs/clientScopeRepresentation"; 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 { import {
Divider, Divider,
FormGroup, FormGroup,

View file

@ -1,15 +1,19 @@
import type GroupRepresentation from "@keycloak/keycloak-admin-client/lib/defs/groupRepresentation"; 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 { AlertVariant, PageSection } from "@patternfly/react-core";
import { useState } from "react"; import { useState } from "react";
import { FormProvider, useForm } from "react-hook-form"; import { useForm } from "react-hook-form";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { useNavigate } from "react-router-dom"; import { useNavigate } from "react-router-dom";
import { adminClient } from "../admin-client"; import { adminClient } from "../admin-client";
import { useAlerts } from "../components/alert/Alerts"; import { useAlerts } from "../components/alert/Alerts";
import { KeycloakSpinner } from "../components/keycloak-spinner/KeycloakSpinner";
import { ViewHeader } from "../components/view-header/ViewHeader"; import { ViewHeader } from "../components/view-header/ViewHeader";
import { useRealm } from "../context/realm-context/RealmContext"; 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 { UserForm } from "./UserForm";
import { import {
isUserProfileError, isUserProfileError,
@ -19,23 +23,40 @@ import { UserFormFields, toUserRepresentation } from "./form-state";
import { toUser } from "./routes/User"; import { toUser } from "./routes/User";
import "./user-section.css"; import "./user-section.css";
import RealmRepresentation from "@keycloak/keycloak-admin-client/lib/defs/realmRepresentation";
import { useFetch } from "../utils/useFetch";
export default function CreateUser() { export default function CreateUser() {
const { t } = useTranslation(); const { t } = useTranslation();
const { addAlert, addError } = useAlerts(); const { addAlert, addError } = useAlerts();
const navigate = useNavigate(); const navigate = useNavigate();
const { realm } = useRealm(); const { realm: realmName } = useRealm();
const userForm = useForm<UserFormFields>({ mode: "onChange" }); const isFeatureEnabled = useIsFeatureEnabled();
const form = useForm<UserFormFields>({ mode: "onChange" });
const [addedGroups, setAddedGroups] = useState<GroupRepresentation[]>([]); const [addedGroups, setAddedGroups] = useState<GroupRepresentation[]>([]);
const [realm, setRealm] = useState<RealmRepresentation>();
const [realmRepresentation, setRealmRepresentation] = const [userProfileMetadata, setUserProfileMetadata] =
useState<RealmRepresentation>(); useState<UserProfileMetadata>();
useFetch( 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); addAlert(t("userCreated"), AlertVariant.success);
navigate(toUser({ id: createdUser.id, realm, tab: "settings" })); navigate(
toUser({ id: createdUser.id, realm: realmName, tab: "settings" }),
);
} catch (error) { } catch (error) {
if (isUserProfileError(error)) { if (isUserProfileError(error)) {
addError(userProfileErrorToString(error), error); addError(userProfileErrorToString(error), error);
@ -58,24 +81,24 @@ export default function CreateUser() {
} }
}; };
if (!realm) {
return <KeycloakSpinner />;
}
return ( return (
<> <>
<ViewHeader <ViewHeader
titleKey={t("createUser")} titleKey={t("createUser")}
className="kc-username-view-header" className="kc-username-view-header"
/> />
<PageSection variant="light" className="pf-u-p-0"> <PageSection variant="light">
<UserProfileProvider> <UserForm
<FormProvider {...userForm}> form={form}
<PageSection variant="light"> realm={realm}
<UserForm userProfileMetadata={userProfileMetadata}
realm={realmRepresentation} onGroupsUpdate={setAddedGroups}
onGroupsUpdate={setAddedGroups} save={save}
save={save} />
/>
</PageSection>
</FormProvider>
</UserProfileProvider>
</PageSection> </PageSection>
</> </>
); );

View file

@ -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 RealmRepresentation from "@keycloak/keycloak-admin-client/lib/defs/realmRepresentation";
import type UserRepresentation from "@keycloak/keycloak-admin-client/lib/defs/userRepresentation"; import type UserRepresentation from "@keycloak/keycloak-admin-client/lib/defs/userRepresentation";
import { import {
@ -12,6 +13,7 @@ import { useState } from "react";
import { FormProvider, useForm } from "react-hook-form"; import { FormProvider, useForm } from "react-hook-form";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { useNavigate } from "react-router-dom"; import { useNavigate } from "react-router-dom";
import { adminClient } from "../admin-client"; import { adminClient } from "../admin-client";
import { useAlerts } from "../components/alert/Alerts"; import { useAlerts } from "../components/alert/Alerts";
import { useConfirmDialog } from "../components/confirm-dialog/ConfirmDialog"; import { useConfirmDialog } from "../components/confirm-dialog/ConfirmDialog";
@ -50,27 +52,25 @@ import { toUsers } from "./routes/Users";
import "./user-section.css"; import "./user-section.css";
export default function EditUser() { export default function EditUser() {
const { realm } = useRealm();
const { id } = useParams<UserParams>();
const { t } = useTranslation(); const { t } = useTranslation();
const [user, setUser] = useState<UserRepresentation>();
const [bruteForced, setBruteForced] = useState<BruteForced>();
const [refreshCount, setRefreshCount] = useState(0);
const refresh = () => setRefreshCount((count) => count + 1);
const [isUserProfileEnabled, setIsUserProfileEnabled] = useState(false);
const [realmRepresentation, setRealmRepresentation] =
useState<RealmRepresentation>();
const isFeatureEnabled = useIsFeatureEnabled();
const { addAlert, addError } = useAlerts(); const { addAlert, addError } = useAlerts();
const navigate = useNavigate(); const navigate = useNavigate();
const { hasAccess } = useAccess(); const { hasAccess } = useAccess();
const userForm = useForm<UserFormFields>({ const { id } = useParams<UserParams>();
mode: "onChange", const { realm: realmName } = useRealm();
}); const isFeatureEnabled = useIsFeatureEnabled();
const form = useForm<UserFormFields>({ mode: "onChange" });
const [realm, setRealm] = useState<RealmRepresentation>();
const [user, setUser] = useState<UserRepresentation>();
const [bruteForced, setBruteForced] = useState<BruteForced>();
const [userProfileMetadata, setUserProfileMetadata] =
useState<UserProfileMetadata>();
const [refreshCount, setRefreshCount] = useState(0);
const refresh = () => setRefreshCount((count) => count + 1);
const toTab = (tab: UserTab) => const toTab = (tab: UserTab) =>
toUser({ toUser({
realm, realm: realmName,
id: user?.id || "", id: user?.id || "",
tab, tab,
}); });
@ -87,35 +87,34 @@ export default function EditUser() {
const sessionsTab = useTab("sessions"); const sessionsTab = useTab("sessions");
useFetch( useFetch(
async () => { async () =>
const [user, currentRealm, attackDetection] = await Promise.all([ Promise.all([
adminClient.realms.findOne({ realm: realmName }),
adminClient.users.findOne({ id: id!, userProfileMetadata: true }), adminClient.users.findOne({ id: id!, userProfileMetadata: true }),
adminClient.realms.findOne({ realm }),
adminClient.attackDetection.findOne({ id: id! }), adminClient.attackDetection.findOne({ id: id! }),
]); ]),
([realm, user, attackDetection]) => {
if (!user || !currentRealm || !attackDetection) { if (!user || !realm || !attackDetection) {
throw new Error(t("notFound")); throw new Error(t("notFound"));
} }
const isBruteForceProtected = currentRealm.bruteForceProtected; setRealm(realm);
setUser(user);
const isBruteForceProtected = realm.bruteForceProtected;
const isLocked = isBruteForceProtected && attackDetection.disabled; const isLocked = isBruteForceProtected && attackDetection.disabled;
return { setBruteForced({ isBruteForceProtected, isLocked });
user,
bruteForced: { isBruteForceProtected, isLocked },
currentRealm,
};
},
({ user, bruteForced, currentRealm }) => {
setUser(user);
const isUserProfileEnabled = const isUserProfileEnabled =
isFeatureEnabled(Feature.DeclarativeUserProfile) && isFeatureEnabled(Feature.DeclarativeUserProfile) &&
currentRealm.attributes?.userProfileEnabled === "true"; realm.attributes?.userProfileEnabled === "true";
userForm.reset(isUserProfileEnabled ? user : toUserFormFields(user));
setIsUserProfileEnabled(isUserProfileEnabled); setUserProfileMetadata(
setRealmRepresentation(currentRealm); isUserProfileEnabled ? user.userProfileMetadata : undefined,
setBruteForced(bruteForced); );
form.reset(toUserFormFields(user, isUserProfileEnabled));
}, },
[refreshCount], [refreshCount],
); );
@ -146,7 +145,7 @@ export default function EditUser() {
try { try {
await adminClient.users.del({ id: user!.id! }); await adminClient.users.del({ id: user!.id! });
addAlert(t("userDeletedSuccess"), AlertVariant.success); addAlert(t("userDeletedSuccess"), AlertVariant.success);
navigate(toUsers({ realm })); navigate(toUsers({ realm: realmName }));
} catch (error) { } catch (error) {
addError("userDeletedError", error); addError("userDeletedError", error);
} }
@ -161,7 +160,7 @@ export default function EditUser() {
try { try {
const data = await adminClient.users.impersonation( const data = await adminClient.users.impersonation(
{ id: user!.id! }, { id: user!.id! },
{ user: user!.id!, realm }, { user: user!.id!, realm: realmName },
); );
if (data.sameRealm) { if (data.sameRealm) {
window.location = data.redirect; window.location = data.redirect;
@ -174,7 +173,7 @@ export default function EditUser() {
}, },
}); });
if (!user || !bruteForced) { if (!realm || !user || !bruteForced) {
return <KeycloakSpinner />; return <KeycloakSpinner />;
} }
@ -203,14 +202,17 @@ export default function EditUser() {
</DropdownItem>, </DropdownItem>,
]} ]}
onToggle={(value) => onToggle={(value) =>
save({ ...toUserFormFields(user), enabled: value }) save({
...toUserFormFields(user, !!userProfileMetadata),
enabled: value,
})
} }
isEnabled={user.enabled} isEnabled={user.enabled}
/> />
<PageSection variant="light" className="pf-u-p-0"> <PageSection variant="light" className="pf-u-p-0">
<UserProfileProvider> <UserProfileProvider>
<FormProvider {...userForm}> <FormProvider {...form}>
<RoutableTabs <RoutableTabs
isBox isBox
mountOnEnter mountOnEnter
@ -223,14 +225,16 @@ export default function EditUser() {
> >
<PageSection variant="light"> <PageSection variant="light">
<UserForm <UserForm
save={save} form={form}
realm={realm}
user={user} user={user}
bruteForce={bruteForced} bruteForce={bruteForced}
realm={realmRepresentation} userProfileMetadata={userProfileMetadata}
save={save}
/> />
</PageSection> </PageSection>
</Tab> </Tab>
{!isUserProfileEnabled && ( {!userProfileMetadata && (
<Tab <Tab
data-testid="attributes" data-testid="attributes"
title={<TabTitleText>{t("attributes")}</TabTitleText>} title={<TabTitleText>{t("attributes")}</TabTitleText>}

View file

@ -1,8 +1,11 @@
import type UserRepresentation from "@keycloak/keycloak-admin-client/lib/defs/userRepresentation"; import type UserRepresentation from "@keycloak/keycloak-admin-client/lib/defs/userRepresentation";
import { PageSection, PageSectionVariants } from "@patternfly/react-core"; 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"; import { UserFormFields, toUserFormFields } from "./form-state";
type UserAttributesProps = { type UserAttributesProps = {
@ -16,13 +19,13 @@ export const UserAttributes = ({ user, save }: UserAttributesProps) => {
return ( return (
<PageSection variant={PageSectionVariants.light}> <PageSection variant={PageSectionVariants.light}>
<AttributesForm <AttributesForm
form={form} form={form as UseFormReturn<AttributeForm>}
save={save} save={save}
fineGrainedAccess={user.access?.manage} fineGrainedAccess={user.access?.manage}
reset={() => reset={() =>
form.reset({ form.reset({
...form.getValues(), ...form.getValues(),
attributes: toUserFormFields(user).attributes, attributes: toUserFormFields(user, false).attributes,
}) })
} }
/> />

View file

@ -1,5 +1,6 @@
import type GroupRepresentation from "@keycloak/keycloak-admin-client/lib/defs/groupRepresentation"; import type GroupRepresentation from "@keycloak/keycloak-admin-client/lib/defs/groupRepresentation";
import type RealmRepresentation from "@keycloak/keycloak-admin-client/lib/defs/realmRepresentation"; 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 type UserRepresentation from "@keycloak/keycloak-admin-client/lib/defs/userRepresentation";
import { import {
ActionGroup, ActionGroup,
@ -12,9 +13,9 @@ import {
Switch, Switch,
} from "@patternfly/react-core"; } from "@patternfly/react-core";
import { useState } from "react"; import { useState } from "react";
import { Controller, useFormContext } from "react-hook-form"; import { Controller, UseFormReturn } from "react-hook-form";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { useNavigate } from "react-router-dom"; import { Link } from "react-router-dom";
import { HelpItem } from "ui-shared"; import { HelpItem } from "ui-shared";
import { adminClient } from "../admin-client"; import { adminClient } from "../admin-client";
@ -23,13 +24,12 @@ import { FormAccess } from "../components/form/FormAccess";
import { GroupPickerDialog } from "../components/group/GroupPickerDialog"; import { GroupPickerDialog } from "../components/group/GroupPickerDialog";
import { KeycloakTextInput } from "../components/keycloak-text-input/KeycloakTextInput"; import { KeycloakTextInput } from "../components/keycloak-text-input/KeycloakTextInput";
import { useAccess } from "../context/access/Access"; import { useAccess } from "../context/access/Access";
import { useRealm } from "../context/realm-context/RealmContext";
import { emailRegexPattern } from "../util"; import { emailRegexPattern } from "../util";
import useFormatDate from "../utils/useFormatDate"; import useFormatDate from "../utils/useFormatDate";
import useIsFeatureEnabled, { Feature } from "../utils/useIsFeatureEnabled";
import { FederatedUserLink } from "./FederatedUserLink"; import { FederatedUserLink } from "./FederatedUserLink";
import { UserProfileFields } from "./UserProfileFields"; 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"; import { RequiredActionMultiSelect } from "./user-credentials/RequiredActionMultiSelect";
export type BruteForced = { export type BruteForced = {
@ -38,63 +38,29 @@ export type BruteForced = {
}; };
export type UserFormProps = { export type UserFormProps = {
form: UseFormReturn<UserFormFields>;
realm: RealmRepresentation;
user?: UserRepresentation; user?: UserRepresentation;
bruteForce?: BruteForced; bruteForce?: BruteForced;
realm?: RealmRepresentation; userProfileMetadata?: UserProfileMetadata;
save: (user: UserFormFields) => void; save: (user: UserFormFields) => void;
onGroupsUpdate?: (groups: GroupRepresentation[]) => void; onGroupsUpdate?: (groups: GroupRepresentation[]) => void;
}; };
const EmailVerified = () => {
const { t } = useTranslation();
const { control } = useFormContext<UserFormFields>();
return (
<FormGroup
label={t("emailVerified")}
fieldId="kc-email-verified"
helperTextInvalid={t("required")}
labelIcon={
<HelpItem
helpText={t("emailVerifiedHelp")}
fieldLabelId="emailVerified"
/>
}
>
<Controller
name="emailVerified"
defaultValue={false}
control={control}
render={({ field }) => (
<Switch
data-testid="email-verified-switch"
id="kc-user-email-verified"
onChange={(value) => field.onChange(value)}
isChecked={field.value}
label={t("yes")}
labelOff={t("no")}
/>
)}
/>
</FormGroup>
);
};
export const UserForm = ({ export const UserForm = ({
user, form,
realm, realm,
user,
bruteForce: { isBruteForceProtected, isLocked } = { bruteForce: { isBruteForceProtected, isLocked } = {
isBruteForceProtected: false, isBruteForceProtected: false,
isLocked: false, isLocked: false,
}, },
userProfileMetadata,
save, save,
onGroupsUpdate, onGroupsUpdate,
}: UserFormProps) => { }: UserFormProps) => {
const { t } = useTranslation(); const { t } = useTranslation();
const { realm: realmName } = useRealm();
const formatDate = useFormatDate(); const formatDate = useFormatDate();
const isFeatureEnabled = useIsFeatureEnabled();
const navigate = useNavigate();
const { addAlert, addError } = useAlerts(); const { addAlert, addError } = useAlerts();
const { hasAccess } = useAccess(); const { hasAccess } = useAccess();
const isManager = hasAccess("manage-users"); const isManager = hasAccess("manage-users");
@ -107,7 +73,7 @@ export const UserForm = ({
control, control,
reset, reset,
formState: { errors }, formState: { errors },
} = useFormContext<UserFormFields>(); } = form;
const watchUsernameInput = watch("username"); const watchUsernameInput = watch("username");
const [selectedGroups, setSelectedGroups] = useState<GroupRepresentation[]>( const [selectedGroups, setSelectedGroups] = useState<GroupRepresentation[]>(
[], [],
@ -154,10 +120,6 @@ export const UserForm = ({
setOpen(!open); setOpen(!open);
}; };
const isUserProfileEnabled =
isFeatureEnabled(Feature.DeclarativeUserProfile) &&
realm?.attributes?.userProfileEnabled === "true";
return ( return (
<FormAccess <FormAccess
isHorizontal isHorizontal
@ -206,6 +168,7 @@ export const UserForm = ({
</> </>
)} )}
<RequiredActionMultiSelect <RequiredActionMultiSelect
control={control}
name="requiredActions" name="requiredActions"
label="requiredUserActions" label="requiredUserActions"
help="requiredUserActionsHelp" help="requiredUserActionsHelp"
@ -223,11 +186,15 @@ export const UserForm = ({
<FederatedUserLink user={user} /> <FederatedUserLink user={user} />
</FormGroup> </FormGroup>
)} )}
{isUserProfileEnabled && user?.userProfileMetadata ? ( {userProfileMetadata ? (
<UserProfileFields config={user.userProfileMetadata} /> <UserProfileFields
form={form}
userProfileMetadata={userProfileMetadata}
hideReadOnly={!user}
/>
) : ( ) : (
<> <>
{!realm?.registrationEmailAsUsername && ( {!realm.registrationEmailAsUsername && (
<FormGroup <FormGroup
label={t("username")} label={t("username")}
fieldId="kc-username" fieldId="kc-username"
@ -239,8 +206,8 @@ export const UserForm = ({
id="kc-username" id="kc-username"
isReadOnly={ isReadOnly={
!!user?.id && !!user?.id &&
!realm?.editUsernameAllowed && !realm.editUsernameAllowed &&
realm?.editUsernameAllowed !== undefined realm.editUsernameAllowed !== undefined
} }
{...register("username")} {...register("username")}
/> />
@ -261,7 +228,33 @@ export const UserForm = ({
})} })}
/> />
</FormGroup> </FormGroup>
<EmailVerified /> <FormGroup
label={t("emailVerified")}
fieldId="kc-email-verified"
helperTextInvalid={t("required")}
labelIcon={
<HelpItem
helpText={t("emailVerifiedHelp")}
fieldLabelId="emailVerified"
/>
}
>
<Controller
name="emailVerified"
defaultValue={false}
control={control}
render={({ field }) => (
<Switch
data-testid="email-verified-switch"
id="kc-user-email-verified"
onChange={(value) => field.onChange(value)}
isChecked={field.value}
label={t("yes")}
labelOff={t("no")}
/>
)}
/>
</FormGroup>
<FormGroup <FormGroup
label={t("firstName")} label={t("firstName")}
fieldId="kc-firstName" fieldId="kc-firstName"
@ -357,7 +350,7 @@ export const UserForm = ({
isDisabled={ isDisabled={
!user?.id && !user?.id &&
!watchUsernameInput && !watchUsernameInput &&
!realm?.registrationEmailAsUsername !realm.registrationEmailAsUsername
} }
variant="primary" variant="primary"
type="submit" type="submit"
@ -366,10 +359,19 @@ export const UserForm = ({
</Button> </Button>
<Button <Button
data-testid="cancel-create-user" data-testid="cancel-create-user"
onClick={() =>
user?.id ? reset(user) : navigate(`/${realmName}/users`)
}
variant="link" variant="link"
onClick={
user?.id
? () => reset(toUserFormFields(user, !!userProfileMetadata))
: undefined
}
component={
!user?.id
? (props) => (
<Link {...props} to={toUsers({ realm: realm.id! })} />
)
: undefined
}
> >
{user?.id ? t("revert") : t("cancel")} {user?.id ? t("revert") : t("cancel")}
</Button> </Button>

View file

@ -1,8 +1,11 @@
import type { UserProfileAttribute } from "@keycloak/keycloak-admin-client/lib/defs/userProfileConfig"; import type {
import UserProfileConfig from "@keycloak/keycloak-admin-client/lib/defs/userProfileConfig"; UserProfileAttributeGroupMetadata,
UserProfileAttributeMetadata,
UserProfileMetadata,
} from "@keycloak/keycloak-admin-client/lib/defs/userProfileMetadata";
import { Text } from "@patternfly/react-core"; import { Text } from "@patternfly/react-core";
import { Fragment } from "react"; import { useMemo } from "react";
import { useFormContext } from "react-hook-form"; import { FieldPath, UseFormReturn } from "react-hook-form";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { ScrollForm } from "../components/scroll-form/ScrollForm"; import { ScrollForm } from "../components/scroll-form/ScrollForm";
@ -10,19 +13,15 @@ import { OptionComponent } from "./components/OptionsComponent";
import { SelectComponent } from "./components/SelectComponent"; import { SelectComponent } from "./components/SelectComponent";
import { TextAreaComponent } from "./components/TextAreaComponent"; import { TextAreaComponent } from "./components/TextAreaComponent";
import { TextComponent } from "./components/TextComponent"; import { TextComponent } from "./components/TextComponent";
import { fieldName } from "./utils"; import { UserFormFields } from "./form-state";
import { fieldName, isRootAttribute } from "./utils";
type UserProfileFieldsProps = {
config: UserProfileConfig;
roles?: string[];
};
export type UserProfileError = { export type UserProfileError = {
responseData: { errors?: { errorMessage: string }[] }; responseData: { errors?: { errorMessage: string }[] };
}; };
export type Options = { export type Options = {
options: string[] | undefined; options?: string[];
}; };
export function isUserProfileError(error: unknown): error is UserProfileError { export function isUserProfileError(error: unknown): error is UserProfileError {
@ -35,7 +34,7 @@ export function userProfileErrorToString(error: UserProfileError) {
); );
} }
const FieldTypes = [ const INPUT_TYPES = [
"text", "text",
"textarea", "textarea",
"select", "select",
@ -53,10 +52,17 @@ const FieldTypes = [
"html5-time", "html5-time",
] as const; ] as const;
export type Field = (typeof FieldTypes)[number]; export type InputType = (typeof INPUT_TYPES)[number];
export type UserProfileFieldProps = {
form: UseFormReturn<UserFormFields>;
inputType: InputType;
attribute: UserProfileAttributeMetadata;
roles: string[];
};
export const FIELDS: { export const FIELDS: {
[index in Field]: (props: any) => JSX.Element; [type in InputType]: (props: UserProfileFieldProps) => JSX.Element;
} = { } = {
text: TextComponent, text: TextComponent,
textarea: TextAreaComponent, textarea: TextAreaComponent,
@ -75,51 +81,129 @@ export const FIELDS: {
"html5-time": TextComponent, "html5-time": TextComponent,
} as const; } as const;
export const isValidComponentType = (value: string): value is Field => export type UserProfileFieldsProps = {
value in FIELDS; form: UseFormReturn<UserFormFields>;
userProfileMetadata: UserProfileMetadata;
roles?: string[];
hideReadOnly?: boolean;
};
type GroupWithAttributes = {
group: UserProfileAttributeGroupMetadata;
attributes: UserProfileAttributeMetadata[];
};
export const UserProfileFields = ({ export const UserProfileFields = ({
config, form,
userProfileMetadata,
roles = ["admin"], roles = ["admin"],
hideReadOnly = false,
}: UserProfileFieldsProps) => { }: UserProfileFieldsProps) => {
const { t } = useTranslation(); 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<GroupWithAttributes>((group) => ({
group,
attributes: attributes.filter(
(attribute) => attribute.group === group.name,
),
}));
}, [
hideReadOnly,
userProfileMetadata.groups,
userProfileMetadata.attributes,
]);
if (groupsWithAttributes.length === 0) {
return null;
}
return ( return (
<ScrollForm <ScrollForm
sections={[{ name: "" }, ...(config.groups || [])].map((g) => ({ sections={groupsWithAttributes
title: g.displayHeader || g.name || t("general"), .filter((group) => group.attributes.length > 0)
panel: ( .map(({ group, attributes }) => ({
<div className="pf-c-form"> title: group.displayHeader || group.name || t("general"),
{g.displayDescription && ( panel: (
<Text className="pf-u-pb-lg">{g.displayDescription}</Text> <div className="pf-c-form">
)} {group.displayDescription && (
{config.attributes?.map((attribute) => ( <Text className="pf-u-pb-lg">{group.displayDescription}</Text>
<Fragment key={attribute.name}> )}
{(attribute.group || "") === g.name && ( {attributes.map((attribute) => (
<FormField attribute={attribute} roles={roles} /> <FormField
)} key={attribute.name}
</Fragment> form={form}
))} attribute={attribute}
</div> roles={roles}
), />
}))} ))}
</div>
),
}))}
/> />
); );
}; };
type FormFieldProps = { type FormFieldProps = {
attribute: UserProfileAttribute; form: UseFormReturn<UserFormFields>;
attribute: UserProfileAttributeMetadata;
roles: string[]; roles: string[];
}; };
const FormField = ({ attribute, roles }: FormFieldProps) => { const FormField = ({ form, attribute, roles }: FormFieldProps) => {
const { watch } = useFormContext(); const value = form.watch(fieldName(attribute) as FieldPath<UserFormFields>);
const value = watch(fieldName(attribute)); const inputType = determineInputType(attribute, value);
const Component = FIELDS[inputType];
const componentType = (attribute.annotations?.["inputType"] || return (
(Array.isArray(value) ? "multiselect" : "text")) as Field; <Component
form={form}
const Component = FIELDS[componentType]; inputType={inputType}
attribute={attribute}
return <Component {...{ ...attribute, roles }} />; roles={roles}
/>
);
}; };
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;

View file

@ -1,23 +1,27 @@
import { UserProfileAttribute } from "@keycloak/keycloak-admin-client/lib/defs/userProfileConfig";
import { Checkbox, Radio } from "@patternfly/react-core"; import { Checkbox, Radio } from "@patternfly/react-core";
import { Controller, useFormContext } from "react-hook-form"; import { Controller, FieldPath } from "react-hook-form";
import { UserProfileGroup } from "./UserProfileGroup"; import { isRequiredAttribute } from "../utils/user-profile";
import { Options } from "../UserProfileFields";
import { Options, UserProfileFieldProps } from "../UserProfileFields";
import { UserFormFields } from "../form-state";
import { fieldName } from "../utils"; import { fieldName } from "../utils";
import { UserProfileGroup } from "./UserProfileGroup";
export const OptionComponent = (attr: UserProfileAttribute) => { export const OptionComponent = ({
const { control } = useFormContext(); form,
const type = attr.annotations?.["inputType"] as string; inputType,
const isMultiSelect = type.includes("multiselect"); attribute,
}: UserProfileFieldProps) => {
const isRequired = isRequiredAttribute(attribute);
const isMultiSelect = inputType.startsWith("multiselect");
const Component = isMultiSelect ? Checkbox : Radio; const Component = isMultiSelect ? Checkbox : Radio;
const options = (attribute.validators?.options as Options).options || [];
const options = (attr.validators?.options as Options).options || [];
return ( return (
<UserProfileGroup {...attr}> <UserProfileGroup form={form} attribute={attribute}>
<Controller <Controller
name={fieldName(attr)} name={fieldName(attribute) as FieldPath<UserFormFields>}
control={control} control={form.control}
defaultValue="" defaultValue=""
render={({ field }) => ( render={({ field }) => (
<> <>
@ -42,7 +46,8 @@ export const OptionComponent = (attr: UserProfileAttribute) => {
field.onChange([option]); field.onChange([option]);
} }
}} }}
readOnly={attr.readOnly} readOnly={attribute.readOnly}
isRequired={isRequired}
/> />
))} ))}
</> </>

View file

@ -1,35 +1,30 @@
import { Select, SelectOption } from "@patternfly/react-core"; import { Select, SelectOption } from "@patternfly/react-core";
import { useState } from "react"; import { useState } from "react";
import { import { Controller, ControllerRenderProps, FieldPath } from "react-hook-form";
Controller,
useFormContext,
ControllerRenderProps,
FieldValues,
} from "react-hook-form";
import { useTranslation } from "react-i18next"; 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 { fieldName, unWrap } from "../utils";
import { UserProfileFieldsProps, UserProfileGroup } from "./UserProfileGroup"; import { UserProfileGroup } from "./UserProfileGroup";
import { isRequiredAttribute } from "../utils/user-profile";
type OptionLabel = Record<string, string> | undefined; type OptionLabel = Record<string, string> | undefined;
export const SelectComponent = (attribute: UserProfileFieldsProps) => { export const SelectComponent = ({
form,
inputType,
attribute,
}: UserProfileFieldProps) => {
const { t } = useTranslation(); const { t } = useTranslation();
const { control } = useFormContext();
const [open, setOpen] = useState(false); const [open, setOpen] = useState(false);
const isRequired = isRequiredAttribute(attribute);
const isMultiValue = (field: ControllerRenderProps<FieldValues, string>) => { const isMultiValue = inputType === "multiselect";
return (
attribute.annotations?.["inputType"] === "multiselect" ||
Array.isArray(field.value)
);
};
const setValue = ( const setValue = (
value: string, value: string,
field: ControllerRenderProps<FieldValues, string>, field: ControllerRenderProps<UserFormFields>,
) => { ) => {
if (isMultiValue(field)) { if (isMultiValue) {
if (field.value.includes(value)) { if (field.value.includes(value)) {
field.onChange(field.value.filter((item: string) => item !== value)); field.onChange(field.value.filter((item: string) => item !== value));
} else { } else {
@ -50,11 +45,11 @@ export const SelectComponent = (attribute: UserProfileFieldsProps) => {
optionLabel ? t(unWrap(optionLabel[label])) : label; optionLabel ? t(unWrap(optionLabel[label])) : label;
return ( return (
<UserProfileGroup {...attribute}> <UserProfileGroup form={form} attribute={attribute}>
<Controller <Controller
name={fieldName(attribute)} name={fieldName(attribute) as FieldPath<UserFormFields>}
defaultValue="" defaultValue=""
control={control} control={form.control}
render={({ field }) => ( render={({ field }) => (
<Select <Select
toggleId={attribute.name} toggleId={attribute.name}
@ -69,12 +64,13 @@ export const SelectComponent = (attribute: UserProfileFieldsProps) => {
} }
}} }}
selections={ selections={
field.value ? field.value : isMultiValue(field) ? [] : t("choose") field.value ? field.value : isMultiValue ? [] : t("choose")
} }
variant={isMultiValue(field) ? "typeaheadmulti" : "single"} variant={isMultiValue ? "typeaheadmulti" : "single"}
aria-label={t("selectOne")} aria-label={t("selectOne")}
isOpen={open} isOpen={open}
readOnly={attribute.readOnly} isDisabled={attribute.readOnly}
required={isRequired}
> >
{options.map((option) => ( {options.map((option) => (
<SelectOption <SelectOption

View file

@ -1,21 +1,28 @@
import { UserProfileAttribute } from "@keycloak/keycloak-admin-client/lib/defs/userProfileConfig"; import { FieldPath } from "react-hook-form";
import { useFormContext } from "react-hook-form";
import { KeycloakTextArea } from "../../components/keycloak-text-area/KeycloakTextArea";
import { UserProfileGroup } from "./UserProfileGroup";
import { fieldName } from "../utils";
export const TextAreaComponent = (attr: UserProfileAttribute) => { import { KeycloakTextArea } from "../../components/keycloak-text-area/KeycloakTextArea";
const { register } = useFormContext(); import { UserProfileFieldProps } from "../UserProfileFields";
import { UserFormFields } from "../form-state";
import { fieldName } from "../utils";
import { isRequiredAttribute } from "../utils/user-profile";
import { UserProfileGroup } from "./UserProfileGroup";
export const TextAreaComponent = ({
form,
attribute,
}: UserProfileFieldProps) => {
const isRequired = isRequiredAttribute(attribute);
return ( return (
<UserProfileGroup {...attr}> <UserProfileGroup form={form} attribute={attribute}>
<KeycloakTextArea <KeycloakTextArea
id={attr.name} id={attribute.name}
data-testid={attr.name} data-testid={attribute.name}
{...register(fieldName(attr))} {...form.register(fieldName(attribute) as FieldPath<UserFormFields>)}
cols={attr.annotations?.["inputTypeCols"] as number} cols={attribute.annotations?.["inputTypeCols"] as number}
rows={attr.annotations?.["inputTypeRows"] as number} rows={attribute.annotations?.["inputTypeRows"] as number}
readOnly={attr.readOnly} readOnly={attribute.readOnly}
isRequired={isRequired}
/> />
</UserProfileGroup> </UserProfileGroup>
); );

View file

@ -1,25 +1,33 @@
import { UserProfileAttribute } from "@keycloak/keycloak-admin-client/lib/defs/userProfileConfig"; import { TextInputTypes } from "@patternfly/react-core";
import { useFormContext } from "react-hook-form"; import { FieldPath } from "react-hook-form";
import { KeycloakTextInput } from "ui-shared"; import { KeycloakTextInput } from "ui-shared";
import { UserProfileFieldProps } from "../UserProfileFields";
import { UserFormFields } from "../form-state";
import { fieldName } from "../utils"; import { fieldName } from "../utils";
import { isRequiredAttribute } from "../utils/user-profile";
import { UserProfileGroup } from "./UserProfileGroup"; import { UserProfileGroup } from "./UserProfileGroup";
export const TextComponent = (attr: UserProfileAttribute) => { export const TextComponent = ({
const { register } = useFormContext(); form,
const inputType = attr.annotations?.["inputType"] as string | undefined; inputType,
const type: any = inputType?.startsWith("html") attribute,
? inputType.substring("html".length + 2) }: UserProfileFieldProps) => {
const isRequired = isRequiredAttribute(attribute);
const type = inputType.startsWith("html")
? (inputType.substring("html".length + 2) as TextInputTypes)
: "text"; : "text";
return ( return (
<UserProfileGroup {...attr}> <UserProfileGroup form={form} attribute={attribute}>
<KeycloakTextInput <KeycloakTextInput
id={attr.name} id={attribute.name}
data-testid={attr.name} data-testid={attribute.name}
type={type} type={type}
placeholder={attr.annotations?.["inputTypePlaceholder"] as string} placeholder={attribute.annotations?.["inputTypePlaceholder"] as string}
readOnly={attr.readOnly} readOnly={attribute.readOnly}
{...register(fieldName(attr))} isRequired={isRequired}
{...form.register(fieldName(attribute) as FieldPath<UserFormFields>)}
/> />
</UserProfileGroup> </UserProfileGroup>
); );

View file

@ -1,43 +1,36 @@
import { UserProfileAttribute } from "@keycloak/keycloak-admin-client/lib/defs/userProfileConfig"; import { UserProfileAttributeMetadata } from "@keycloak/keycloak-admin-client/lib/defs/userProfileMetadata";
import { FormGroup } from "@patternfly/react-core"; import { FormGroup } from "@patternfly/react-core";
import { PropsWithChildren } from "react"; import { PropsWithChildren } from "react";
import { useFormContext } from "react-hook-form"; import { UseFormReturn } from "react-hook-form";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { HelpItem } from "ui-shared"; import { HelpItem } from "ui-shared";
import { label } from "../utils";
export type UserProfileFieldsProps = UserProfileAttribute & { import { UserFormFields } from "../form-state";
roles?: string[]; import { label } from "../utils";
import { isRequiredAttribute } from "../utils/user-profile";
export type UserProfileGroupProps = {
form: UseFormReturn<UserFormFields>;
attribute: UserProfileAttributeMetadata;
}; };
type LengthValidator =
| {
min: number;
}
| undefined;
const isRequired = (attribute: UserProfileAttribute) =>
Object.keys(attribute.required || {}).length !== 0 ||
(((attribute.validators?.length as LengthValidator)?.min as number) || 0) > 0;
export const UserProfileGroup = ({ export const UserProfileGroup = ({
form,
attribute,
children, children,
...attribute }: PropsWithChildren<UserProfileGroupProps>) => {
}: PropsWithChildren<UserProfileFieldsProps>) => {
const { t } = useTranslation(); const { t } = useTranslation();
const helpText = attribute.annotations?.["inputHelperTextBefore"] as string; const helpText = attribute.annotations?.["inputHelperTextBefore"] as string;
const { const {
formState: { errors }, formState: { errors },
} = useFormContext(); } = form;
return ( return (
<FormGroup <FormGroup
key={attribute.name} key={attribute.name}
label={label(attribute, t) || ""} label={label(attribute, t) || ""}
fieldId={attribute.name} fieldId={attribute.name}
isRequired={isRequired(attribute)} isRequired={isRequiredAttribute(attribute)}
validated={errors.username ? "error" : "default"} validated={errors.username ? "error" : "default"}
helperTextInvalid={t("required")} helperTextInvalid={t("required")}
labelIcon={ labelIcon={

View file

@ -5,19 +5,29 @@ import {
keyValueToArray, keyValueToArray,
} from "../components/key-value-form/key-value-convert"; } from "../components/key-value-form/key-value-convert";
export type UserFormFields = Omit<UserRepresentation, "userProfileMetadata"> & { export type UserFormFields = Omit<
attributes?: KeyValueType[]; UserRepresentation,
"attributes" | "userProfileMetadata"
> & {
attributes?: KeyValueType[] | Record<string, string | string[]>;
}; };
export function toUserFormFields(data: UserRepresentation): UserFormFields { export function toUserFormFields(
const attributes = arrayToKeyValue(data.attributes); data: UserRepresentation,
userProfileEnabled: boolean,
): UserFormFields {
const attributes = userProfileEnabled
? data.attributes
: arrayToKeyValue(data.attributes);
return { ...data, attributes }; return { ...data, attributes };
} }
export function toUserRepresentation(data: UserFormFields): UserRepresentation { export function toUserRepresentation(data: UserFormFields): UserRepresentation {
const username = data.username?.trim(); const username = data.username?.trim();
const attributes = keyValueToArray(data.attributes); const attributes = Array.isArray(data.attributes)
? keyValueToArray(data.attributes)
: data.attributes;
return { ...data, username, attributes }; return { ...data, username, attributes };
} }

View file

@ -6,26 +6,39 @@ import {
SelectVariant, SelectVariant,
} from "@patternfly/react-core"; } from "@patternfly/react-core";
import { useState } from "react"; import { useState } from "react";
import { Controller, useFormContext } from "react-hook-form"; import {
Control,
Controller,
FieldPathByValue,
FieldValues,
PathValue,
} from "react-hook-form";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { HelpItem } from "ui-shared"; import { HelpItem } from "ui-shared";
import { adminClient } from "../../admin-client"; import { adminClient } from "../../admin-client";
import { useFetch } from "../../utils/useFetch"; import { useFetch } from "../../utils/useFetch";
type RequiredActionMultiSelectProps = { export type RequiredActionMultiSelectProps<
name: string; T extends FieldValues,
P extends FieldPathByValue<T, string[] | undefined>,
> = {
control: Control<T>;
name: P;
label: string; label: string;
help: string; help: string;
}; };
export const RequiredActionMultiSelect = ({ export const RequiredActionMultiSelect = <
T extends FieldValues,
P extends FieldPathByValue<T, string[] | undefined>,
>({
control,
name, name,
label, label,
help, help,
}: RequiredActionMultiSelectProps) => { }: RequiredActionMultiSelectProps<T, P>) => {
const { t } = useTranslation(); const { t } = useTranslation();
const { control } = useFormContext();
const [open, setOpen] = useState(false); const [open, setOpen] = useState(false);
const [requiredActions, setRequiredActions] = useState< const [requiredActions, setRequiredActions] = useState<
RequiredActionProviderRepresentation[] RequiredActionProviderRepresentation[]
@ -50,7 +63,7 @@ export const RequiredActionMultiSelect = ({
> >
<Controller <Controller
name={name} name={name}
defaultValue={[]} defaultValue={[] as PathValue<T, P>}
control={control} control={control}
render={({ field }) => ( render={({ field }) => (
<Select <Select
@ -64,14 +77,15 @@ export const RequiredActionMultiSelect = ({
menuAppendTo="parent" menuAppendTo="parent"
onToggle={(open) => setOpen(open)} onToggle={(open) => setOpen(open)}
isOpen={open} isOpen={open}
selections={field.value} selections={field.value as string[]}
onSelect={(_, selectedValue) => onSelect={(_, selectedValue) => {
const value: string[] = field.value;
field.onChange( field.onChange(
field.value.find((o: string) => o === selectedValue) value.find((item) => item === selectedValue)
? field.value.filter((item: string) => item !== selectedValue) ? value.filter((item) => item !== selectedValue)
: [...field.value, selectedValue], : [...value, selectedValue],
) );
} }}
onClear={(event) => { onClear={(event) => {
event.stopPropagation(); event.stopPropagation();
field.onChange([]); field.onChange([]);

View file

@ -82,12 +82,13 @@ export const ResetCredentialDialog = ({
isHorizontal isHorizontal
data-testid="credential-reset-modal" data-testid="credential-reset-modal"
> >
<RequiredActionMultiSelect
control={control}
name="actions"
label="resetAction"
help="resetActions"
/>
<FormProvider {...form}> <FormProvider {...form}>
<RequiredActionMultiSelect
name="actions"
label="resetAction"
help="resetActions"
/>
<LifespanField /> <LifespanField />
</FormProvider> </FormProvider>
</Form> </Form>

View file

@ -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"; import { TFunction } from "i18next";
export const isBundleKey = (displayName?: string) => export const isBundleKey = (displayName?: string) =>
displayName?.includes("${"); displayName?.includes("${");
export const unWrap = (key: string) => key.substring(2, key.length - 1); 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) (isBundleKey(attribute.displayName)
? t(unWrap(attribute.displayName!)) ? t(unWrap(attribute.displayName!))
: attribute.displayName) || attribute.name; : attribute.displayName) || attribute.name;
const ROOT_ATTRIBUTES = ["username", "firstName", "lastName", "email"]; const ROOT_ATTRIBUTES = ["username", "firstName", "lastName", "email"];
const isRootAttribute = (attr?: string) => export const isRootAttribute = (attr?: string) =>
attr && ROOT_ATTRIBUTES.includes(attr); attr && ROOT_ATTRIBUTES.includes(attr);
export const fieldName = (attribute: UserProfileAttribute) => export const fieldName = (attribute: UserProfileAttributeMetadata) =>
`${isRootAttribute(attribute.name) ? "" : "attributes."}${attribute.name}`; isRootAttribute(attribute.name)
? attribute.name
: `attributes.${attribute.name}`;

View file

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

View file

@ -1,5 +1,5 @@
// See: https://github.com/keycloak/keycloak/blob/main/services/src/main/java/org/keycloak/userprofile/config/UPConfig.java // 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[]; attributes?: UserProfileAttribute[];
groups?: UserProfileGroup[]; 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 // See: https://github.com/keycloak/keycloak/blob/main/services/src/main/java/org/keycloak/userprofile/config/UPAttribute.java
export interface UserProfileAttribute { export interface UserProfileAttribute {
name?: string; name?: string;
validations?: Record<string, unknown>; validations?: Record<string, Record<string, unknown>>;
validators?: Record<string, unknown>;
annotations?: Record<string, unknown>; annotations?: Record<string, unknown>;
required?: UserProfileAttributeRequired; required?: UserProfileAttributeRequired;
readOnly?: boolean;
permissions?: UserProfileAttributePermissions; permissions?: UserProfileAttributePermissions;
selector?: UserProfileAttributeSelector; selector?: UserProfileAttributeSelector;
displayName?: string; displayName?: string;

View file

@ -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<string, unknown>;
validators?: Record<string, Record<string, unknown>>;
}
// 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<string, unknown>;
}

View file

@ -1,8 +1,8 @@
import type UserConsentRepresentation from "./userConsentRepresentation.js";
import type CredentialRepresentation from "./credentialRepresentation.js"; import type CredentialRepresentation from "./credentialRepresentation.js";
import type FederatedIdentityRepresentation from "./federatedIdentityRepresentation.js"; import type FederatedIdentityRepresentation from "./federatedIdentityRepresentation.js";
import type { RequiredActionAlias } from "./requiredActionProviderRepresentation.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 { export default interface UserRepresentation {
id?: string; id?: string;
@ -31,5 +31,5 @@ export default interface UserRepresentation {
realmRoles?: string[]; realmRoles?: string[];
self?: string; self?: string;
serviceAccountClientId?: string; serviceAccountClientId?: string;
userProfileMetadata?: UserProfileConfig; userProfileMetadata?: UserProfileMetadata;
} }

View file

@ -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 { KeycloakAdminClient } from "../client.js";
import type MappingsRepresentation from "../defs/mappingsRepresentation.js"; import type CredentialRepresentation from "../defs/credentialRepresentation.js";
import type RoleRepresentation from "../defs/roleRepresentation.js";
import type { RoleMappingPayload } from "../defs/roleRepresentation.js";
import type { RequiredActionAlias } from "../defs/requiredActionProviderRepresentation.js";
import type FederatedIdentityRepresentation from "../defs/federatedIdentityRepresentation.js"; import type FederatedIdentityRepresentation from "../defs/federatedIdentityRepresentation.js";
import type GroupRepresentation from "../defs/groupRepresentation.js"; import type GroupRepresentation from "../defs/groupRepresentation.js";
import type CredentialRepresentation from "../defs/credentialRepresentation.js"; import type MappingsRepresentation from "../defs/mappingsRepresentation.js";
import type UserProfileConfig from "../defs/userProfileConfig.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 { interface SearchQuery {
search?: string; 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 * role mappings
*/ */

View file

@ -34,15 +34,17 @@ public final class AttributeContext {
private final Map.Entry<String, List<String>> attribute; private final Map.Entry<String, List<String>> attribute;
private final UserModel user; private final UserModel user;
private final AttributeMetadata metadata; private final AttributeMetadata metadata;
private final Attributes attributes;
private UserProfileContext context; private UserProfileContext context;
public AttributeContext(UserProfileContext context, KeycloakSession session, Map.Entry<String, List<String>> attribute, public AttributeContext(UserProfileContext context, KeycloakSession session, Map.Entry<String, List<String>> attribute,
UserModel user, AttributeMetadata metadata) { UserModel user, AttributeMetadata metadata, Attributes attributes) {
this.context = context; this.context = context;
this.session = session; this.session = session;
this.attribute = attribute; this.attribute = attribute;
this.user = user; this.user = user;
this.metadata = metadata; this.metadata = metadata;
this.attributes = attributes;
} }
public KeycloakSession getSession() { public KeycloakSession getSession() {
@ -64,4 +66,8 @@ public final class AttributeContext {
public AttributeMetadata getMetadata() { public AttributeMetadata getMetadata() {
return metadata; return metadata;
} }
public Attributes getAttributes() {
return attributes;
}
} }

View file

@ -232,11 +232,11 @@ public class DefaultAttributes extends HashMap<String, List<String>> implements
} }
private AttributeContext createAttributeContext(Entry<String, List<String>> attribute, AttributeMetadata metadata) { private AttributeContext createAttributeContext(Entry<String, List<String>> 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) { 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) { protected AttributeContext createAttributeContext(AttributeMetadata metadata) {

View file

@ -19,6 +19,7 @@ package org.keycloak.userprofile;
import java.util.Map; import java.util.Map;
import org.keycloak.models.RealmModel;
import org.keycloak.models.UserModel; import org.keycloak.models.UserModel;
import org.keycloak.provider.Provider; import org.keycloak.provider.Provider;
@ -87,4 +88,12 @@ public interface UserProfileProvider extends Provider {
* @see #getConfiguration() * @see #getConfiguration()
*/ */
void setConfiguration(String configuration); 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);
} }

View file

@ -306,7 +306,7 @@ public class FreeMarkerLoginFormsProvider implements LoginFormsProvider {
} }
private boolean isDynamicUserProfile() { private boolean isDynamicUserProfile() {
return session.getProvider(UserProfileProvider.class).getConfiguration() != null; return session.getProvider(UserProfileProvider.class).isEnabled(realm);
} }
/** /**

View file

@ -80,11 +80,11 @@ import org.keycloak.services.managers.Auth;
import org.keycloak.services.managers.UserConsentManager; import org.keycloak.services.managers.UserConsentManager;
import org.keycloak.services.messages.Messages; import org.keycloak.services.messages.Messages;
import org.keycloak.services.resources.account.resources.ResourcesService; import org.keycloak.services.resources.account.resources.ResourcesService;
import org.keycloak.services.resources.admin.UserProfileResource;
import org.keycloak.services.util.ResolveRelative; import org.keycloak.services.util.ResolveRelative;
import org.keycloak.storage.ReadOnlyException; import org.keycloak.storage.ReadOnlyException;
import org.keycloak.theme.Theme; import org.keycloak.theme.Theme;
import org.keycloak.userprofile.AttributeMetadata; import org.keycloak.userprofile.AttributeMetadata;
import org.keycloak.userprofile.AttributeValidatorMetadata;
import org.keycloak.userprofile.Attributes; import org.keycloak.userprofile.Attributes;
import org.keycloak.userprofile.UserProfile; import org.keycloak.userprofile.UserProfile;
import org.keycloak.userprofile.UserProfileContext; import org.keycloak.userprofile.UserProfileContext;
@ -153,7 +153,7 @@ public class AccountRestService {
addReadableBuiltinAttributes(user, rep, profile.getAttributes().getReadable(true).keySet()); addReadableBuiltinAttributes(user, rep, profile.getAttributes().getReadable(true).keySet());
if(userProfileMetadata == null || userProfileMetadata.booleanValue()) if(userProfileMetadata == null || userProfileMetadata.booleanValue())
rep.setUserProfileMetadata(createUserProfileMetadata(profile)); rep.setUserProfileMetadata(UserProfileResource.createUserProfileMetadata(session, profile));
return rep; return rep;
} }
@ -173,37 +173,6 @@ public class AccountRestService {
} }
} }
private UserProfileMetadata createUserProfileMetadata(final UserProfile profile) {
Map<String, List<String>> am = profile.getAttributes().getReadable();
if(am == null)
return null;
List<UserProfileAttributeMetadata> 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<String, Map<String, Object>> 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("/") @Path("/")
@POST @POST
@Consumes(MediaType.APPLICATION_JSON) @Consumes(MediaType.APPLICATION_JSON)

View file

@ -16,6 +16,15 @@
*/ */
package org.keycloak.services.resources.admin; 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 org.eclipse.microprofile.openapi.annotations.Operation;
import jakarta.ws.rs.Consumes; import jakarta.ws.rs.Consumes;
import jakarta.ws.rs.GET; 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.component.ComponentValidationException;
import org.keycloak.models.KeycloakSession; import org.keycloak.models.KeycloakSession;
import org.keycloak.models.RealmModel; 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.ErrorResponse;
import org.keycloak.services.resources.KeycloakOpenAPI; import org.keycloak.services.resources.KeycloakOpenAPI;
import org.keycloak.services.resources.admin.permissions.AdminPermissionEvaluator; 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.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 <velias@redhat.com> * @author Vlastimil Elias <velias@redhat.com>
@ -60,6 +81,17 @@ public class UserProfileResource {
return session.getProvider(UserProfileProvider.class).getConfiguration(); 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 @PUT
@Consumes(MediaType.APPLICATION_JSON) @Consumes(MediaType.APPLICATION_JSON)
@Tag(name = KeycloakOpenAPI.Admin.Tags.USERS) @Tag(name = KeycloakOpenAPI.Admin.Tags.USERS)
@ -78,4 +110,58 @@ public class UserProfileResource {
return Response.ok(t.getConfiguration()).type(MediaType.APPLICATION_JSON).build(); return Response.ok(t.getConfiguration()).type(MediaType.APPLICATION_JSON).build();
} }
public static UserProfileMetadata createUserProfileMetadata(KeycloakSession session, UserProfile profile) {
Map<String, List<String>> am = profile.getAttributes().getReadable();
if(am == null)
return null;
List<UserProfileAttributeMetadata> 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<UserProfileAttributeGroupMetadata> groups = config.getGroups().stream().map(new Function<UPGroup, UserProfileAttributeGroupMetadata>() {
@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<String, Map<String, Object>> 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));
}
} }

View file

@ -127,6 +127,7 @@ import java.util.stream.Stream;
import static org.keycloak.models.ImpersonationSessionNote.IMPERSONATOR_ID; import static org.keycloak.models.ImpersonationSessionNote.IMPERSONATOR_ID;
import static org.keycloak.models.ImpersonationSessionNote.IMPERSONATOR_USERNAME; 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.userprofile.UserProfileContext.USER_API;
import static org.keycloak.utils.LockObjectsForModification.lockUserSessionsForModification; import static org.keycloak.utils.LockObjectsForModification.lockUserSessionsForModification;
@ -336,7 +337,7 @@ public class UserResource {
} }
if (userProfileMetadata) { if (userProfileMetadata) {
rep.setUserProfileMetadata(createUserProfileMetadata(profile)); rep.setUserProfileMetadata(createUserProfileMetadata(session, profile));
} }
return rep; return rep;
@ -1068,35 +1069,4 @@ public class UserResource {
rep.setLastAccess(Time.toMillis(clientSession.getTimestamp())); rep.setLastAccess(Time.toMillis(clientSession.getTimestamp()));
return rep; return rep;
} }
private UserProfileMetadata createUserProfileMetadata(final UserProfile profile) {
Map<String, List<String>> am = profile.getAttributes().getReadable();
if(am == null)
return null;
List<UserProfileAttributeMetadata> 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<String, Map<String, Object>> 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));
}
} }

View file

@ -87,6 +87,11 @@ public abstract class AbstractUserProfileProvider<U extends UserProfileProvider>
if (realm.isRegistrationEmailAsUsername()) { if (realm.isRegistrationEmailAsUsername()) {
return false; return false;
} }
if (isNewUser(c)) {
// when creating a user the username is always editable
return true;
}
} }
return realm.isEditUsernameAllowed(); return realm.isEditUsernameAllowed();
@ -116,23 +121,15 @@ public abstract class AbstractUserProfileProvider<U extends UserProfileProvider>
private static boolean editEmailCondition(AttributeContext c) { private static boolean editEmailCondition(AttributeContext c) {
RealmModel realm = c.getSession().getContext().getRealm(); 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; return true;
} }
if (USER_API.equals(c.getContext())) {
if (realm.isRegistrationEmailAsUsername()) {
return true;
}
}
if (Profile.isFeatureEnabled(Feature.UPDATE_EMAIL)) { if (Profile.isFeatureEnabled(Feature.UPDATE_EMAIL)) {
return !(UPDATE_PROFILE.equals(c.getContext()) || ACCOUNT.equals(c.getContext())); return !(UPDATE_PROFILE.equals(c.getContext()) || ACCOUNT.equals(c.getContext()));
} }
UserModel user = c.getUser(); if (!isNewUser(c) && realm.isRegistrationEmailAsUsername() && !realm.isEditUsernameAllowed()) {
if (user != null && realm.isRegistrationEmailAsUsername() && !realm.isEditUsernameAllowed()) {
return false; return false;
} }
@ -142,7 +139,7 @@ public abstract class AbstractUserProfileProvider<U extends UserProfileProvider>
private static boolean readEmailCondition(AttributeContext c) { private static boolean readEmailCondition(AttributeContext c) {
UserProfileContext context = c.getContext(); UserProfileContext context = c.getContext();
if (REGISTRATION_PROFILE.equals(context)) { if (REGISTRATION_PROFILE.equals(context) || USER_API.equals(c.getContext())) {
return true; return true;
} }
@ -183,6 +180,10 @@ public abstract class AbstractUserProfileProvider<U extends UserProfileProvider>
return realm.isInternationalizationEnabled(); 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 * 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. * user profiles are used. They are related to internal attributes with hard conditions on them in terms of management.

View file

@ -126,7 +126,9 @@ public class DeclarativeUserProfileProvider extends AbstractUserProfileProvider<
@Override @Override
protected Attributes createAttributes(UserProfileContext context, Map<String, ?> attributes, protected Attributes createAttributes(UserProfileContext context, Map<String, ?> attributes,
UserModel user, UserProfileMetadata metadata) { UserModel user, UserProfileMetadata metadata) {
if (isEnabled(session)) { RealmModel realm = session.getContext().getRealm();
if (isEnabled(realm)) {
if (user != null && user.getServiceAccountClientLink() != null) { if (user != null && user.getServiceAccountClientLink() != null) {
return new LegacyAttributes(context, attributes, user, metadata, session); return new LegacyAttributes(context, attributes, user, metadata, session);
} }
@ -139,8 +141,9 @@ public class DeclarativeUserProfileProvider extends AbstractUserProfileProvider<
protected UserProfileMetadata configureUserProfile(UserProfileMetadata metadata, KeycloakSession session) { protected UserProfileMetadata configureUserProfile(UserProfileMetadata metadata, KeycloakSession session) {
UserProfileContext context = metadata.getContext(); UserProfileContext context = metadata.getContext();
UserProfileMetadata decoratedMetadata = metadata.clone(); UserProfileMetadata decoratedMetadata = metadata.clone();
RealmModel realm = session.getContext().getRealm();
if (!isEnabled(session)) { if (!isEnabled(realm)) {
if(!context.equals(UserProfileContext.USER_API) if(!context.equals(UserProfileContext.USER_API)
&& !context.equals(UserProfileContext.REGISTRATION_USER_CREATION) && !context.equals(UserProfileContext.REGISTRATION_USER_CREATION)
&& !context.equals(UserProfileContext.UPDATE_EMAIL)) { && !context.equals(UserProfileContext.UPDATE_EMAIL)) {
@ -194,8 +197,10 @@ public class DeclarativeUserProfileProvider extends AbstractUserProfileProvider<
@Override @Override
public String getConfiguration() { public String getConfiguration() {
if (!isEnabled(session)) { RealmModel realm = session.getContext().getRealm();
return null;
if (!isEnabled(realm)) {
return defaultRawConfig;
} }
String cfg = getConfigJsonFromComponentModel(getComponentModel()); String cfg = getConfigJsonFromComponentModel(getComponentModel());
@ -349,7 +354,31 @@ public class DeclarativeUserProfileProvider extends AbstractUserProfileProvider<
} }
if (UserModel.USERNAME.equals(attributeName)) { if (UserModel.USERNAME.equals(attributeName)) {
required = AttributeMetadata.ALWAYS_TRUE; required = new Predicate<AttributeContext>() {
@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<AttributeContext>() {
@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 // 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); model.getConfig().remove(UP_PIECES_COUNT_COMPONENT_CONFIG_KEY);
} }
/** @Override
* Returns whether the declarative provider is enabled to a realm public boolean isEnabled(RealmModel realm) {
* return isDeclarativeConfigurationEnabled && realm.getAttribute(REALM_USER_PROFILE_ENABLED, false);
* @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);
} }
} }

View file

@ -19,6 +19,7 @@ package org.keycloak.userprofile.validator;
import static org.keycloak.validate.Validators.notBlankValidator; import static org.keycloak.validate.Validators.notBlankValidator;
import java.util.List; import java.util.List;
import java.util.Objects;
import java.util.stream.Collectors; import java.util.stream.Collectors;
import org.keycloak.common.util.CollectionUtil; import org.keycloak.common.util.CollectionUtil;
@ -57,7 +58,7 @@ public class ImmutableAttributeValidator implements SimpleValidator {
return context; return context;
} }
List<String> currentValue = user.getAttributeStream(inputHint).collect(Collectors.toList()); List<String> currentValue = user.getAttributeStream(inputHint).filter(Objects::nonNull).collect(Collectors.toList());
List<String> values = (List<String>) input; List<String> values = (List<String>) input;
if (!CollectionUtil.collectionEquals(currentValue, values) && isReadOnly(attributeContext)) { if (!CollectionUtil.collectionEquals(currentValue, values) && isReadOnly(attributeContext)) {

View file

@ -24,8 +24,8 @@ import org.keycloak.models.utils.KeycloakModelUtils;
import org.keycloak.services.messages.Messages; import org.keycloak.services.messages.Messages;
import org.keycloak.services.validation.Validation; import org.keycloak.services.validation.Validation;
import org.keycloak.userprofile.AttributeContext; import org.keycloak.userprofile.AttributeContext;
import org.keycloak.userprofile.Attributes;
import org.keycloak.userprofile.UserProfileAttributeValidationContext; import org.keycloak.userprofile.UserProfileAttributeValidationContext;
import org.keycloak.userprofile.UserProfileContext;
import org.keycloak.validate.SimpleValidator; import org.keycloak.validate.SimpleValidator;
import org.keycloak.validate.ValidationContext; import org.keycloak.validate.ValidationContext;
import org.keycloak.validate.ValidationError; import org.keycloak.validate.ValidationError;
@ -69,7 +69,8 @@ public class UsernameMutationValidator implements SimpleValidator {
if (! KeycloakModelUtils.isUsernameCaseSensitive(realm)) value = value.toLowerCase(); if (! KeycloakModelUtils.isUsernameCaseSensitive(realm)) value = value.toLowerCase();
if (!realm.isEditUsernameAllowed() && user != null && !value.equals(user.getFirstAttribute(UserModel.USERNAME))) { 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 // 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 // it is expected that username changes when attributes are normalized by the provider
return context; return context;

View file

@ -113,12 +113,14 @@ public class AccountRestServiceTest extends AbstractRestServiceTest {
realmRep.setEditUsernameAllowed(true); realmRep.setEditUsernameAllowed(true);
realm.update(realmRep); realm.update(realmRep);
user = getUser(); user = getUser();
assertNotNull(user.getUserProfileMetadata()); if (isDeclarativeUserProfile()) {
// can write both username and email assertNotNull(user.getUserProfileMetadata());
assertUserProfileAttributeMetadata(user, "username", "${username}", true, false); // can write both username and email
assertUserProfileAttributeMetadata(user, "email", "${email}", true, false); assertUserProfileAttributeMetadata(user, "username", "${username}", true, false);
assertUserProfileAttributeMetadata(user, "firstName", "${firstName}", true, false); assertUserProfileAttributeMetadata(user, "email", "${email}", true, false);
assertUserProfileAttributeMetadata(user, "lastName", "${lastName}", true, false); assertUserProfileAttributeMetadata(user, "firstName", "${firstName}", true, false);
assertUserProfileAttributeMetadata(user, "lastName", "${lastName}", true, false);
}
user.setUsername("changed-username"); user.setUsername("changed-username");
user.setEmail("changed-email@keycloak.org"); user.setEmail("changed-email@keycloak.org");
user = updateAndGet(user); user = updateAndGet(user);
@ -129,10 +131,12 @@ public class AccountRestServiceTest extends AbstractRestServiceTest {
realmRep.setEditUsernameAllowed(false); realmRep.setEditUsernameAllowed(false);
realm.update(realmRep); realm.update(realmRep);
user = getUser(); user = getUser();
assertNotNull(user.getUserProfileMetadata()); if (isDeclarativeUserProfile()) {
// username is readonly but email is writable assertNotNull(user.getUserProfileMetadata());
assertUserProfileAttributeMetadata(user, "username", "${username}", true, true); // username is readonly but email is writable
assertUserProfileAttributeMetadata(user, "email", "${email}", true, false); assertUserProfileAttributeMetadata(user, "username", "${username}", true, true);
assertUserProfileAttributeMetadata(user, "email", "${email}", true, false);
}
user.setUsername("should-not-change"); user.setUsername("should-not-change");
user.setEmail("changed-email@keycloak.org"); user.setEmail("changed-email@keycloak.org");
updateError(user, 400, Messages.READ_ONLY_USERNAME); updateError(user, 400, Messages.READ_ONLY_USERNAME);
@ -141,10 +145,12 @@ public class AccountRestServiceTest extends AbstractRestServiceTest {
realmRep.setEditUsernameAllowed(true); realmRep.setEditUsernameAllowed(true);
realm.update(realmRep); realm.update(realmRep);
user = getUser(); user = getUser();
assertNotNull(user.getUserProfileMetadata()); if (isDeclarativeUserProfile()) {
// username is read-only and is the same as email, but email is writable assertNotNull(user.getUserProfileMetadata());
assertUserProfileAttributeMetadata(user, "username", "${username}", true, true); // username is read-only and is the same as email, but email is writable
assertUserProfileAttributeMetadata(user, "email", "${email}", true, false); assertUserProfileAttributeMetadata(user, "username", "${username}", true, true);
assertUserProfileAttributeMetadata(user, "email", "${email}", true, false);
}
user.setUsername("should-be-the-email"); user.setUsername("should-be-the-email");
user.setEmail("user@keycloak.org"); user.setEmail("user@keycloak.org");
user = updateAndGet(user); user = updateAndGet(user);
@ -155,15 +161,36 @@ public class AccountRestServiceTest extends AbstractRestServiceTest {
realmRep.setEditUsernameAllowed(false); realmRep.setEditUsernameAllowed(false);
realm.update(realmRep); realm.update(realmRep);
user = getUser(); user = getUser();
assertNotNull(user.getUserProfileMetadata()); if (isDeclarativeUserProfile()) {
// username is read-only and is the same as email, but email is read-only assertNotNull(user.getUserProfileMetadata());
assertUserProfileAttributeMetadata(user, "username", "${username}", true, true); // username is read-only and is the same as email, but email is read-only
assertUserProfileAttributeMetadata(user, "email", "${email}", true, true); assertUserProfileAttributeMetadata(user, "username", "${username}", true, true);
assertUserProfileAttributeMetadata(user, "email", "${email}", true, true);
}
user.setUsername("should-be-the-email"); user.setUsername("should-be-the-email");
user.setEmail("should-not-change@keycloak.org"); user.setEmail("should-not-change@keycloak.org");
user = updateAndGet(user); user = updateAndGet(user);
assertEquals("user@keycloak.org", user.getUsername()); assertEquals("user@keycloak.org", user.getUsername());
assertEquals("user@keycloak.org", user.getEmail()); 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 { } finally {
realmRep.setRegistrationEmailAsUsername(registrationEmailAsUsername); realmRep.setRegistrationEmailAsUsername(registrationEmailAsUsername);
realmRep.setEditUsernameAllowed(editUsernameAllowed); realmRep.setEditUsernameAllowed(editUsernameAllowed);
@ -189,24 +216,28 @@ public class AccountRestServiceTest extends AbstractRestServiceTest {
realm.update(realmRep); realm.update(realmRep);
UserRepresentation user = getUser(); UserRepresentation user = getUser();
assertNotNull(user.getUserProfileMetadata()); if (isDeclarativeUserProfile()) {
UserProfileAttributeMetadata upm = assertUserProfileAttributeMetadata(user, "username", "${username}", true, true); assertNotNull(user.getUserProfileMetadata());
//makes sure internal validators are not exposed UserProfileAttributeMetadata upm = assertUserProfileAttributeMetadata(user, "username", "${username}", true, true);
Assert.assertEquals(0, upm.getValidators().size()); //makes sure internal validators are not exposed
Assert.assertEquals(0, upm.getValidators().size());
upm = assertUserProfileAttributeMetadata(user, "email", "${email}", true, false); upm = assertUserProfileAttributeMetadata(user, "email", "${email}", true, false);
Assert.assertEquals(1, upm.getValidators().size()); Assert.assertEquals(1, upm.getValidators().size());
Assert.assertTrue(upm.getValidators().containsKey(EmailValidator.ID)); Assert.assertTrue(upm.getValidators().containsKey(EmailValidator.ID));
}
realmRep.setRegistrationEmailAsUsername(true); realmRep.setRegistrationEmailAsUsername(true);
realm.update(realmRep); realm.update(realmRep);
user = getUser(); user = getUser();
upm = assertUserProfileAttributeMetadata(user, "email", "${email}", true, true); if (isDeclarativeUserProfile()) {
Assert.assertEquals(1, upm.getValidators().size()); UserProfileAttributeMetadata upm = assertUserProfileAttributeMetadata(user, "email", "${email}", true, true);
Assert.assertTrue(upm.getValidators().containsKey(EmailValidator.ID)); Assert.assertEquals(1, upm.getValidators().size());
Assert.assertTrue(upm.getValidators().containsKey(EmailValidator.ID));
assertUserProfileAttributeMetadata(user, "firstName", "${firstName}", true, false); assertUserProfileAttributeMetadata(user, "firstName", "${firstName}", true, false);
assertUserProfileAttributeMetadata(user, "lastName", "${lastName}", true, false); assertUserProfileAttributeMetadata(user, "lastName", "${lastName}", true, false);
}
} finally { } finally {
RealmRepresentation realmRep = testRealm().toRepresentation(); RealmRepresentation realmRep = testRealm().toRepresentation();
realmRep.setEditUsernameAllowed(true); realmRep.setEditUsernameAllowed(true);

View file

@ -71,7 +71,7 @@ public class UserTestWithUserProfile extends UserTest {
assertNotNull(metadata); assertNotNull(metadata);
for (String name : managedAttributes) { 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); UserRepresentation user = realm.users().get(userId).toRepresentation(true);
UserProfileMetadata metadata = user.getUserProfileMetadata(); UserProfileMetadata metadata = user.getUserProfileMetadata();
assertNotNull(metadata); assertNotNull(metadata);
UserProfileAttributeMetadata username = getAttributeMetadata(metadata, UserModel.USERNAME); UserProfileAttributeMetadata username = metadata.getAttributeMetadata(UserModel.USERNAME);
assertNotNull(username); assertNotNull(username);
assertTrue(username.isReadOnly()); assertTrue(username.isReadOnly());
UserProfileAttributeMetadata email = getAttributeMetadata(metadata, UserModel.EMAIL); UserProfileAttributeMetadata email = metadata.getAttributeMetadata(UserModel.EMAIL);
assertNotNull(email); assertNotNull(email);
assertFalse(email.isReadOnly()); assertFalse(email.isReadOnly());
} }
@ -101,26 +101,14 @@ public class UserTestWithUserProfile extends UserTest {
UserRepresentation user = realm.users().get(userId).toRepresentation(true); UserRepresentation user = realm.users().get(userId).toRepresentation(true);
UserProfileMetadata metadata = user.getUserProfileMetadata(); UserProfileMetadata metadata = user.getUserProfileMetadata();
assertNotNull(metadata); assertNotNull(metadata);
UserProfileAttributeMetadata username = getAttributeMetadata(metadata, UserModel.USERNAME); UserProfileAttributeMetadata username = metadata.getAttributeMetadata(UserModel.USERNAME);
assertNotNull(username); assertNotNull(username);
assertTrue(username.isReadOnly()); assertTrue(username.isReadOnly());
UserProfileAttributeMetadata email = getAttributeMetadata(metadata, UserModel.EMAIL); UserProfileAttributeMetadata email = metadata.getAttributeMetadata(UserModel.EMAIL);
assertNotNull(email); assertNotNull(email);
assertFalse(email.isReadOnly()); 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) { private UPAttribute createAttributeMetadata(String name) {
UPAttribute attribute = new UPAttribute(); UPAttribute attribute = new UPAttribute();
attribute.setName(name); attribute.setName(name);

View file

@ -20,18 +20,31 @@
package org.keycloak.testsuite.admin.userprofile; package org.keycloak.testsuite.admin.userprofile;
import static org.junit.Assert.assertEquals; 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.DeclarativeUserProfileProvider.REALM_USER_PROFILE_ENABLED;
import static org.keycloak.userprofile.config.UPConfigUtils.readDefaultConfig; import static org.keycloak.userprofile.config.UPConfigUtils.readDefaultConfig;
import java.io.IOException; import java.io.IOException;
import java.util.HashMap; import java.util.HashMap;
import java.util.List;
import java.util.Map;
import org.junit.Test; import org.junit.Test;
import org.keycloak.admin.client.resource.RealmResource;
import org.keycloak.admin.client.resource.UserProfileResource; import org.keycloak.admin.client.resource.UserProfileResource;
import org.keycloak.common.Profile; import org.keycloak.common.Profile;
import org.keycloak.models.UserModel;
import org.keycloak.representations.idm.RealmRepresentation; 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.admin.AbstractAdminTest;
import org.keycloak.testsuite.arquillian.annotation.EnableFeature; 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 <a href="mailto:psilva@redhat.com">Pedro Igor</a> * @author <a href="mailto:psilva@redhat.com">Pedro Igor</a>
@ -53,12 +66,111 @@ public class UserProfileAdminTest extends AbstractAdminTest {
} }
@Test @Test
public void testSetDefaultConfig() throws IOException { public void testSetDefaultConfig() {
String rawConfig = "{\"attributes\": [{\"name\": \"test\"}]}"; String rawConfig = "{\"attributes\": [{\"name\": \"test\"}]}";
UserProfileResource userProfile = testRealm().users().userProfile(); UserProfileResource userProfile = testRealm().users().userProfile();
userProfile.update(rawConfig); userProfile.update(rawConfig);
getCleanup().addCleanup(() -> testRealm().users().userProfile().update(null));
assertEquals(rawConfig, userProfile.getConfiguration()); 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<UserProfileAttributeGroupMetadata> 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());
}
} }

View file

@ -1314,6 +1314,76 @@ public class UserProfileTest extends AbstractUserProfileTest {
profile.validate(); 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<String, Object> 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 @Test
public void testReadOnlyInternalAttributeValidation() { public void testReadOnlyInternalAttributeValidation() {
getTestingClient().server(TEST_REALM_NAME).run((RunOnServer) UserProfileTest::testReadOnlyInternalAttributeValidation); getTestingClient().server(TEST_REALM_NAME).run((RunOnServer) UserProfileTest::testReadOnlyInternalAttributeValidation);