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:
parent
890600c33c
commit
290bee0787
49 changed files with 1067 additions and 488 deletions
|
@ -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;
|
||||
}
|
||||
}
|
|
@ -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.
|
||||
*/
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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);
|
||||
|
|
|
@ -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({
|
||||
|
|
|
@ -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" };
|
||||
|
|
|
@ -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();
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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"];
|
||||
|
||||
|
|
|
@ -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";
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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>
|
||||
</>
|
||||
);
|
||||
|
|
|
@ -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>}
|
||||
|
|
|
@ -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,
|
||||
})
|
||||
}
|
||||
/>
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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}
|
||||
/>
|
||||
))}
|
||||
</>
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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>
|
||||
);
|
||||
|
|
|
@ -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>
|
||||
);
|
||||
|
|
|
@ -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={
|
||||
|
|
|
@ -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 };
|
||||
}
|
||||
|
|
|
@ -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([]);
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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}`;
|
||||
|
|
33
js/apps/admin-ui/src/user/utils/user-profile.ts
Normal file
33
js/apps/admin-ui/src/user/utils/user-profile.ts
Normal 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;
|
||||
}
|
|
@ -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;
|
||||
|
|
|
@ -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>;
|
||||
}
|
|
@ -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;
|
||||
}
|
||||
|
|
|
@ -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
|
||||
*/
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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) {
|
||||
|
|
|
@ -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);
|
||||
}
|
||||
|
|
|
@ -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);
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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));
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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));
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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.
|
||||
|
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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)) {
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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);
|
||||
|
|
|
@ -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);
|
||||
|
|
|
@ -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());
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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);
|
||||
|
|
Loading…
Reference in a new issue