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

View file

@ -16,7 +16,10 @@
*/
package org.keycloak.representations.idm;
import static java.util.Collections.emptyList;
import java.util.List;
import java.util.Optional;
/**
* @author Vlastimil Elias <velias@redhat.com>
@ -24,22 +27,47 @@ import java.util.List;
public class UserProfileMetadata {
private List<UserProfileAttributeMetadata> attributes;
private List<UserProfileAttributeGroupMetadata> groups;
public UserProfileMetadata() {
}
public UserProfileMetadata(List<UserProfileAttributeMetadata> attributes) {
public UserProfileMetadata(List<UserProfileAttributeMetadata> attributes, List<UserProfileAttributeGroupMetadata> groups) {
super();
this.attributes = attributes;
this.groups = groups;
}
public List<UserProfileAttributeMetadata> getAttributes() {
return attributes;
}
public List<UserProfileAttributeGroupMetadata> getGroups() {
return groups;
}
public void setAttributes(List<UserProfileAttributeMetadata> 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.GET;
import javax.ws.rs.PUT;
import javax.ws.rs.Path;
import javax.ws.rs.Produces;
import javax.ws.rs.core.MediaType;
import javax.ws.rs.core.Response;
import org.keycloak.representations.idm.UserProfileMetadata;
/**
* @author Vlastimil Elias <velias@redhat.com>
*/
@ -34,6 +37,11 @@ public interface UserProfileResource {
@Consumes(MediaType.APPLICATION_JSON)
String getConfiguration();
@GET
@Path("/metadata")
@Consumes(jakarta.ws.rs.core.MediaType.APPLICATION_JSON)
UserProfileMetadata getMetadata();
@PUT
@Produces(MediaType.APPLICATION_JSON)
Response update(String text);

View file

@ -1,6 +1,6 @@
import KeycloakAdminClient from "@keycloak/keycloak-admin-client";
import RealmRepresentation from "@keycloak/keycloak-admin-client/lib/defs/realmRepresentation";
import type UserProfileConfig from "@keycloak/keycloak-admin-client/lib/defs/userProfileConfig";
import type { UserProfileConfig } from "@keycloak/keycloak-admin-client/lib/defs/userProfileConfig";
import UserRepresentation from "@keycloak/keycloak-admin-client/lib/defs/userRepresentation";
const adminClient = new KeycloakAdminClient({

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 {
enableLocalization,
importUserProfile,
createUser,
deleteUser,
enableLocalization,
importUserProfile,
} from "../admin-client";
import { login } from "../login";
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 SidebarPage from "../support/pages/admin-ui/SidebarPage";
import ProviderPage from "../support/pages/admin-ui/manage/providers/ProviderPage";
import adminClient from "../support/util/AdminClient";
import { keycloakBefore } from "../support/util/keycloak_hooks";
import ProviderPage from "../support/pages/admin-ui/manage/providers/ProviderPage";
import RealmRepresentation from "libs/keycloak-admin-client/lib/defs/realmRepresentation";
const loginPage = new LoginPage();
const sidebarPage = new SidebarPage();

View file

@ -1,11 +1,11 @@
import KeycloakAdminClient from "@keycloak/keycloak-admin-client";
import type UserRepresentation from "@keycloak/keycloak-admin-client/lib/defs/userRepresentation";
import type ClientRepresentation from "@keycloak/keycloak-admin-client/lib/defs/clientRepresentation";
import type ClientScopeRepresentation from "@keycloak/keycloak-admin-client/lib/defs/clientScopeRepresentation";
import type RealmRepresentation from "@keycloak/keycloak-admin-client/lib/defs/realmRepresentation";
import type UserProfileConfig from "@keycloak/keycloak-admin-client/lib/defs/userProfileConfig";
import type RoleRepresentation from "@keycloak/keycloak-admin-client/lib/defs/roleRepresentation";
import type { RoleMappingPayload } from "@keycloak/keycloak-admin-client/lib/defs/roleRepresentation";
import type { UserProfileConfig } from "@keycloak/keycloak-admin-client/lib/defs/userProfileConfig";
import type UserRepresentation from "@keycloak/keycloak-admin-client/lib/defs/userRepresentation";
import { merge } from "lodash-es";
class AdminClient {

View file

@ -1,6 +1,6 @@
import type ComponentRepresentation from "@keycloak/keycloak-admin-client/lib/defs/componentRepresentation";
import type RealmRepresentation from "@keycloak/keycloak-admin-client/lib/defs/realmRepresentation";
import type UserProfileConfig from "@keycloak/keycloak-admin-client/lib/defs/userProfileConfig";
import type { UserProfileConfig } from "@keycloak/keycloak-admin-client/lib/defs/userProfileConfig";
import type UserRepresentation from "@keycloak/keycloak-admin-client/lib/defs/userRepresentation";
import {
AlertVariant,
@ -29,19 +29,19 @@ import { useTranslation } from "react-i18next";
import { Link, useNavigate } from "react-router-dom";
import { adminClient } from "../../admin-client";
import { useRealm } from "../../context/realm-context/RealmContext";
import { SearchType } from "../../user/details/SearchFilter";
import { toAddUser } from "../../user/routes/AddUser";
import { toUser } from "../../user/routes/User";
import { emptyFormatter } from "../../util";
import { useFetch } from "../../utils/useFetch";
import { useAlerts } from "../alert/Alerts";
import { useConfirmDialog } from "../confirm-dialog/ConfirmDialog";
import { KeycloakSpinner } from "../keycloak-spinner/KeycloakSpinner";
import { ListEmptyState } from "../list-empty-state/ListEmptyState";
import { BruteUser, findUsers } from "../role-mapping/resource";
import { KeycloakDataTable } from "../table-toolbar/KeycloakDataTable";
import { useRealm } from "../../context/realm-context/RealmContext";
import { emptyFormatter } from "../../util";
import { useFetch } from "../../utils/useFetch";
import { toAddUser } from "../../user/routes/AddUser";
import { toUser } from "../../user/routes/User";
import { UserDataTableToolbarItems } from "./UserDataTableToolbarItems";
import { SearchType } from "../../user/details/SearchFilter";
export type UserAttribute = {
name: string;

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

View file

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

View file

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

View file

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

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

View file

@ -1,5 +1,5 @@
import type ClientScopeRepresentation from "@keycloak/keycloak-admin-client/lib/defs/clientScopeRepresentation";
import type UserProfileConfig from "@keycloak/keycloak-admin-client/lib/defs/userProfileConfig";
import type { UserProfileConfig } from "@keycloak/keycloak-admin-client/lib/defs/userProfileConfig";
import {
Divider,
FormGroup,

View file

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

View file

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

View file

@ -1,8 +1,11 @@
import type { UserProfileAttribute } from "@keycloak/keycloak-admin-client/lib/defs/userProfileConfig";
import UserProfileConfig from "@keycloak/keycloak-admin-client/lib/defs/userProfileConfig";
import type {
UserProfileAttributeGroupMetadata,
UserProfileAttributeMetadata,
UserProfileMetadata,
} from "@keycloak/keycloak-admin-client/lib/defs/userProfileMetadata";
import { Text } from "@patternfly/react-core";
import { Fragment } from "react";
import { useFormContext } from "react-hook-form";
import { useMemo } from "react";
import { FieldPath, UseFormReturn } from "react-hook-form";
import { useTranslation } from "react-i18next";
import { ScrollForm } from "../components/scroll-form/ScrollForm";
@ -10,19 +13,15 @@ import { OptionComponent } from "./components/OptionsComponent";
import { SelectComponent } from "./components/SelectComponent";
import { TextAreaComponent } from "./components/TextAreaComponent";
import { TextComponent } from "./components/TextComponent";
import { fieldName } from "./utils";
type UserProfileFieldsProps = {
config: UserProfileConfig;
roles?: string[];
};
import { UserFormFields } from "./form-state";
import { fieldName, isRootAttribute } from "./utils";
export type UserProfileError = {
responseData: { errors?: { errorMessage: string }[] };
};
export type Options = {
options: string[] | undefined;
options?: string[];
};
export function isUserProfileError(error: unknown): error is UserProfileError {
@ -35,7 +34,7 @@ export function userProfileErrorToString(error: UserProfileError) {
);
}
const FieldTypes = [
const INPUT_TYPES = [
"text",
"textarea",
"select",
@ -53,10 +52,17 @@ const FieldTypes = [
"html5-time",
] as const;
export type Field = (typeof FieldTypes)[number];
export type InputType = (typeof INPUT_TYPES)[number];
export type UserProfileFieldProps = {
form: UseFormReturn<UserFormFields>;
inputType: InputType;
attribute: UserProfileAttributeMetadata;
roles: string[];
};
export const FIELDS: {
[index in Field]: (props: any) => JSX.Element;
[type in InputType]: (props: UserProfileFieldProps) => JSX.Element;
} = {
text: TextComponent,
textarea: TextAreaComponent,
@ -75,51 +81,129 @@ export const FIELDS: {
"html5-time": TextComponent,
} as const;
export const isValidComponentType = (value: string): value is Field =>
value in FIELDS;
export type UserProfileFieldsProps = {
form: UseFormReturn<UserFormFields>;
userProfileMetadata: UserProfileMetadata;
roles?: string[];
hideReadOnly?: boolean;
};
type GroupWithAttributes = {
group: UserProfileAttributeGroupMetadata;
attributes: UserProfileAttributeMetadata[];
};
export const UserProfileFields = ({
config,
form,
userProfileMetadata,
roles = ["admin"],
hideReadOnly = false,
}: UserProfileFieldsProps) => {
const { t } = useTranslation();
// Group attributes by group, for easier rendering.
const groupsWithAttributes = useMemo(() => {
// If there are no attributes, there is no need to group them.
if (!userProfileMetadata.attributes) {
return [];
}
// Hide read-only attributes if 'hideReadOnly' is enabled.
const attributes = hideReadOnly
? userProfileMetadata.attributes.filter(({ readOnly }) => !readOnly)
: userProfileMetadata.attributes;
return [
// Insert an empty group for attributes without a group.
{ name: undefined },
...(userProfileMetadata.groups ?? []),
].map<GroupWithAttributes>((group) => ({
group,
attributes: attributes.filter(
(attribute) => attribute.group === group.name,
),
}));
}, [
hideReadOnly,
userProfileMetadata.groups,
userProfileMetadata.attributes,
]);
if (groupsWithAttributes.length === 0) {
return null;
}
return (
<ScrollForm
sections={[{ name: "" }, ...(config.groups || [])].map((g) => ({
title: g.displayHeader || g.name || t("general"),
panel: (
<div className="pf-c-form">
{g.displayDescription && (
<Text className="pf-u-pb-lg">{g.displayDescription}</Text>
)}
{config.attributes?.map((attribute) => (
<Fragment key={attribute.name}>
{(attribute.group || "") === g.name && (
<FormField attribute={attribute} roles={roles} />
)}
</Fragment>
))}
</div>
),
}))}
sections={groupsWithAttributes
.filter((group) => group.attributes.length > 0)
.map(({ group, attributes }) => ({
title: group.displayHeader || group.name || t("general"),
panel: (
<div className="pf-c-form">
{group.displayDescription && (
<Text className="pf-u-pb-lg">{group.displayDescription}</Text>
)}
{attributes.map((attribute) => (
<FormField
key={attribute.name}
form={form}
attribute={attribute}
roles={roles}
/>
))}
</div>
),
}))}
/>
);
};
type FormFieldProps = {
attribute: UserProfileAttribute;
form: UseFormReturn<UserFormFields>;
attribute: UserProfileAttributeMetadata;
roles: string[];
};
const FormField = ({ attribute, roles }: FormFieldProps) => {
const { watch } = useFormContext();
const value = watch(fieldName(attribute));
const FormField = ({ form, attribute, roles }: FormFieldProps) => {
const value = form.watch(fieldName(attribute) as FieldPath<UserFormFields>);
const inputType = determineInputType(attribute, value);
const Component = FIELDS[inputType];
const componentType = (attribute.annotations?.["inputType"] ||
(Array.isArray(value) ? "multiselect" : "text")) as Field;
const Component = FIELDS[componentType];
return <Component {...{ ...attribute, roles }} />;
return (
<Component
form={form}
inputType={inputType}
attribute={attribute}
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 { Controller, useFormContext } from "react-hook-form";
import { UserProfileGroup } from "./UserProfileGroup";
import { Options } from "../UserProfileFields";
import { Controller, FieldPath } from "react-hook-form";
import { isRequiredAttribute } from "../utils/user-profile";
import { Options, UserProfileFieldProps } from "../UserProfileFields";
import { UserFormFields } from "../form-state";
import { fieldName } from "../utils";
import { UserProfileGroup } from "./UserProfileGroup";
export const OptionComponent = (attr: UserProfileAttribute) => {
const { control } = useFormContext();
const type = attr.annotations?.["inputType"] as string;
const isMultiSelect = type.includes("multiselect");
export const OptionComponent = ({
form,
inputType,
attribute,
}: UserProfileFieldProps) => {
const isRequired = isRequiredAttribute(attribute);
const isMultiSelect = inputType.startsWith("multiselect");
const Component = isMultiSelect ? Checkbox : Radio;
const options = (attr.validators?.options as Options).options || [];
const options = (attribute.validators?.options as Options).options || [];
return (
<UserProfileGroup {...attr}>
<UserProfileGroup form={form} attribute={attribute}>
<Controller
name={fieldName(attr)}
control={control}
name={fieldName(attribute) as FieldPath<UserFormFields>}
control={form.control}
defaultValue=""
render={({ field }) => (
<>
@ -42,7 +46,8 @@ export const OptionComponent = (attr: UserProfileAttribute) => {
field.onChange([option]);
}
}}
readOnly={attr.readOnly}
readOnly={attribute.readOnly}
isRequired={isRequired}
/>
))}
</>

View file

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

View file

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

View file

@ -1,25 +1,33 @@
import { UserProfileAttribute } from "@keycloak/keycloak-admin-client/lib/defs/userProfileConfig";
import { useFormContext } from "react-hook-form";
import { TextInputTypes } from "@patternfly/react-core";
import { FieldPath } from "react-hook-form";
import { KeycloakTextInput } from "ui-shared";
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 TextComponent = (attr: UserProfileAttribute) => {
const { register } = useFormContext();
const inputType = attr.annotations?.["inputType"] as string | undefined;
const type: any = inputType?.startsWith("html")
? inputType.substring("html".length + 2)
export const TextComponent = ({
form,
inputType,
attribute,
}: UserProfileFieldProps) => {
const isRequired = isRequiredAttribute(attribute);
const type = inputType.startsWith("html")
? (inputType.substring("html".length + 2) as TextInputTypes)
: "text";
return (
<UserProfileGroup {...attr}>
<UserProfileGroup form={form} attribute={attribute}>
<KeycloakTextInput
id={attr.name}
data-testid={attr.name}
id={attribute.name}
data-testid={attribute.name}
type={type}
placeholder={attr.annotations?.["inputTypePlaceholder"] as string}
readOnly={attr.readOnly}
{...register(fieldName(attr))}
placeholder={attribute.annotations?.["inputTypePlaceholder"] as string}
readOnly={attribute.readOnly}
isRequired={isRequired}
{...form.register(fieldName(attribute) as FieldPath<UserFormFields>)}
/>
</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 { PropsWithChildren } from "react";
import { useFormContext } from "react-hook-form";
import { UseFormReturn } from "react-hook-form";
import { useTranslation } from "react-i18next";
import { HelpItem } from "ui-shared";
import { label } from "../utils";
export type UserProfileFieldsProps = UserProfileAttribute & {
roles?: string[];
import { UserFormFields } from "../form-state";
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 = ({
form,
attribute,
children,
...attribute
}: PropsWithChildren<UserProfileFieldsProps>) => {
}: PropsWithChildren<UserProfileGroupProps>) => {
const { t } = useTranslation();
const helpText = attribute.annotations?.["inputHelperTextBefore"] as string;
const {
formState: { errors },
} = useFormContext();
} = form;
return (
<FormGroup
key={attribute.name}
label={label(attribute, t) || ""}
fieldId={attribute.name}
isRequired={isRequired(attribute)}
isRequired={isRequiredAttribute(attribute)}
validated={errors.username ? "error" : "default"}
helperTextInvalid={t("required")}
labelIcon={

View file

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

View file

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

View file

@ -82,12 +82,13 @@ export const ResetCredentialDialog = ({
isHorizontal
data-testid="credential-reset-modal"
>
<RequiredActionMultiSelect
control={control}
name="actions"
label="resetAction"
help="resetActions"
/>
<FormProvider {...form}>
<RequiredActionMultiSelect
name="actions"
label="resetAction"
help="resetActions"
/>
<LifespanField />
</FormProvider>
</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";
export const isBundleKey = (displayName?: string) =>
displayName?.includes("${");
export const unWrap = (key: string) => key.substring(2, key.length - 1);
export const label = (attribute: UserProfileAttribute, t: TFunction) =>
export const label = (attribute: UserProfileAttributeMetadata, t: TFunction) =>
(isBundleKey(attribute.displayName)
? t(unWrap(attribute.displayName!))
: attribute.displayName) || attribute.name;
const ROOT_ATTRIBUTES = ["username", "firstName", "lastName", "email"];
const isRootAttribute = (attr?: string) =>
export const isRootAttribute = (attr?: string) =>
attr && ROOT_ATTRIBUTES.includes(attr);
export const fieldName = (attribute: UserProfileAttribute) =>
`${isRootAttribute(attribute.name) ? "" : "attributes."}${attribute.name}`;
export const fieldName = (attribute: UserProfileAttributeMetadata) =>
isRootAttribute(attribute.name)
? attribute.name
: `attributes.${attribute.name}`;

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
export default interface UserProfileConfig {
export interface UserProfileConfig {
attributes?: UserProfileAttribute[];
groups?: UserProfileGroup[];
}
@ -7,11 +7,9 @@ export default interface UserProfileConfig {
// See: https://github.com/keycloak/keycloak/blob/main/services/src/main/java/org/keycloak/userprofile/config/UPAttribute.java
export interface UserProfileAttribute {
name?: string;
validations?: Record<string, unknown>;
validators?: Record<string, unknown>;
validations?: Record<string, Record<string, unknown>>;
annotations?: Record<string, unknown>;
required?: UserProfileAttributeRequired;
readOnly?: boolean;
permissions?: UserProfileAttributePermissions;
selector?: UserProfileAttributeSelector;
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 FederatedIdentityRepresentation from "./federatedIdentityRepresentation.js";
import type { RequiredActionAlias } from "./requiredActionProviderRepresentation.js";
import type UserProfileConfig from "./userProfileConfig.js";
import type UserConsentRepresentation from "./userConsentRepresentation.js";
import type { UserProfileMetadata } from "./userProfileMetadata.js";
export default interface UserRepresentation {
id?: string;
@ -31,5 +31,5 @@ export default interface UserRepresentation {
realmRoles?: string[];
self?: string;
serviceAccountClientId?: string;
userProfileMetadata?: UserProfileConfig;
userProfileMetadata?: UserProfileMetadata;
}

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 MappingsRepresentation from "../defs/mappingsRepresentation.js";
import type RoleRepresentation from "../defs/roleRepresentation.js";
import type { RoleMappingPayload } from "../defs/roleRepresentation.js";
import type { RequiredActionAlias } from "../defs/requiredActionProviderRepresentation.js";
import type CredentialRepresentation from "../defs/credentialRepresentation.js";
import type FederatedIdentityRepresentation from "../defs/federatedIdentityRepresentation.js";
import type GroupRepresentation from "../defs/groupRepresentation.js";
import type CredentialRepresentation from "../defs/credentialRepresentation.js";
import type UserProfileConfig from "../defs/userProfileConfig.js";
import type MappingsRepresentation from "../defs/mappingsRepresentation.js";
import type { RequiredActionAlias } from "../defs/requiredActionProviderRepresentation.js";
import type RoleRepresentation from "../defs/roleRepresentation.js";
import type { RoleMappingPayload } from "../defs/roleRepresentation.js";
import type UserConsentRepresentation from "../defs/userConsentRepresentation.js";
import type { UserProfileConfig } from "../defs/userProfileConfig.js";
import type { UserProfileMetadata } from "../defs/userProfileMetadata.js";
import type UserRepresentation from "../defs/userRepresentation.js";
import type UserSessionRepresentation from "../defs/userSessionRepresentation.js";
import Resource from "./resource.js";
interface SearchQuery {
search?: string;
@ -90,6 +91,11 @@ export class Users extends Resource<{ realm?: string }> {
},
);
public getProfileMetadata = this.makeRequest<{}, UserProfileMetadata>({
method: "GET",
path: "/profile/metadata",
});
/**
* role mappings
*/

View file

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

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) {
return new AttributeContext(context, session, attribute, user, metadata);
return new AttributeContext(context, session, attribute, user, metadata, this);
}
private AttributeContext createAttributeContext(String attributeName, AttributeMetadata metadata) {
return new AttributeContext(context, session, createAttribute(attributeName), user, metadata);
return new AttributeContext(context, session, createAttribute(attributeName), user, metadata, this);
}
protected AttributeContext createAttributeContext(AttributeMetadata metadata) {

View file

@ -19,6 +19,7 @@ package org.keycloak.userprofile;
import java.util.Map;
import org.keycloak.models.RealmModel;
import org.keycloak.models.UserModel;
import org.keycloak.provider.Provider;
@ -87,4 +88,12 @@ public interface UserProfileProvider extends Provider {
* @see #getConfiguration()
*/
void setConfiguration(String configuration);
/**
* Returns whether the declarative provider is enabled to a realm
*
* @deprecated should be removed once {@link DeclarativeUserProfileProvider} becomes the default.
* @return {@code true} if the declarative provider is enabled. Otherwise, {@code false}.
*/
boolean isEnabled(RealmModel realm);
}

View file

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

View file

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

View file

@ -16,6 +16,15 @@
*/
package org.keycloak.services.resources.admin;
import java.util.Collections;
import java.util.Comparator;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.function.Function;
import java.util.stream.Collectors;
import jakarta.ws.rs.Path;
import org.eclipse.microprofile.openapi.annotations.Operation;
import jakarta.ws.rs.Consumes;
import jakarta.ws.rs.GET;
@ -29,10 +38,22 @@ import org.eclipse.microprofile.openapi.annotations.tags.Tag;
import org.keycloak.component.ComponentValidationException;
import org.keycloak.models.KeycloakSession;
import org.keycloak.models.RealmModel;
import org.keycloak.provider.ConfiguredProvider;
import org.keycloak.representations.idm.UserProfileAttributeGroupMetadata;
import org.keycloak.representations.idm.UserProfileAttributeMetadata;
import org.keycloak.representations.idm.UserProfileMetadata;
import org.keycloak.services.ErrorResponse;
import org.keycloak.services.resources.KeycloakOpenAPI;
import org.keycloak.services.resources.admin.permissions.AdminPermissionEvaluator;
import org.keycloak.userprofile.AttributeMetadata;
import org.keycloak.userprofile.AttributeValidatorMetadata;
import org.keycloak.userprofile.UserProfile;
import org.keycloak.userprofile.UserProfileContext;
import org.keycloak.userprofile.UserProfileProvider;
import org.keycloak.userprofile.config.UPConfig;
import org.keycloak.userprofile.config.UPGroup;
import org.keycloak.util.JsonSerialization;
import org.keycloak.validate.Validators;
/**
* @author Vlastimil Elias <velias@redhat.com>
@ -60,6 +81,17 @@ public class UserProfileResource {
return session.getProvider(UserProfileProvider.class).getConfiguration();
}
@GET
@Path("/metadata")
@Produces(MediaType.APPLICATION_JSON)
@Tag(name = KeycloakOpenAPI.Admin.Tags.USERS)
@Operation()
public UserProfileMetadata getMetadata() {
auth.requireAnyAdminRole();
UserProfile profile = session.getProvider(UserProfileProvider.class).create(UserProfileContext.USER_API, Collections.emptyMap());
return createUserProfileMetadata(session, profile);
}
@PUT
@Consumes(MediaType.APPLICATION_JSON)
@Tag(name = KeycloakOpenAPI.Admin.Tags.USERS)
@ -78,4 +110,58 @@ public class UserProfileResource {
return Response.ok(t.getConfiguration()).type(MediaType.APPLICATION_JSON).build();
}
public static UserProfileMetadata createUserProfileMetadata(KeycloakSession session, UserProfile profile) {
Map<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_USERNAME;
import static org.keycloak.services.resources.admin.UserProfileResource.createUserProfileMetadata;
import static org.keycloak.userprofile.UserProfileContext.USER_API;
import static org.keycloak.utils.LockObjectsForModification.lockUserSessionsForModification;
@ -336,7 +337,7 @@ public class UserResource {
}
if (userProfileMetadata) {
rep.setUserProfileMetadata(createUserProfileMetadata(profile));
rep.setUserProfileMetadata(createUserProfileMetadata(session, profile));
}
return rep;
@ -1068,35 +1069,4 @@ public class UserResource {
rep.setLastAccess(Time.toMillis(clientSession.getTimestamp()));
return rep;
}
private UserProfileMetadata createUserProfileMetadata(final UserProfile profile) {
Map<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()) {
return false;
}
if (isNewUser(c)) {
// when creating a user the username is always editable
return true;
}
}
return realm.isEditUsernameAllowed();
@ -116,23 +121,15 @@ public abstract class AbstractUserProfileProvider<U extends UserProfileProvider>
private static boolean editEmailCondition(AttributeContext c) {
RealmModel realm = c.getSession().getContext().getRealm();
if (REGISTRATION_PROFILE.equals(c.getContext())) {
if (REGISTRATION_PROFILE.equals(c.getContext()) || USER_API.equals(c.getContext())) {
return true;
}
if (USER_API.equals(c.getContext())) {
if (realm.isRegistrationEmailAsUsername()) {
return true;
}
}
if (Profile.isFeatureEnabled(Feature.UPDATE_EMAIL)) {
return !(UPDATE_PROFILE.equals(c.getContext()) || ACCOUNT.equals(c.getContext()));
}
UserModel user = c.getUser();
if (user != null && realm.isRegistrationEmailAsUsername() && !realm.isEditUsernameAllowed()) {
if (!isNewUser(c) && realm.isRegistrationEmailAsUsername() && !realm.isEditUsernameAllowed()) {
return false;
}
@ -142,7 +139,7 @@ public abstract class AbstractUserProfileProvider<U extends UserProfileProvider>
private static boolean readEmailCondition(AttributeContext c) {
UserProfileContext context = c.getContext();
if (REGISTRATION_PROFILE.equals(context)) {
if (REGISTRATION_PROFILE.equals(context) || USER_API.equals(c.getContext())) {
return true;
}
@ -183,6 +180,10 @@ public abstract class AbstractUserProfileProvider<U extends UserProfileProvider>
return realm.isInternationalizationEnabled();
}
private static boolean isNewUser(AttributeContext c) {
return c.getUser() == null;
}
/**
* There are the declarations for creating the built-in validations for read-only attributes. Regardless of the context where
* user profiles are used. They are related to internal attributes with hard conditions on them in terms of management.

View file

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

View file

@ -19,6 +19,7 @@ package org.keycloak.userprofile.validator;
import static org.keycloak.validate.Validators.notBlankValidator;
import java.util.List;
import java.util.Objects;
import java.util.stream.Collectors;
import org.keycloak.common.util.CollectionUtil;
@ -57,7 +58,7 @@ public class ImmutableAttributeValidator implements SimpleValidator {
return context;
}
List<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;
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.validation.Validation;
import org.keycloak.userprofile.AttributeContext;
import org.keycloak.userprofile.Attributes;
import org.keycloak.userprofile.UserProfileAttributeValidationContext;
import org.keycloak.userprofile.UserProfileContext;
import org.keycloak.validate.SimpleValidator;
import org.keycloak.validate.ValidationContext;
import org.keycloak.validate.ValidationError;
@ -69,7 +69,8 @@ public class UsernameMutationValidator implements SimpleValidator {
if (! KeycloakModelUtils.isUsernameCaseSensitive(realm)) value = value.toLowerCase();
if (!realm.isEditUsernameAllowed() && user != null && !value.equals(user.getFirstAttribute(UserModel.USERNAME))) {
if (realm.isRegistrationEmailAsUsername() && UserProfileContext.UPDATE_PROFILE.equals(attributeContext.getContext())) {
Attributes attributes = attributeContext.getAttributes();
if (realm.isRegistrationEmailAsUsername() && value.equals(attributes.getFirstValue(UserModel.EMAIL))) {
// if username changed is because email as username is allowed so no validation should happen for update profile
// it is expected that username changes when attributes are normalized by the provider
return context;

View file

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

View file

@ -71,7 +71,7 @@ public class UserTestWithUserProfile extends UserTest {
assertNotNull(metadata);
for (String name : managedAttributes) {
assertNotNull(getAttributeMetadata(metadata, name));
assertNotNull(metadata.getAttributeMetadata(name));
}
}
@ -83,10 +83,10 @@ public class UserTestWithUserProfile extends UserTest {
UserRepresentation user = realm.users().get(userId).toRepresentation(true);
UserProfileMetadata metadata = user.getUserProfileMetadata();
assertNotNull(metadata);
UserProfileAttributeMetadata username = getAttributeMetadata(metadata, UserModel.USERNAME);
UserProfileAttributeMetadata username = metadata.getAttributeMetadata(UserModel.USERNAME);
assertNotNull(username);
assertTrue(username.isReadOnly());
UserProfileAttributeMetadata email = getAttributeMetadata(metadata, UserModel.EMAIL);
UserProfileAttributeMetadata email = metadata.getAttributeMetadata(UserModel.EMAIL);
assertNotNull(email);
assertFalse(email.isReadOnly());
}
@ -101,26 +101,14 @@ public class UserTestWithUserProfile extends UserTest {
UserRepresentation user = realm.users().get(userId).toRepresentation(true);
UserProfileMetadata metadata = user.getUserProfileMetadata();
assertNotNull(metadata);
UserProfileAttributeMetadata username = getAttributeMetadata(metadata, UserModel.USERNAME);
UserProfileAttributeMetadata username = metadata.getAttributeMetadata(UserModel.USERNAME);
assertNotNull(username);
assertTrue(username.isReadOnly());
UserProfileAttributeMetadata email = getAttributeMetadata(metadata, UserModel.EMAIL);
UserProfileAttributeMetadata email = metadata.getAttributeMetadata(UserModel.EMAIL);
assertNotNull(email);
assertFalse(email.isReadOnly());
}
@Nullable
private static UserProfileAttributeMetadata getAttributeMetadata(UserProfileMetadata metadata, String name) {
UserProfileAttributeMetadata attrMetadata = null;
for (UserProfileAttributeMetadata m : metadata.getAttributes()) {
if (name.equals(m.getName())) {
attrMetadata = m;
}
}
return attrMetadata;
}
private UPAttribute createAttributeMetadata(String name) {
UPAttribute attribute = new UPAttribute();
attribute.setName(name);

View file

@ -20,18 +20,31 @@
package org.keycloak.testsuite.admin.userprofile;
import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertFalse;
import static org.junit.Assert.assertNotNull;
import static org.junit.Assert.assertTrue;
import static org.keycloak.userprofile.DeclarativeUserProfileProvider.REALM_USER_PROFILE_ENABLED;
import static org.keycloak.userprofile.config.UPConfigUtils.readDefaultConfig;
import java.io.IOException;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import org.junit.Test;
import org.keycloak.admin.client.resource.RealmResource;
import org.keycloak.admin.client.resource.UserProfileResource;
import org.keycloak.common.Profile;
import org.keycloak.models.UserModel;
import org.keycloak.representations.idm.RealmRepresentation;
import org.keycloak.representations.idm.UserProfileAttributeGroupMetadata;
import org.keycloak.representations.idm.UserProfileMetadata;
import org.keycloak.testsuite.admin.AbstractAdminTest;
import org.keycloak.testsuite.arquillian.annotation.EnableFeature;
import org.keycloak.userprofile.config.UPAttribute;
import org.keycloak.userprofile.config.UPConfig;
import org.keycloak.userprofile.config.UPGroup;
import org.keycloak.util.JsonSerialization;
/**
* @author <a href="mailto:psilva@redhat.com">Pedro Igor</a>
@ -53,12 +66,111 @@ public class UserProfileAdminTest extends AbstractAdminTest {
}
@Test
public void testSetDefaultConfig() throws IOException {
public void testSetDefaultConfig() {
String rawConfig = "{\"attributes\": [{\"name\": \"test\"}]}";
UserProfileResource userProfile = testRealm().users().userProfile();
userProfile.update(rawConfig);
getCleanup().addCleanup(() -> testRealm().users().userProfile().update(null));
assertEquals(rawConfig, userProfile.getConfiguration());
}
@Test
public void testEmailRequiredIfEmailAsUsernameEnabled() {
RealmResource realm = testRealm();
RealmRepresentation realmRep = realm.toRepresentation();
Boolean registrationEmailAsUsername = realmRep.isRegistrationEmailAsUsername();
realmRep.setRegistrationEmailAsUsername(true);
realm.update(realmRep);
getCleanup().addCleanup(() -> {
realmRep.setRegistrationEmailAsUsername(registrationEmailAsUsername);
realm.update(realmRep);
});
UserProfileResource userProfile = realm.users().userProfile();
UserProfileMetadata metadata = userProfile.getMetadata();
assertTrue(metadata.getAttributeMetadata(UserModel.EMAIL).isRequired());
}
@Test
public void testEmailNotRequiredIfEmailAsUsernameDisabled() {
RealmResource realm = testRealm();
RealmRepresentation realmRep = realm.toRepresentation();
Boolean registrationEmailAsUsername = realmRep.isRegistrationEmailAsUsername();
realmRep.setRegistrationEmailAsUsername(false);
realm.update(realmRep);
getCleanup().addCleanup(() -> {
realmRep.setRegistrationEmailAsUsername(registrationEmailAsUsername);
realm.update(realmRep);
});
UserProfileResource userProfile = realm.users().userProfile();
UserProfileMetadata metadata = userProfile.getMetadata();
assertFalse(metadata.getAttributeMetadata(UserModel.EMAIL).isRequired());
}
@Test
public void testUsernameRequiredIfEmailAsUsernameDisabled() {
RealmResource realm = testRealm();
RealmRepresentation realmRep = realm.toRepresentation();
Boolean registrationEmailAsUsername = realmRep.isRegistrationEmailAsUsername();
realmRep.setRegistrationEmailAsUsername(false);
realm.update(realmRep);
getCleanup().addCleanup(() -> {
realmRep.setRegistrationEmailAsUsername(registrationEmailAsUsername);
realm.update(realmRep);
});
UserProfileResource userProfile = realm.users().userProfile();
UserProfileMetadata metadata = userProfile.getMetadata();
assertTrue(metadata.getAttributeMetadata(UserModel.USERNAME).isRequired());
}
@Test
public void testUsernameNotRequiredIfEmailAsUsernameEnabled() {
RealmResource realm = testRealm();
RealmRepresentation realmRep = realm.toRepresentation();
Boolean registrationEmailAsUsername = realmRep.isRegistrationEmailAsUsername();
realmRep.setRegistrationEmailAsUsername(true);
realm.update(realmRep);
getCleanup().addCleanup(() -> {
realmRep.setRegistrationEmailAsUsername(registrationEmailAsUsername);
realm.update(realmRep);
});
UserProfileResource userProfile = realm.users().userProfile();
UserProfileMetadata metadata = userProfile.getMetadata();
assertFalse(metadata.getAttributeMetadata(UserModel.USERNAME).isRequired());
}
@Test
public void testGroupsMetadata() throws IOException {
UPConfig config = JsonSerialization.readValue(testRealm().users().userProfile().getConfiguration(), UPConfig.class);
for (int i = 0; i < 3; i++) {
UPGroup group = new UPGroup();
group.setName("name-" + i);
group.setDisplayHeader("displayHeader-" + i);
group.setDisplayDescription("displayDescription-" + i);
group.setAnnotations(Map.of("k1", "v1", "k2", "v2", "k3", "v3"));
config.addGroup(group);
}
UPAttribute firstName = config.getAttribute(UserModel.FIRST_NAME);
firstName.setGroup(config.getGroups().get(0).getName());
UserProfileResource userProfile = testRealm().users().userProfile();
userProfile.update(JsonSerialization.writeValueAsString(config));
getCleanup().addCleanup(() -> testRealm().users().userProfile().update(null));
UserProfileMetadata metadata = testRealm().users().userProfile().getMetadata();
List<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();
}
@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
public void testReadOnlyInternalAttributeValidation() {
getTestingClient().server(TEST_REALM_NAME).run((RunOnServer) UserProfileTest::testReadOnlyInternalAttributeValidation);