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 boolean readOnly;
|
||||||
private Map<String, Object> annotations;
|
private Map<String, Object> annotations;
|
||||||
private Map<String, Map<String, Object>> validators;
|
private Map<String, Map<String, Object>> validators;
|
||||||
|
private String group;
|
||||||
|
|
||||||
public UserProfileAttributeMetadata() {
|
public UserProfileAttributeMetadata() {
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public UserProfileAttributeMetadata(String name, String displayName, boolean required, boolean readOnly, Map<String, Object> annotations,
|
public UserProfileAttributeMetadata(String name, String displayName, boolean required, boolean readOnly, String group, Map<String, Object> annotations,
|
||||||
Map<String, Map<String, Object>> validators) {
|
Map<String, Map<String, Object>> validators) {
|
||||||
this.name = name;
|
this.name = name;
|
||||||
this.displayName = displayName;
|
this.displayName = displayName;
|
||||||
|
@ -42,6 +43,7 @@ public class UserProfileAttributeMetadata {
|
||||||
this.readOnly = readOnly;
|
this.readOnly = readOnly;
|
||||||
this.annotations = annotations;
|
this.annotations = annotations;
|
||||||
this.validators = validators;
|
this.validators = validators;
|
||||||
|
this.group = group;
|
||||||
}
|
}
|
||||||
|
|
||||||
public String getName() {
|
public String getName() {
|
||||||
|
@ -63,6 +65,10 @@ public class UserProfileAttributeMetadata {
|
||||||
return readOnly;
|
return readOnly;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public String getGroup() {
|
||||||
|
return group;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get info about attribute annotations loaded from UserProfile configuration.
|
* Get info about attribute annotations loaded from UserProfile configuration.
|
||||||
*/
|
*/
|
||||||
|
|
|
@ -16,7 +16,10 @@
|
||||||
*/
|
*/
|
||||||
package org.keycloak.representations.idm;
|
package org.keycloak.representations.idm;
|
||||||
|
|
||||||
|
import static java.util.Collections.emptyList;
|
||||||
|
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
|
import java.util.Optional;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @author Vlastimil Elias <velias@redhat.com>
|
* @author Vlastimil Elias <velias@redhat.com>
|
||||||
|
@ -24,22 +27,47 @@ import java.util.List;
|
||||||
public class UserProfileMetadata {
|
public class UserProfileMetadata {
|
||||||
|
|
||||||
private List<UserProfileAttributeMetadata> attributes;
|
private List<UserProfileAttributeMetadata> attributes;
|
||||||
|
private List<UserProfileAttributeGroupMetadata> groups;
|
||||||
|
|
||||||
public UserProfileMetadata() {
|
public UserProfileMetadata() {
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public UserProfileMetadata(List<UserProfileAttributeMetadata> attributes) {
|
public UserProfileMetadata(List<UserProfileAttributeMetadata> attributes, List<UserProfileAttributeGroupMetadata> groups) {
|
||||||
super();
|
super();
|
||||||
this.attributes = attributes;
|
this.attributes = attributes;
|
||||||
|
this.groups = groups;
|
||||||
}
|
}
|
||||||
|
|
||||||
public List<UserProfileAttributeMetadata> getAttributes() {
|
public List<UserProfileAttributeMetadata> getAttributes() {
|
||||||
return attributes;
|
return attributes;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public List<UserProfileAttributeGroupMetadata> getGroups() {
|
||||||
|
return groups;
|
||||||
|
}
|
||||||
|
|
||||||
public void setAttributes(List<UserProfileAttributeMetadata> attributes) {
|
public void setAttributes(List<UserProfileAttributeMetadata> attributes) {
|
||||||
this.attributes = attributes;
|
this.attributes = attributes;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public UserProfileAttributeMetadata getAttributeMetadata(String name) {
|
||||||
|
for (UserProfileAttributeMetadata m : Optional.ofNullable(getAttributes()).orElse(emptyList())) {
|
||||||
|
if (m.getName().equals(name)) {
|
||||||
|
return m;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
public UserProfileAttributeGroupMetadata getAttributeGroupMetadata(String name) {
|
||||||
|
for (UserProfileAttributeGroupMetadata m : Optional.ofNullable(getGroups()).orElse(emptyList())) {
|
||||||
|
if (m.getName().equals(name)) {
|
||||||
|
return m;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -19,10 +19,13 @@ package org.keycloak.admin.client.resource;
|
||||||
import javax.ws.rs.Consumes;
|
import javax.ws.rs.Consumes;
|
||||||
import javax.ws.rs.GET;
|
import javax.ws.rs.GET;
|
||||||
import javax.ws.rs.PUT;
|
import javax.ws.rs.PUT;
|
||||||
|
import javax.ws.rs.Path;
|
||||||
import javax.ws.rs.Produces;
|
import javax.ws.rs.Produces;
|
||||||
import javax.ws.rs.core.MediaType;
|
import javax.ws.rs.core.MediaType;
|
||||||
import javax.ws.rs.core.Response;
|
import javax.ws.rs.core.Response;
|
||||||
|
|
||||||
|
import org.keycloak.representations.idm.UserProfileMetadata;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @author Vlastimil Elias <velias@redhat.com>
|
* @author Vlastimil Elias <velias@redhat.com>
|
||||||
*/
|
*/
|
||||||
|
@ -34,6 +37,11 @@ public interface UserProfileResource {
|
||||||
@Consumes(MediaType.APPLICATION_JSON)
|
@Consumes(MediaType.APPLICATION_JSON)
|
||||||
String getConfiguration();
|
String getConfiguration();
|
||||||
|
|
||||||
|
@GET
|
||||||
|
@Path("/metadata")
|
||||||
|
@Consumes(jakarta.ws.rs.core.MediaType.APPLICATION_JSON)
|
||||||
|
UserProfileMetadata getMetadata();
|
||||||
|
|
||||||
@PUT
|
@PUT
|
||||||
@Produces(MediaType.APPLICATION_JSON)
|
@Produces(MediaType.APPLICATION_JSON)
|
||||||
Response update(String text);
|
Response update(String text);
|
||||||
|
|
|
@ -1,6 +1,6 @@
|
||||||
import KeycloakAdminClient from "@keycloak/keycloak-admin-client";
|
import KeycloakAdminClient from "@keycloak/keycloak-admin-client";
|
||||||
import RealmRepresentation from "@keycloak/keycloak-admin-client/lib/defs/realmRepresentation";
|
import RealmRepresentation from "@keycloak/keycloak-admin-client/lib/defs/realmRepresentation";
|
||||||
import type UserProfileConfig from "@keycloak/keycloak-admin-client/lib/defs/userProfileConfig";
|
import type { UserProfileConfig } from "@keycloak/keycloak-admin-client/lib/defs/userProfileConfig";
|
||||||
import UserRepresentation from "@keycloak/keycloak-admin-client/lib/defs/userRepresentation";
|
import UserRepresentation from "@keycloak/keycloak-admin-client/lib/defs/userRepresentation";
|
||||||
|
|
||||||
const adminClient = new KeycloakAdminClient({
|
const adminClient = new KeycloakAdminClient({
|
||||||
|
|
|
@ -1,10 +1,10 @@
|
||||||
import type UserProfileConfig from "@keycloak/keycloak-admin-client/lib/defs/userProfileConfig";
|
import type { UserProfileConfig } from "@keycloak/keycloak-admin-client/lib/defs/userProfileConfig";
|
||||||
import { expect, test } from "@playwright/test";
|
import { expect, test } from "@playwright/test";
|
||||||
import {
|
import {
|
||||||
enableLocalization,
|
|
||||||
importUserProfile,
|
|
||||||
createUser,
|
createUser,
|
||||||
deleteUser,
|
deleteUser,
|
||||||
|
enableLocalization,
|
||||||
|
importUserProfile,
|
||||||
} from "../admin-client";
|
} from "../admin-client";
|
||||||
import { login } from "../login";
|
import { login } from "../login";
|
||||||
import userProfileConfig from "./user-profile.json" assert { type: "json" };
|
import userProfileConfig from "./user-profile.json" assert { type: "json" };
|
||||||
|
|
|
@ -1,9 +1,9 @@
|
||||||
|
import RealmRepresentation from "@keycloak/keycloak-admin-client/lib/defs/realmRepresentation";
|
||||||
import LoginPage from "../support/pages/LoginPage";
|
import LoginPage from "../support/pages/LoginPage";
|
||||||
import SidebarPage from "../support/pages/admin-ui/SidebarPage";
|
import SidebarPage from "../support/pages/admin-ui/SidebarPage";
|
||||||
|
import ProviderPage from "../support/pages/admin-ui/manage/providers/ProviderPage";
|
||||||
import adminClient from "../support/util/AdminClient";
|
import adminClient from "../support/util/AdminClient";
|
||||||
import { keycloakBefore } from "../support/util/keycloak_hooks";
|
import { keycloakBefore } from "../support/util/keycloak_hooks";
|
||||||
import ProviderPage from "../support/pages/admin-ui/manage/providers/ProviderPage";
|
|
||||||
import RealmRepresentation from "libs/keycloak-admin-client/lib/defs/realmRepresentation";
|
|
||||||
|
|
||||||
const loginPage = new LoginPage();
|
const loginPage = new LoginPage();
|
||||||
const sidebarPage = new SidebarPage();
|
const sidebarPage = new SidebarPage();
|
||||||
|
|
|
@ -1,11 +1,11 @@
|
||||||
import KeycloakAdminClient from "@keycloak/keycloak-admin-client";
|
import KeycloakAdminClient from "@keycloak/keycloak-admin-client";
|
||||||
import type UserRepresentation from "@keycloak/keycloak-admin-client/lib/defs/userRepresentation";
|
|
||||||
import type ClientRepresentation from "@keycloak/keycloak-admin-client/lib/defs/clientRepresentation";
|
import type ClientRepresentation from "@keycloak/keycloak-admin-client/lib/defs/clientRepresentation";
|
||||||
import type ClientScopeRepresentation from "@keycloak/keycloak-admin-client/lib/defs/clientScopeRepresentation";
|
import type ClientScopeRepresentation from "@keycloak/keycloak-admin-client/lib/defs/clientScopeRepresentation";
|
||||||
import type RealmRepresentation from "@keycloak/keycloak-admin-client/lib/defs/realmRepresentation";
|
import type RealmRepresentation from "@keycloak/keycloak-admin-client/lib/defs/realmRepresentation";
|
||||||
import type UserProfileConfig from "@keycloak/keycloak-admin-client/lib/defs/userProfileConfig";
|
|
||||||
import type RoleRepresentation from "@keycloak/keycloak-admin-client/lib/defs/roleRepresentation";
|
import type RoleRepresentation from "@keycloak/keycloak-admin-client/lib/defs/roleRepresentation";
|
||||||
import type { RoleMappingPayload } from "@keycloak/keycloak-admin-client/lib/defs/roleRepresentation";
|
import type { RoleMappingPayload } from "@keycloak/keycloak-admin-client/lib/defs/roleRepresentation";
|
||||||
|
import type { UserProfileConfig } from "@keycloak/keycloak-admin-client/lib/defs/userProfileConfig";
|
||||||
|
import type UserRepresentation from "@keycloak/keycloak-admin-client/lib/defs/userRepresentation";
|
||||||
import { merge } from "lodash-es";
|
import { merge } from "lodash-es";
|
||||||
|
|
||||||
class AdminClient {
|
class AdminClient {
|
||||||
|
|
|
@ -1,6 +1,6 @@
|
||||||
import type ComponentRepresentation from "@keycloak/keycloak-admin-client/lib/defs/componentRepresentation";
|
import type ComponentRepresentation from "@keycloak/keycloak-admin-client/lib/defs/componentRepresentation";
|
||||||
import type RealmRepresentation from "@keycloak/keycloak-admin-client/lib/defs/realmRepresentation";
|
import type RealmRepresentation from "@keycloak/keycloak-admin-client/lib/defs/realmRepresentation";
|
||||||
import type UserProfileConfig from "@keycloak/keycloak-admin-client/lib/defs/userProfileConfig";
|
import type { UserProfileConfig } from "@keycloak/keycloak-admin-client/lib/defs/userProfileConfig";
|
||||||
import type UserRepresentation from "@keycloak/keycloak-admin-client/lib/defs/userRepresentation";
|
import type UserRepresentation from "@keycloak/keycloak-admin-client/lib/defs/userRepresentation";
|
||||||
import {
|
import {
|
||||||
AlertVariant,
|
AlertVariant,
|
||||||
|
@ -29,19 +29,19 @@ import { useTranslation } from "react-i18next";
|
||||||
import { Link, useNavigate } from "react-router-dom";
|
import { Link, useNavigate } from "react-router-dom";
|
||||||
|
|
||||||
import { adminClient } from "../../admin-client";
|
import { adminClient } from "../../admin-client";
|
||||||
|
import { useRealm } from "../../context/realm-context/RealmContext";
|
||||||
|
import { SearchType } from "../../user/details/SearchFilter";
|
||||||
|
import { toAddUser } from "../../user/routes/AddUser";
|
||||||
|
import { toUser } from "../../user/routes/User";
|
||||||
|
import { emptyFormatter } from "../../util";
|
||||||
|
import { useFetch } from "../../utils/useFetch";
|
||||||
import { useAlerts } from "../alert/Alerts";
|
import { useAlerts } from "../alert/Alerts";
|
||||||
import { useConfirmDialog } from "../confirm-dialog/ConfirmDialog";
|
import { useConfirmDialog } from "../confirm-dialog/ConfirmDialog";
|
||||||
import { KeycloakSpinner } from "../keycloak-spinner/KeycloakSpinner";
|
import { KeycloakSpinner } from "../keycloak-spinner/KeycloakSpinner";
|
||||||
import { ListEmptyState } from "../list-empty-state/ListEmptyState";
|
import { ListEmptyState } from "../list-empty-state/ListEmptyState";
|
||||||
import { BruteUser, findUsers } from "../role-mapping/resource";
|
import { BruteUser, findUsers } from "../role-mapping/resource";
|
||||||
import { KeycloakDataTable } from "../table-toolbar/KeycloakDataTable";
|
import { KeycloakDataTable } from "../table-toolbar/KeycloakDataTable";
|
||||||
import { useRealm } from "../../context/realm-context/RealmContext";
|
|
||||||
import { emptyFormatter } from "../../util";
|
|
||||||
import { useFetch } from "../../utils/useFetch";
|
|
||||||
import { toAddUser } from "../../user/routes/AddUser";
|
|
||||||
import { toUser } from "../../user/routes/User";
|
|
||||||
import { UserDataTableToolbarItems } from "./UserDataTableToolbarItems";
|
import { UserDataTableToolbarItems } from "./UserDataTableToolbarItems";
|
||||||
import { SearchType } from "../../user/details/SearchFilter";
|
|
||||||
|
|
||||||
export type UserAttribute = {
|
export type UserAttribute = {
|
||||||
name: string;
|
name: string;
|
||||||
|
|
|
@ -1,4 +1,4 @@
|
||||||
import type UserProfileConfig from "@keycloak/keycloak-admin-client/lib/defs/userProfileConfig";
|
import type { UserProfileConfig } from "@keycloak/keycloak-admin-client/lib/defs/userProfileConfig";
|
||||||
import {
|
import {
|
||||||
ActionGroup,
|
ActionGroup,
|
||||||
Alert,
|
Alert,
|
||||||
|
|
|
@ -1,5 +1,5 @@
|
||||||
import type RealmRepresentation from "@keycloak/keycloak-admin-client/lib/defs/realmRepresentation";
|
import type RealmRepresentation from "@keycloak/keycloak-admin-client/lib/defs/realmRepresentation";
|
||||||
import type UserProfileConfig from "@keycloak/keycloak-admin-client/lib/defs/userProfileConfig";
|
import type { UserProfileConfig } from "@keycloak/keycloak-admin-client/lib/defs/userProfileConfig";
|
||||||
import {
|
import {
|
||||||
Button,
|
Button,
|
||||||
ButtonVariant,
|
ButtonVariant,
|
||||||
|
@ -11,13 +11,14 @@ import {
|
||||||
SearchInput,
|
SearchInput,
|
||||||
ToolbarItem,
|
ToolbarItem,
|
||||||
} from "@patternfly/react-core";
|
} from "@patternfly/react-core";
|
||||||
|
import { ArrowRightIcon } from "@patternfly/react-icons";
|
||||||
import { ReactNode, useState } from "react";
|
import { ReactNode, useState } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
|
|
||||||
import { useAccess } from "../../context/access/Access";
|
import { useAccess } from "../../context/access/Access";
|
||||||
import { UserDataTableAttributeSearchForm } from "./UserDataTableAttributeSearchForm";
|
|
||||||
import { ArrowRightIcon } from "@patternfly/react-icons";
|
|
||||||
import { SearchDropdown, SearchType } from "../../user/details/SearchFilter";
|
import { SearchDropdown, SearchType } from "../../user/details/SearchFilter";
|
||||||
import { UserAttribute } from "./UserDataTable";
|
import { UserAttribute } from "./UserDataTable";
|
||||||
|
import { UserDataTableAttributeSearchForm } from "./UserDataTableAttributeSearchForm";
|
||||||
|
|
||||||
type UserDataTableToolbarItemsProps = {
|
type UserDataTableToolbarItemsProps = {
|
||||||
realm: RealmRepresentation;
|
realm: RealmRepresentation;
|
||||||
|
|
|
@ -1,5 +1,7 @@
|
||||||
import type UserProfileConfig from "@keycloak/keycloak-admin-client/lib/defs/userProfileConfig";
|
import type {
|
||||||
import type { UserProfileAttribute } from "@keycloak/keycloak-admin-client/lib/defs/userProfileConfig";
|
UserProfileAttribute,
|
||||||
|
UserProfileConfig,
|
||||||
|
} from "@keycloak/keycloak-admin-client/lib/defs/userProfileConfig";
|
||||||
import {
|
import {
|
||||||
AlertVariant,
|
AlertVariant,
|
||||||
Button,
|
Button,
|
||||||
|
|
|
@ -1,5 +1,4 @@
|
||||||
import { useState } from "react";
|
import type { UserProfileAttribute } from "@keycloak/keycloak-admin-client/lib/defs/userProfileConfig";
|
||||||
import { useTranslation } from "react-i18next";
|
|
||||||
import {
|
import {
|
||||||
Button,
|
Button,
|
||||||
ButtonVariant,
|
ButtonVariant,
|
||||||
|
@ -12,17 +11,18 @@ import {
|
||||||
ToolbarItem,
|
ToolbarItem,
|
||||||
} from "@patternfly/react-core";
|
} from "@patternfly/react-core";
|
||||||
import { FilterIcon } from "@patternfly/react-icons";
|
import { FilterIcon } from "@patternfly/react-icons";
|
||||||
|
import { useState } from "react";
|
||||||
import { KeycloakSpinner } from "../../components/keycloak-spinner/KeycloakSpinner";
|
import { useTranslation } from "react-i18next";
|
||||||
import { DraggableTable } from "../../authentication/components/DraggableTable";
|
|
||||||
import { Link, useNavigate } from "react-router-dom";
|
import { Link, useNavigate } from "react-router-dom";
|
||||||
import { toAddAttribute } from "../routes/AddAttribute";
|
|
||||||
import { useRealm } from "../../context/realm-context/RealmContext";
|
import { DraggableTable } from "../../authentication/components/DraggableTable";
|
||||||
import { useUserProfile } from "./UserProfileContext";
|
|
||||||
import { useConfirmDialog } from "../../components/confirm-dialog/ConfirmDialog";
|
import { useConfirmDialog } from "../../components/confirm-dialog/ConfirmDialog";
|
||||||
import { toAttribute } from "../routes/Attribute";
|
import { KeycloakSpinner } from "../../components/keycloak-spinner/KeycloakSpinner";
|
||||||
import type { UserProfileAttribute } from "@keycloak/keycloak-admin-client/lib/defs/userProfileConfig";
|
import { useRealm } from "../../context/realm-context/RealmContext";
|
||||||
import useToggle from "../../utils/useToggle";
|
import useToggle from "../../utils/useToggle";
|
||||||
|
import { toAddAttribute } from "../routes/AddAttribute";
|
||||||
|
import { toAttribute } from "../routes/Attribute";
|
||||||
|
import { useUserProfile } from "./UserProfileContext";
|
||||||
|
|
||||||
const RESTRICTED_ATTRIBUTES = ["username", "email"];
|
const RESTRICTED_ATTRIBUTES = ["username", "email"];
|
||||||
|
|
||||||
|
|
|
@ -1,4 +1,4 @@
|
||||||
import type UserProfileConfig from "@keycloak/keycloak-admin-client/lib/defs/userProfileConfig";
|
import type { UserProfileConfig } from "@keycloak/keycloak-admin-client/lib/defs/userProfileConfig";
|
||||||
import { AlertVariant } from "@patternfly/react-core";
|
import { AlertVariant } from "@patternfly/react-core";
|
||||||
import { PropsWithChildren, useState } from "react";
|
import { PropsWithChildren, useState } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
|
|
|
@ -1,5 +1,5 @@
|
||||||
import type ClientScopeRepresentation from "@keycloak/keycloak-admin-client/lib/defs/clientScopeRepresentation";
|
import type ClientScopeRepresentation from "@keycloak/keycloak-admin-client/lib/defs/clientScopeRepresentation";
|
||||||
import type UserProfileConfig from "@keycloak/keycloak-admin-client/lib/defs/userProfileConfig";
|
import type { UserProfileConfig } from "@keycloak/keycloak-admin-client/lib/defs/userProfileConfig";
|
||||||
import {
|
import {
|
||||||
Divider,
|
Divider,
|
||||||
FormGroup,
|
FormGroup,
|
||||||
|
|
|
@ -1,15 +1,19 @@
|
||||||
import type GroupRepresentation from "@keycloak/keycloak-admin-client/lib/defs/groupRepresentation";
|
import type GroupRepresentation from "@keycloak/keycloak-admin-client/lib/defs/groupRepresentation";
|
||||||
|
import RealmRepresentation from "@keycloak/keycloak-admin-client/lib/defs/realmRepresentation";
|
||||||
|
import type { UserProfileMetadata } from "@keycloak/keycloak-admin-client/lib/defs/userProfileMetadata";
|
||||||
import { AlertVariant, PageSection } from "@patternfly/react-core";
|
import { AlertVariant, PageSection } from "@patternfly/react-core";
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { FormProvider, useForm } from "react-hook-form";
|
import { useForm } from "react-hook-form";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { useNavigate } from "react-router-dom";
|
import { useNavigate } from "react-router-dom";
|
||||||
|
|
||||||
import { adminClient } from "../admin-client";
|
import { adminClient } from "../admin-client";
|
||||||
import { useAlerts } from "../components/alert/Alerts";
|
import { useAlerts } from "../components/alert/Alerts";
|
||||||
|
import { KeycloakSpinner } from "../components/keycloak-spinner/KeycloakSpinner";
|
||||||
import { ViewHeader } from "../components/view-header/ViewHeader";
|
import { ViewHeader } from "../components/view-header/ViewHeader";
|
||||||
import { useRealm } from "../context/realm-context/RealmContext";
|
import { useRealm } from "../context/realm-context/RealmContext";
|
||||||
import { UserProfileProvider } from "../realm-settings/user-profile/UserProfileContext";
|
import { useFetch } from "../utils/useFetch";
|
||||||
|
import useIsFeatureEnabled, { Feature } from "../utils/useIsFeatureEnabled";
|
||||||
import { UserForm } from "./UserForm";
|
import { UserForm } from "./UserForm";
|
||||||
import {
|
import {
|
||||||
isUserProfileError,
|
isUserProfileError,
|
||||||
|
@ -19,23 +23,40 @@ import { UserFormFields, toUserRepresentation } from "./form-state";
|
||||||
import { toUser } from "./routes/User";
|
import { toUser } from "./routes/User";
|
||||||
|
|
||||||
import "./user-section.css";
|
import "./user-section.css";
|
||||||
import RealmRepresentation from "@keycloak/keycloak-admin-client/lib/defs/realmRepresentation";
|
|
||||||
import { useFetch } from "../utils/useFetch";
|
|
||||||
|
|
||||||
export default function CreateUser() {
|
export default function CreateUser() {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const { addAlert, addError } = useAlerts();
|
const { addAlert, addError } = useAlerts();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const { realm } = useRealm();
|
const { realm: realmName } = useRealm();
|
||||||
const userForm = useForm<UserFormFields>({ mode: "onChange" });
|
const isFeatureEnabled = useIsFeatureEnabled();
|
||||||
|
const form = useForm<UserFormFields>({ mode: "onChange" });
|
||||||
const [addedGroups, setAddedGroups] = useState<GroupRepresentation[]>([]);
|
const [addedGroups, setAddedGroups] = useState<GroupRepresentation[]>([]);
|
||||||
|
const [realm, setRealm] = useState<RealmRepresentation>();
|
||||||
const [realmRepresentation, setRealmRepresentation] =
|
const [userProfileMetadata, setUserProfileMetadata] =
|
||||||
useState<RealmRepresentation>();
|
useState<UserProfileMetadata>();
|
||||||
|
|
||||||
useFetch(
|
useFetch(
|
||||||
() => adminClient.realms.findOne({ realm }),
|
() =>
|
||||||
(result) => setRealmRepresentation(result),
|
Promise.all([
|
||||||
|
adminClient.realms.findOne({ realm: realmName }),
|
||||||
|
adminClient.users.getProfileMetadata({ realm: realmName }),
|
||||||
|
]),
|
||||||
|
([realm, userProfileMetadata]) => {
|
||||||
|
if (!realm) {
|
||||||
|
throw new Error(t("notFound"));
|
||||||
|
}
|
||||||
|
|
||||||
|
setRealm(realm);
|
||||||
|
|
||||||
|
const isUserProfileEnabled =
|
||||||
|
isFeatureEnabled(Feature.DeclarativeUserProfile) &&
|
||||||
|
realm.attributes?.userProfileEnabled === "true";
|
||||||
|
|
||||||
|
setUserProfileMetadata(
|
||||||
|
isUserProfileEnabled ? userProfileMetadata : undefined,
|
||||||
|
);
|
||||||
|
},
|
||||||
[],
|
[],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
@ -48,7 +69,9 @@ export default function CreateUser() {
|
||||||
});
|
});
|
||||||
|
|
||||||
addAlert(t("userCreated"), AlertVariant.success);
|
addAlert(t("userCreated"), AlertVariant.success);
|
||||||
navigate(toUser({ id: createdUser.id, realm, tab: "settings" }));
|
navigate(
|
||||||
|
toUser({ id: createdUser.id, realm: realmName, tab: "settings" }),
|
||||||
|
);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (isUserProfileError(error)) {
|
if (isUserProfileError(error)) {
|
||||||
addError(userProfileErrorToString(error), error);
|
addError(userProfileErrorToString(error), error);
|
||||||
|
@ -58,24 +81,24 @@ export default function CreateUser() {
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
if (!realm) {
|
||||||
|
return <KeycloakSpinner />;
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<ViewHeader
|
<ViewHeader
|
||||||
titleKey={t("createUser")}
|
titleKey={t("createUser")}
|
||||||
className="kc-username-view-header"
|
className="kc-username-view-header"
|
||||||
/>
|
/>
|
||||||
<PageSection variant="light" className="pf-u-p-0">
|
<PageSection variant="light">
|
||||||
<UserProfileProvider>
|
<UserForm
|
||||||
<FormProvider {...userForm}>
|
form={form}
|
||||||
<PageSection variant="light">
|
realm={realm}
|
||||||
<UserForm
|
userProfileMetadata={userProfileMetadata}
|
||||||
realm={realmRepresentation}
|
onGroupsUpdate={setAddedGroups}
|
||||||
onGroupsUpdate={setAddedGroups}
|
save={save}
|
||||||
save={save}
|
/>
|
||||||
/>
|
|
||||||
</PageSection>
|
|
||||||
</FormProvider>
|
|
||||||
</UserProfileProvider>
|
|
||||||
</PageSection>
|
</PageSection>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|
|
@ -1,3 +1,4 @@
|
||||||
|
import type { UserProfileMetadata } from "@keycloak/keycloak-admin-client/lib/defs//userProfileMetadata";
|
||||||
import RealmRepresentation from "@keycloak/keycloak-admin-client/lib/defs/realmRepresentation";
|
import RealmRepresentation from "@keycloak/keycloak-admin-client/lib/defs/realmRepresentation";
|
||||||
import type UserRepresentation from "@keycloak/keycloak-admin-client/lib/defs/userRepresentation";
|
import type UserRepresentation from "@keycloak/keycloak-admin-client/lib/defs/userRepresentation";
|
||||||
import {
|
import {
|
||||||
|
@ -12,6 +13,7 @@ import { useState } from "react";
|
||||||
import { FormProvider, useForm } from "react-hook-form";
|
import { FormProvider, useForm } from "react-hook-form";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { useNavigate } from "react-router-dom";
|
import { useNavigate } from "react-router-dom";
|
||||||
|
|
||||||
import { adminClient } from "../admin-client";
|
import { adminClient } from "../admin-client";
|
||||||
import { useAlerts } from "../components/alert/Alerts";
|
import { useAlerts } from "../components/alert/Alerts";
|
||||||
import { useConfirmDialog } from "../components/confirm-dialog/ConfirmDialog";
|
import { useConfirmDialog } from "../components/confirm-dialog/ConfirmDialog";
|
||||||
|
@ -50,27 +52,25 @@ import { toUsers } from "./routes/Users";
|
||||||
import "./user-section.css";
|
import "./user-section.css";
|
||||||
|
|
||||||
export default function EditUser() {
|
export default function EditUser() {
|
||||||
const { realm } = useRealm();
|
|
||||||
const { id } = useParams<UserParams>();
|
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const [user, setUser] = useState<UserRepresentation>();
|
|
||||||
const [bruteForced, setBruteForced] = useState<BruteForced>();
|
|
||||||
const [refreshCount, setRefreshCount] = useState(0);
|
|
||||||
const refresh = () => setRefreshCount((count) => count + 1);
|
|
||||||
const [isUserProfileEnabled, setIsUserProfileEnabled] = useState(false);
|
|
||||||
const [realmRepresentation, setRealmRepresentation] =
|
|
||||||
useState<RealmRepresentation>();
|
|
||||||
const isFeatureEnabled = useIsFeatureEnabled();
|
|
||||||
const { addAlert, addError } = useAlerts();
|
const { addAlert, addError } = useAlerts();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const { hasAccess } = useAccess();
|
const { hasAccess } = useAccess();
|
||||||
const userForm = useForm<UserFormFields>({
|
const { id } = useParams<UserParams>();
|
||||||
mode: "onChange",
|
const { realm: realmName } = useRealm();
|
||||||
});
|
const isFeatureEnabled = useIsFeatureEnabled();
|
||||||
|
const form = useForm<UserFormFields>({ mode: "onChange" });
|
||||||
|
const [realm, setRealm] = useState<RealmRepresentation>();
|
||||||
|
const [user, setUser] = useState<UserRepresentation>();
|
||||||
|
const [bruteForced, setBruteForced] = useState<BruteForced>();
|
||||||
|
const [userProfileMetadata, setUserProfileMetadata] =
|
||||||
|
useState<UserProfileMetadata>();
|
||||||
|
const [refreshCount, setRefreshCount] = useState(0);
|
||||||
|
const refresh = () => setRefreshCount((count) => count + 1);
|
||||||
|
|
||||||
const toTab = (tab: UserTab) =>
|
const toTab = (tab: UserTab) =>
|
||||||
toUser({
|
toUser({
|
||||||
realm,
|
realm: realmName,
|
||||||
id: user?.id || "",
|
id: user?.id || "",
|
||||||
tab,
|
tab,
|
||||||
});
|
});
|
||||||
|
@ -87,35 +87,34 @@ export default function EditUser() {
|
||||||
const sessionsTab = useTab("sessions");
|
const sessionsTab = useTab("sessions");
|
||||||
|
|
||||||
useFetch(
|
useFetch(
|
||||||
async () => {
|
async () =>
|
||||||
const [user, currentRealm, attackDetection] = await Promise.all([
|
Promise.all([
|
||||||
|
adminClient.realms.findOne({ realm: realmName }),
|
||||||
adminClient.users.findOne({ id: id!, userProfileMetadata: true }),
|
adminClient.users.findOne({ id: id!, userProfileMetadata: true }),
|
||||||
adminClient.realms.findOne({ realm }),
|
|
||||||
adminClient.attackDetection.findOne({ id: id! }),
|
adminClient.attackDetection.findOne({ id: id! }),
|
||||||
]);
|
]),
|
||||||
|
([realm, user, attackDetection]) => {
|
||||||
if (!user || !currentRealm || !attackDetection) {
|
if (!user || !realm || !attackDetection) {
|
||||||
throw new Error(t("notFound"));
|
throw new Error(t("notFound"));
|
||||||
}
|
}
|
||||||
|
|
||||||
const isBruteForceProtected = currentRealm.bruteForceProtected;
|
setRealm(realm);
|
||||||
|
setUser(user);
|
||||||
|
|
||||||
|
const isBruteForceProtected = realm.bruteForceProtected;
|
||||||
const isLocked = isBruteForceProtected && attackDetection.disabled;
|
const isLocked = isBruteForceProtected && attackDetection.disabled;
|
||||||
|
|
||||||
return {
|
setBruteForced({ isBruteForceProtected, isLocked });
|
||||||
user,
|
|
||||||
bruteForced: { isBruteForceProtected, isLocked },
|
|
||||||
currentRealm,
|
|
||||||
};
|
|
||||||
},
|
|
||||||
({ user, bruteForced, currentRealm }) => {
|
|
||||||
setUser(user);
|
|
||||||
const isUserProfileEnabled =
|
const isUserProfileEnabled =
|
||||||
isFeatureEnabled(Feature.DeclarativeUserProfile) &&
|
isFeatureEnabled(Feature.DeclarativeUserProfile) &&
|
||||||
currentRealm.attributes?.userProfileEnabled === "true";
|
realm.attributes?.userProfileEnabled === "true";
|
||||||
userForm.reset(isUserProfileEnabled ? user : toUserFormFields(user));
|
|
||||||
setIsUserProfileEnabled(isUserProfileEnabled);
|
setUserProfileMetadata(
|
||||||
setRealmRepresentation(currentRealm);
|
isUserProfileEnabled ? user.userProfileMetadata : undefined,
|
||||||
setBruteForced(bruteForced);
|
);
|
||||||
|
|
||||||
|
form.reset(toUserFormFields(user, isUserProfileEnabled));
|
||||||
},
|
},
|
||||||
[refreshCount],
|
[refreshCount],
|
||||||
);
|
);
|
||||||
|
@ -146,7 +145,7 @@ export default function EditUser() {
|
||||||
try {
|
try {
|
||||||
await adminClient.users.del({ id: user!.id! });
|
await adminClient.users.del({ id: user!.id! });
|
||||||
addAlert(t("userDeletedSuccess"), AlertVariant.success);
|
addAlert(t("userDeletedSuccess"), AlertVariant.success);
|
||||||
navigate(toUsers({ realm }));
|
navigate(toUsers({ realm: realmName }));
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
addError("userDeletedError", error);
|
addError("userDeletedError", error);
|
||||||
}
|
}
|
||||||
|
@ -161,7 +160,7 @@ export default function EditUser() {
|
||||||
try {
|
try {
|
||||||
const data = await adminClient.users.impersonation(
|
const data = await adminClient.users.impersonation(
|
||||||
{ id: user!.id! },
|
{ id: user!.id! },
|
||||||
{ user: user!.id!, realm },
|
{ user: user!.id!, realm: realmName },
|
||||||
);
|
);
|
||||||
if (data.sameRealm) {
|
if (data.sameRealm) {
|
||||||
window.location = data.redirect;
|
window.location = data.redirect;
|
||||||
|
@ -174,7 +173,7 @@ export default function EditUser() {
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!user || !bruteForced) {
|
if (!realm || !user || !bruteForced) {
|
||||||
return <KeycloakSpinner />;
|
return <KeycloakSpinner />;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -203,14 +202,17 @@ export default function EditUser() {
|
||||||
</DropdownItem>,
|
</DropdownItem>,
|
||||||
]}
|
]}
|
||||||
onToggle={(value) =>
|
onToggle={(value) =>
|
||||||
save({ ...toUserFormFields(user), enabled: value })
|
save({
|
||||||
|
...toUserFormFields(user, !!userProfileMetadata),
|
||||||
|
enabled: value,
|
||||||
|
})
|
||||||
}
|
}
|
||||||
isEnabled={user.enabled}
|
isEnabled={user.enabled}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<PageSection variant="light" className="pf-u-p-0">
|
<PageSection variant="light" className="pf-u-p-0">
|
||||||
<UserProfileProvider>
|
<UserProfileProvider>
|
||||||
<FormProvider {...userForm}>
|
<FormProvider {...form}>
|
||||||
<RoutableTabs
|
<RoutableTabs
|
||||||
isBox
|
isBox
|
||||||
mountOnEnter
|
mountOnEnter
|
||||||
|
@ -223,14 +225,16 @@ export default function EditUser() {
|
||||||
>
|
>
|
||||||
<PageSection variant="light">
|
<PageSection variant="light">
|
||||||
<UserForm
|
<UserForm
|
||||||
save={save}
|
form={form}
|
||||||
|
realm={realm}
|
||||||
user={user}
|
user={user}
|
||||||
bruteForce={bruteForced}
|
bruteForce={bruteForced}
|
||||||
realm={realmRepresentation}
|
userProfileMetadata={userProfileMetadata}
|
||||||
|
save={save}
|
||||||
/>
|
/>
|
||||||
</PageSection>
|
</PageSection>
|
||||||
</Tab>
|
</Tab>
|
||||||
{!isUserProfileEnabled && (
|
{!userProfileMetadata && (
|
||||||
<Tab
|
<Tab
|
||||||
data-testid="attributes"
|
data-testid="attributes"
|
||||||
title={<TabTitleText>{t("attributes")}</TabTitleText>}
|
title={<TabTitleText>{t("attributes")}</TabTitleText>}
|
||||||
|
|
|
@ -1,8 +1,11 @@
|
||||||
import type UserRepresentation from "@keycloak/keycloak-admin-client/lib/defs/userRepresentation";
|
import type UserRepresentation from "@keycloak/keycloak-admin-client/lib/defs/userRepresentation";
|
||||||
import { PageSection, PageSectionVariants } from "@patternfly/react-core";
|
import { PageSection, PageSectionVariants } from "@patternfly/react-core";
|
||||||
import { useFormContext } from "react-hook-form";
|
import { UseFormReturn, useFormContext } from "react-hook-form";
|
||||||
|
|
||||||
import { AttributesForm } from "../components/key-value-form/AttributeForm";
|
import {
|
||||||
|
AttributeForm,
|
||||||
|
AttributesForm,
|
||||||
|
} from "../components/key-value-form/AttributeForm";
|
||||||
import { UserFormFields, toUserFormFields } from "./form-state";
|
import { UserFormFields, toUserFormFields } from "./form-state";
|
||||||
|
|
||||||
type UserAttributesProps = {
|
type UserAttributesProps = {
|
||||||
|
@ -16,13 +19,13 @@ export const UserAttributes = ({ user, save }: UserAttributesProps) => {
|
||||||
return (
|
return (
|
||||||
<PageSection variant={PageSectionVariants.light}>
|
<PageSection variant={PageSectionVariants.light}>
|
||||||
<AttributesForm
|
<AttributesForm
|
||||||
form={form}
|
form={form as UseFormReturn<AttributeForm>}
|
||||||
save={save}
|
save={save}
|
||||||
fineGrainedAccess={user.access?.manage}
|
fineGrainedAccess={user.access?.manage}
|
||||||
reset={() =>
|
reset={() =>
|
||||||
form.reset({
|
form.reset({
|
||||||
...form.getValues(),
|
...form.getValues(),
|
||||||
attributes: toUserFormFields(user).attributes,
|
attributes: toUserFormFields(user, false).attributes,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
|
|
@ -1,5 +1,6 @@
|
||||||
import type GroupRepresentation from "@keycloak/keycloak-admin-client/lib/defs/groupRepresentation";
|
import type GroupRepresentation from "@keycloak/keycloak-admin-client/lib/defs/groupRepresentation";
|
||||||
import type RealmRepresentation from "@keycloak/keycloak-admin-client/lib/defs/realmRepresentation";
|
import type RealmRepresentation from "@keycloak/keycloak-admin-client/lib/defs/realmRepresentation";
|
||||||
|
import { UserProfileMetadata } from "@keycloak/keycloak-admin-client/lib/defs/userProfileMetadata";
|
||||||
import type UserRepresentation from "@keycloak/keycloak-admin-client/lib/defs/userRepresentation";
|
import type UserRepresentation from "@keycloak/keycloak-admin-client/lib/defs/userRepresentation";
|
||||||
import {
|
import {
|
||||||
ActionGroup,
|
ActionGroup,
|
||||||
|
@ -12,9 +13,9 @@ import {
|
||||||
Switch,
|
Switch,
|
||||||
} from "@patternfly/react-core";
|
} from "@patternfly/react-core";
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { Controller, useFormContext } from "react-hook-form";
|
import { Controller, UseFormReturn } from "react-hook-form";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { useNavigate } from "react-router-dom";
|
import { Link } from "react-router-dom";
|
||||||
import { HelpItem } from "ui-shared";
|
import { HelpItem } from "ui-shared";
|
||||||
|
|
||||||
import { adminClient } from "../admin-client";
|
import { adminClient } from "../admin-client";
|
||||||
|
@ -23,13 +24,12 @@ import { FormAccess } from "../components/form/FormAccess";
|
||||||
import { GroupPickerDialog } from "../components/group/GroupPickerDialog";
|
import { GroupPickerDialog } from "../components/group/GroupPickerDialog";
|
||||||
import { KeycloakTextInput } from "../components/keycloak-text-input/KeycloakTextInput";
|
import { KeycloakTextInput } from "../components/keycloak-text-input/KeycloakTextInput";
|
||||||
import { useAccess } from "../context/access/Access";
|
import { useAccess } from "../context/access/Access";
|
||||||
import { useRealm } from "../context/realm-context/RealmContext";
|
|
||||||
import { emailRegexPattern } from "../util";
|
import { emailRegexPattern } from "../util";
|
||||||
import useFormatDate from "../utils/useFormatDate";
|
import useFormatDate from "../utils/useFormatDate";
|
||||||
import useIsFeatureEnabled, { Feature } from "../utils/useIsFeatureEnabled";
|
|
||||||
import { FederatedUserLink } from "./FederatedUserLink";
|
import { FederatedUserLink } from "./FederatedUserLink";
|
||||||
import { UserProfileFields } from "./UserProfileFields";
|
import { UserProfileFields } from "./UserProfileFields";
|
||||||
import { UserFormFields } from "./form-state";
|
import { UserFormFields, toUserFormFields } from "./form-state";
|
||||||
|
import { toUsers } from "./routes/Users";
|
||||||
import { RequiredActionMultiSelect } from "./user-credentials/RequiredActionMultiSelect";
|
import { RequiredActionMultiSelect } from "./user-credentials/RequiredActionMultiSelect";
|
||||||
|
|
||||||
export type BruteForced = {
|
export type BruteForced = {
|
||||||
|
@ -38,63 +38,29 @@ export type BruteForced = {
|
||||||
};
|
};
|
||||||
|
|
||||||
export type UserFormProps = {
|
export type UserFormProps = {
|
||||||
|
form: UseFormReturn<UserFormFields>;
|
||||||
|
realm: RealmRepresentation;
|
||||||
user?: UserRepresentation;
|
user?: UserRepresentation;
|
||||||
bruteForce?: BruteForced;
|
bruteForce?: BruteForced;
|
||||||
realm?: RealmRepresentation;
|
userProfileMetadata?: UserProfileMetadata;
|
||||||
save: (user: UserFormFields) => void;
|
save: (user: UserFormFields) => void;
|
||||||
onGroupsUpdate?: (groups: GroupRepresentation[]) => void;
|
onGroupsUpdate?: (groups: GroupRepresentation[]) => void;
|
||||||
};
|
};
|
||||||
|
|
||||||
const EmailVerified = () => {
|
|
||||||
const { t } = useTranslation();
|
|
||||||
const { control } = useFormContext<UserFormFields>();
|
|
||||||
return (
|
|
||||||
<FormGroup
|
|
||||||
label={t("emailVerified")}
|
|
||||||
fieldId="kc-email-verified"
|
|
||||||
helperTextInvalid={t("required")}
|
|
||||||
labelIcon={
|
|
||||||
<HelpItem
|
|
||||||
helpText={t("emailVerifiedHelp")}
|
|
||||||
fieldLabelId="emailVerified"
|
|
||||||
/>
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<Controller
|
|
||||||
name="emailVerified"
|
|
||||||
defaultValue={false}
|
|
||||||
control={control}
|
|
||||||
render={({ field }) => (
|
|
||||||
<Switch
|
|
||||||
data-testid="email-verified-switch"
|
|
||||||
id="kc-user-email-verified"
|
|
||||||
onChange={(value) => field.onChange(value)}
|
|
||||||
isChecked={field.value}
|
|
||||||
label={t("yes")}
|
|
||||||
labelOff={t("no")}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
</FormGroup>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export const UserForm = ({
|
export const UserForm = ({
|
||||||
user,
|
form,
|
||||||
realm,
|
realm,
|
||||||
|
user,
|
||||||
bruteForce: { isBruteForceProtected, isLocked } = {
|
bruteForce: { isBruteForceProtected, isLocked } = {
|
||||||
isBruteForceProtected: false,
|
isBruteForceProtected: false,
|
||||||
isLocked: false,
|
isLocked: false,
|
||||||
},
|
},
|
||||||
|
userProfileMetadata,
|
||||||
save,
|
save,
|
||||||
onGroupsUpdate,
|
onGroupsUpdate,
|
||||||
}: UserFormProps) => {
|
}: UserFormProps) => {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const { realm: realmName } = useRealm();
|
|
||||||
const formatDate = useFormatDate();
|
const formatDate = useFormatDate();
|
||||||
const isFeatureEnabled = useIsFeatureEnabled();
|
|
||||||
|
|
||||||
const navigate = useNavigate();
|
|
||||||
const { addAlert, addError } = useAlerts();
|
const { addAlert, addError } = useAlerts();
|
||||||
const { hasAccess } = useAccess();
|
const { hasAccess } = useAccess();
|
||||||
const isManager = hasAccess("manage-users");
|
const isManager = hasAccess("manage-users");
|
||||||
|
@ -107,7 +73,7 @@ export const UserForm = ({
|
||||||
control,
|
control,
|
||||||
reset,
|
reset,
|
||||||
formState: { errors },
|
formState: { errors },
|
||||||
} = useFormContext<UserFormFields>();
|
} = form;
|
||||||
const watchUsernameInput = watch("username");
|
const watchUsernameInput = watch("username");
|
||||||
const [selectedGroups, setSelectedGroups] = useState<GroupRepresentation[]>(
|
const [selectedGroups, setSelectedGroups] = useState<GroupRepresentation[]>(
|
||||||
[],
|
[],
|
||||||
|
@ -154,10 +120,6 @@ export const UserForm = ({
|
||||||
setOpen(!open);
|
setOpen(!open);
|
||||||
};
|
};
|
||||||
|
|
||||||
const isUserProfileEnabled =
|
|
||||||
isFeatureEnabled(Feature.DeclarativeUserProfile) &&
|
|
||||||
realm?.attributes?.userProfileEnabled === "true";
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<FormAccess
|
<FormAccess
|
||||||
isHorizontal
|
isHorizontal
|
||||||
|
@ -206,6 +168,7 @@ export const UserForm = ({
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
<RequiredActionMultiSelect
|
<RequiredActionMultiSelect
|
||||||
|
control={control}
|
||||||
name="requiredActions"
|
name="requiredActions"
|
||||||
label="requiredUserActions"
|
label="requiredUserActions"
|
||||||
help="requiredUserActionsHelp"
|
help="requiredUserActionsHelp"
|
||||||
|
@ -223,11 +186,15 @@ export const UserForm = ({
|
||||||
<FederatedUserLink user={user} />
|
<FederatedUserLink user={user} />
|
||||||
</FormGroup>
|
</FormGroup>
|
||||||
)}
|
)}
|
||||||
{isUserProfileEnabled && user?.userProfileMetadata ? (
|
{userProfileMetadata ? (
|
||||||
<UserProfileFields config={user.userProfileMetadata} />
|
<UserProfileFields
|
||||||
|
form={form}
|
||||||
|
userProfileMetadata={userProfileMetadata}
|
||||||
|
hideReadOnly={!user}
|
||||||
|
/>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
{!realm?.registrationEmailAsUsername && (
|
{!realm.registrationEmailAsUsername && (
|
||||||
<FormGroup
|
<FormGroup
|
||||||
label={t("username")}
|
label={t("username")}
|
||||||
fieldId="kc-username"
|
fieldId="kc-username"
|
||||||
|
@ -239,8 +206,8 @@ export const UserForm = ({
|
||||||
id="kc-username"
|
id="kc-username"
|
||||||
isReadOnly={
|
isReadOnly={
|
||||||
!!user?.id &&
|
!!user?.id &&
|
||||||
!realm?.editUsernameAllowed &&
|
!realm.editUsernameAllowed &&
|
||||||
realm?.editUsernameAllowed !== undefined
|
realm.editUsernameAllowed !== undefined
|
||||||
}
|
}
|
||||||
{...register("username")}
|
{...register("username")}
|
||||||
/>
|
/>
|
||||||
|
@ -261,7 +228,33 @@ export const UserForm = ({
|
||||||
})}
|
})}
|
||||||
/>
|
/>
|
||||||
</FormGroup>
|
</FormGroup>
|
||||||
<EmailVerified />
|
<FormGroup
|
||||||
|
label={t("emailVerified")}
|
||||||
|
fieldId="kc-email-verified"
|
||||||
|
helperTextInvalid={t("required")}
|
||||||
|
labelIcon={
|
||||||
|
<HelpItem
|
||||||
|
helpText={t("emailVerifiedHelp")}
|
||||||
|
fieldLabelId="emailVerified"
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<Controller
|
||||||
|
name="emailVerified"
|
||||||
|
defaultValue={false}
|
||||||
|
control={control}
|
||||||
|
render={({ field }) => (
|
||||||
|
<Switch
|
||||||
|
data-testid="email-verified-switch"
|
||||||
|
id="kc-user-email-verified"
|
||||||
|
onChange={(value) => field.onChange(value)}
|
||||||
|
isChecked={field.value}
|
||||||
|
label={t("yes")}
|
||||||
|
labelOff={t("no")}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</FormGroup>
|
||||||
<FormGroup
|
<FormGroup
|
||||||
label={t("firstName")}
|
label={t("firstName")}
|
||||||
fieldId="kc-firstName"
|
fieldId="kc-firstName"
|
||||||
|
@ -357,7 +350,7 @@ export const UserForm = ({
|
||||||
isDisabled={
|
isDisabled={
|
||||||
!user?.id &&
|
!user?.id &&
|
||||||
!watchUsernameInput &&
|
!watchUsernameInput &&
|
||||||
!realm?.registrationEmailAsUsername
|
!realm.registrationEmailAsUsername
|
||||||
}
|
}
|
||||||
variant="primary"
|
variant="primary"
|
||||||
type="submit"
|
type="submit"
|
||||||
|
@ -366,10 +359,19 @@ export const UserForm = ({
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
data-testid="cancel-create-user"
|
data-testid="cancel-create-user"
|
||||||
onClick={() =>
|
|
||||||
user?.id ? reset(user) : navigate(`/${realmName}/users`)
|
|
||||||
}
|
|
||||||
variant="link"
|
variant="link"
|
||||||
|
onClick={
|
||||||
|
user?.id
|
||||||
|
? () => reset(toUserFormFields(user, !!userProfileMetadata))
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
|
component={
|
||||||
|
!user?.id
|
||||||
|
? (props) => (
|
||||||
|
<Link {...props} to={toUsers({ realm: realm.id! })} />
|
||||||
|
)
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
>
|
>
|
||||||
{user?.id ? t("revert") : t("cancel")}
|
{user?.id ? t("revert") : t("cancel")}
|
||||||
</Button>
|
</Button>
|
||||||
|
|
|
@ -1,8 +1,11 @@
|
||||||
import type { UserProfileAttribute } from "@keycloak/keycloak-admin-client/lib/defs/userProfileConfig";
|
import type {
|
||||||
import UserProfileConfig from "@keycloak/keycloak-admin-client/lib/defs/userProfileConfig";
|
UserProfileAttributeGroupMetadata,
|
||||||
|
UserProfileAttributeMetadata,
|
||||||
|
UserProfileMetadata,
|
||||||
|
} from "@keycloak/keycloak-admin-client/lib/defs/userProfileMetadata";
|
||||||
import { Text } from "@patternfly/react-core";
|
import { Text } from "@patternfly/react-core";
|
||||||
import { Fragment } from "react";
|
import { useMemo } from "react";
|
||||||
import { useFormContext } from "react-hook-form";
|
import { FieldPath, UseFormReturn } from "react-hook-form";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
|
|
||||||
import { ScrollForm } from "../components/scroll-form/ScrollForm";
|
import { ScrollForm } from "../components/scroll-form/ScrollForm";
|
||||||
|
@ -10,19 +13,15 @@ import { OptionComponent } from "./components/OptionsComponent";
|
||||||
import { SelectComponent } from "./components/SelectComponent";
|
import { SelectComponent } from "./components/SelectComponent";
|
||||||
import { TextAreaComponent } from "./components/TextAreaComponent";
|
import { TextAreaComponent } from "./components/TextAreaComponent";
|
||||||
import { TextComponent } from "./components/TextComponent";
|
import { TextComponent } from "./components/TextComponent";
|
||||||
import { fieldName } from "./utils";
|
import { UserFormFields } from "./form-state";
|
||||||
|
import { fieldName, isRootAttribute } from "./utils";
|
||||||
type UserProfileFieldsProps = {
|
|
||||||
config: UserProfileConfig;
|
|
||||||
roles?: string[];
|
|
||||||
};
|
|
||||||
|
|
||||||
export type UserProfileError = {
|
export type UserProfileError = {
|
||||||
responseData: { errors?: { errorMessage: string }[] };
|
responseData: { errors?: { errorMessage: string }[] };
|
||||||
};
|
};
|
||||||
|
|
||||||
export type Options = {
|
export type Options = {
|
||||||
options: string[] | undefined;
|
options?: string[];
|
||||||
};
|
};
|
||||||
|
|
||||||
export function isUserProfileError(error: unknown): error is UserProfileError {
|
export function isUserProfileError(error: unknown): error is UserProfileError {
|
||||||
|
@ -35,7 +34,7 @@ export function userProfileErrorToString(error: UserProfileError) {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const FieldTypes = [
|
const INPUT_TYPES = [
|
||||||
"text",
|
"text",
|
||||||
"textarea",
|
"textarea",
|
||||||
"select",
|
"select",
|
||||||
|
@ -53,10 +52,17 @@ const FieldTypes = [
|
||||||
"html5-time",
|
"html5-time",
|
||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
export type Field = (typeof FieldTypes)[number];
|
export type InputType = (typeof INPUT_TYPES)[number];
|
||||||
|
|
||||||
|
export type UserProfileFieldProps = {
|
||||||
|
form: UseFormReturn<UserFormFields>;
|
||||||
|
inputType: InputType;
|
||||||
|
attribute: UserProfileAttributeMetadata;
|
||||||
|
roles: string[];
|
||||||
|
};
|
||||||
|
|
||||||
export const FIELDS: {
|
export const FIELDS: {
|
||||||
[index in Field]: (props: any) => JSX.Element;
|
[type in InputType]: (props: UserProfileFieldProps) => JSX.Element;
|
||||||
} = {
|
} = {
|
||||||
text: TextComponent,
|
text: TextComponent,
|
||||||
textarea: TextAreaComponent,
|
textarea: TextAreaComponent,
|
||||||
|
@ -75,51 +81,129 @@ export const FIELDS: {
|
||||||
"html5-time": TextComponent,
|
"html5-time": TextComponent,
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
export const isValidComponentType = (value: string): value is Field =>
|
export type UserProfileFieldsProps = {
|
||||||
value in FIELDS;
|
form: UseFormReturn<UserFormFields>;
|
||||||
|
userProfileMetadata: UserProfileMetadata;
|
||||||
|
roles?: string[];
|
||||||
|
hideReadOnly?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
type GroupWithAttributes = {
|
||||||
|
group: UserProfileAttributeGroupMetadata;
|
||||||
|
attributes: UserProfileAttributeMetadata[];
|
||||||
|
};
|
||||||
|
|
||||||
export const UserProfileFields = ({
|
export const UserProfileFields = ({
|
||||||
config,
|
form,
|
||||||
|
userProfileMetadata,
|
||||||
roles = ["admin"],
|
roles = ["admin"],
|
||||||
|
hideReadOnly = false,
|
||||||
}: UserProfileFieldsProps) => {
|
}: UserProfileFieldsProps) => {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
// Group attributes by group, for easier rendering.
|
||||||
|
const groupsWithAttributes = useMemo(() => {
|
||||||
|
// If there are no attributes, there is no need to group them.
|
||||||
|
if (!userProfileMetadata.attributes) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Hide read-only attributes if 'hideReadOnly' is enabled.
|
||||||
|
const attributes = hideReadOnly
|
||||||
|
? userProfileMetadata.attributes.filter(({ readOnly }) => !readOnly)
|
||||||
|
: userProfileMetadata.attributes;
|
||||||
|
|
||||||
|
return [
|
||||||
|
// Insert an empty group for attributes without a group.
|
||||||
|
{ name: undefined },
|
||||||
|
...(userProfileMetadata.groups ?? []),
|
||||||
|
].map<GroupWithAttributes>((group) => ({
|
||||||
|
group,
|
||||||
|
attributes: attributes.filter(
|
||||||
|
(attribute) => attribute.group === group.name,
|
||||||
|
),
|
||||||
|
}));
|
||||||
|
}, [
|
||||||
|
hideReadOnly,
|
||||||
|
userProfileMetadata.groups,
|
||||||
|
userProfileMetadata.attributes,
|
||||||
|
]);
|
||||||
|
|
||||||
|
if (groupsWithAttributes.length === 0) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ScrollForm
|
<ScrollForm
|
||||||
sections={[{ name: "" }, ...(config.groups || [])].map((g) => ({
|
sections={groupsWithAttributes
|
||||||
title: g.displayHeader || g.name || t("general"),
|
.filter((group) => group.attributes.length > 0)
|
||||||
panel: (
|
.map(({ group, attributes }) => ({
|
||||||
<div className="pf-c-form">
|
title: group.displayHeader || group.name || t("general"),
|
||||||
{g.displayDescription && (
|
panel: (
|
||||||
<Text className="pf-u-pb-lg">{g.displayDescription}</Text>
|
<div className="pf-c-form">
|
||||||
)}
|
{group.displayDescription && (
|
||||||
{config.attributes?.map((attribute) => (
|
<Text className="pf-u-pb-lg">{group.displayDescription}</Text>
|
||||||
<Fragment key={attribute.name}>
|
)}
|
||||||
{(attribute.group || "") === g.name && (
|
{attributes.map((attribute) => (
|
||||||
<FormField attribute={attribute} roles={roles} />
|
<FormField
|
||||||
)}
|
key={attribute.name}
|
||||||
</Fragment>
|
form={form}
|
||||||
))}
|
attribute={attribute}
|
||||||
</div>
|
roles={roles}
|
||||||
),
|
/>
|
||||||
}))}
|
))}
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
}))}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
type FormFieldProps = {
|
type FormFieldProps = {
|
||||||
attribute: UserProfileAttribute;
|
form: UseFormReturn<UserFormFields>;
|
||||||
|
attribute: UserProfileAttributeMetadata;
|
||||||
roles: string[];
|
roles: string[];
|
||||||
};
|
};
|
||||||
|
|
||||||
const FormField = ({ attribute, roles }: FormFieldProps) => {
|
const FormField = ({ form, attribute, roles }: FormFieldProps) => {
|
||||||
const { watch } = useFormContext();
|
const value = form.watch(fieldName(attribute) as FieldPath<UserFormFields>);
|
||||||
const value = watch(fieldName(attribute));
|
const inputType = determineInputType(attribute, value);
|
||||||
|
const Component = FIELDS[inputType];
|
||||||
|
|
||||||
const componentType = (attribute.annotations?.["inputType"] ||
|
return (
|
||||||
(Array.isArray(value) ? "multiselect" : "text")) as Field;
|
<Component
|
||||||
|
form={form}
|
||||||
const Component = FIELDS[componentType];
|
inputType={inputType}
|
||||||
|
attribute={attribute}
|
||||||
return <Component {...{ ...attribute, roles }} />;
|
roles={roles}
|
||||||
|
/>
|
||||||
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const DEFAULT_INPUT_TYPE = "multiselect" satisfies InputType;
|
||||||
|
|
||||||
|
function determineInputType(
|
||||||
|
attribute: UserProfileAttributeMetadata,
|
||||||
|
value: string | string[],
|
||||||
|
): InputType {
|
||||||
|
// Always treat the root attributes as a text field.
|
||||||
|
if (isRootAttribute(attribute.name)) {
|
||||||
|
return "text";
|
||||||
|
}
|
||||||
|
|
||||||
|
const inputType = attribute.annotations?.inputType;
|
||||||
|
|
||||||
|
// If the attribute has no valid input type, it is always multi-valued.
|
||||||
|
if (!isValidInputType(inputType)) {
|
||||||
|
return DEFAULT_INPUT_TYPE;
|
||||||
|
}
|
||||||
|
|
||||||
|
// An attribute with multiple values is always multi-valued, even if an input type is provided.
|
||||||
|
if (Array.isArray(value) && value.length > 1) {
|
||||||
|
return DEFAULT_INPUT_TYPE;
|
||||||
|
}
|
||||||
|
|
||||||
|
return inputType;
|
||||||
|
}
|
||||||
|
|
||||||
|
const isValidInputType = (value: unknown): value is InputType =>
|
||||||
|
typeof value === "string" && value in FIELDS;
|
||||||
|
|
|
@ -1,23 +1,27 @@
|
||||||
import { UserProfileAttribute } from "@keycloak/keycloak-admin-client/lib/defs/userProfileConfig";
|
|
||||||
import { Checkbox, Radio } from "@patternfly/react-core";
|
import { Checkbox, Radio } from "@patternfly/react-core";
|
||||||
import { Controller, useFormContext } from "react-hook-form";
|
import { Controller, FieldPath } from "react-hook-form";
|
||||||
import { UserProfileGroup } from "./UserProfileGroup";
|
import { isRequiredAttribute } from "../utils/user-profile";
|
||||||
import { Options } from "../UserProfileFields";
|
|
||||||
|
import { Options, UserProfileFieldProps } from "../UserProfileFields";
|
||||||
|
import { UserFormFields } from "../form-state";
|
||||||
import { fieldName } from "../utils";
|
import { fieldName } from "../utils";
|
||||||
|
import { UserProfileGroup } from "./UserProfileGroup";
|
||||||
|
|
||||||
export const OptionComponent = (attr: UserProfileAttribute) => {
|
export const OptionComponent = ({
|
||||||
const { control } = useFormContext();
|
form,
|
||||||
const type = attr.annotations?.["inputType"] as string;
|
inputType,
|
||||||
const isMultiSelect = type.includes("multiselect");
|
attribute,
|
||||||
|
}: UserProfileFieldProps) => {
|
||||||
|
const isRequired = isRequiredAttribute(attribute);
|
||||||
|
const isMultiSelect = inputType.startsWith("multiselect");
|
||||||
const Component = isMultiSelect ? Checkbox : Radio;
|
const Component = isMultiSelect ? Checkbox : Radio;
|
||||||
|
const options = (attribute.validators?.options as Options).options || [];
|
||||||
const options = (attr.validators?.options as Options).options || [];
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<UserProfileGroup {...attr}>
|
<UserProfileGroup form={form} attribute={attribute}>
|
||||||
<Controller
|
<Controller
|
||||||
name={fieldName(attr)}
|
name={fieldName(attribute) as FieldPath<UserFormFields>}
|
||||||
control={control}
|
control={form.control}
|
||||||
defaultValue=""
|
defaultValue=""
|
||||||
render={({ field }) => (
|
render={({ field }) => (
|
||||||
<>
|
<>
|
||||||
|
@ -42,7 +46,8 @@ export const OptionComponent = (attr: UserProfileAttribute) => {
|
||||||
field.onChange([option]);
|
field.onChange([option]);
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
readOnly={attr.readOnly}
|
readOnly={attribute.readOnly}
|
||||||
|
isRequired={isRequired}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</>
|
</>
|
||||||
|
|
|
@ -1,35 +1,30 @@
|
||||||
import { Select, SelectOption } from "@patternfly/react-core";
|
import { Select, SelectOption } from "@patternfly/react-core";
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import {
|
import { Controller, ControllerRenderProps, FieldPath } from "react-hook-form";
|
||||||
Controller,
|
|
||||||
useFormContext,
|
|
||||||
ControllerRenderProps,
|
|
||||||
FieldValues,
|
|
||||||
} from "react-hook-form";
|
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
|
|
||||||
import { Options } from "../UserProfileFields";
|
import { Options, UserProfileFieldProps } from "../UserProfileFields";
|
||||||
|
import { UserFormFields } from "../form-state";
|
||||||
import { fieldName, unWrap } from "../utils";
|
import { fieldName, unWrap } from "../utils";
|
||||||
import { UserProfileFieldsProps, UserProfileGroup } from "./UserProfileGroup";
|
import { UserProfileGroup } from "./UserProfileGroup";
|
||||||
|
import { isRequiredAttribute } from "../utils/user-profile";
|
||||||
|
|
||||||
type OptionLabel = Record<string, string> | undefined;
|
type OptionLabel = Record<string, string> | undefined;
|
||||||
export const SelectComponent = (attribute: UserProfileFieldsProps) => {
|
export const SelectComponent = ({
|
||||||
|
form,
|
||||||
|
inputType,
|
||||||
|
attribute,
|
||||||
|
}: UserProfileFieldProps) => {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const { control } = useFormContext();
|
|
||||||
const [open, setOpen] = useState(false);
|
const [open, setOpen] = useState(false);
|
||||||
|
const isRequired = isRequiredAttribute(attribute);
|
||||||
const isMultiValue = (field: ControllerRenderProps<FieldValues, string>) => {
|
const isMultiValue = inputType === "multiselect";
|
||||||
return (
|
|
||||||
attribute.annotations?.["inputType"] === "multiselect" ||
|
|
||||||
Array.isArray(field.value)
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
const setValue = (
|
const setValue = (
|
||||||
value: string,
|
value: string,
|
||||||
field: ControllerRenderProps<FieldValues, string>,
|
field: ControllerRenderProps<UserFormFields>,
|
||||||
) => {
|
) => {
|
||||||
if (isMultiValue(field)) {
|
if (isMultiValue) {
|
||||||
if (field.value.includes(value)) {
|
if (field.value.includes(value)) {
|
||||||
field.onChange(field.value.filter((item: string) => item !== value));
|
field.onChange(field.value.filter((item: string) => item !== value));
|
||||||
} else {
|
} else {
|
||||||
|
@ -50,11 +45,11 @@ export const SelectComponent = (attribute: UserProfileFieldsProps) => {
|
||||||
optionLabel ? t(unWrap(optionLabel[label])) : label;
|
optionLabel ? t(unWrap(optionLabel[label])) : label;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<UserProfileGroup {...attribute}>
|
<UserProfileGroup form={form} attribute={attribute}>
|
||||||
<Controller
|
<Controller
|
||||||
name={fieldName(attribute)}
|
name={fieldName(attribute) as FieldPath<UserFormFields>}
|
||||||
defaultValue=""
|
defaultValue=""
|
||||||
control={control}
|
control={form.control}
|
||||||
render={({ field }) => (
|
render={({ field }) => (
|
||||||
<Select
|
<Select
|
||||||
toggleId={attribute.name}
|
toggleId={attribute.name}
|
||||||
|
@ -69,12 +64,13 @@ export const SelectComponent = (attribute: UserProfileFieldsProps) => {
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
selections={
|
selections={
|
||||||
field.value ? field.value : isMultiValue(field) ? [] : t("choose")
|
field.value ? field.value : isMultiValue ? [] : t("choose")
|
||||||
}
|
}
|
||||||
variant={isMultiValue(field) ? "typeaheadmulti" : "single"}
|
variant={isMultiValue ? "typeaheadmulti" : "single"}
|
||||||
aria-label={t("selectOne")}
|
aria-label={t("selectOne")}
|
||||||
isOpen={open}
|
isOpen={open}
|
||||||
readOnly={attribute.readOnly}
|
isDisabled={attribute.readOnly}
|
||||||
|
required={isRequired}
|
||||||
>
|
>
|
||||||
{options.map((option) => (
|
{options.map((option) => (
|
||||||
<SelectOption
|
<SelectOption
|
||||||
|
|
|
@ -1,21 +1,28 @@
|
||||||
import { UserProfileAttribute } from "@keycloak/keycloak-admin-client/lib/defs/userProfileConfig";
|
import { FieldPath } from "react-hook-form";
|
||||||
import { useFormContext } from "react-hook-form";
|
|
||||||
import { KeycloakTextArea } from "../../components/keycloak-text-area/KeycloakTextArea";
|
|
||||||
import { UserProfileGroup } from "./UserProfileGroup";
|
|
||||||
import { fieldName } from "../utils";
|
|
||||||
|
|
||||||
export const TextAreaComponent = (attr: UserProfileAttribute) => {
|
import { KeycloakTextArea } from "../../components/keycloak-text-area/KeycloakTextArea";
|
||||||
const { register } = useFormContext();
|
import { UserProfileFieldProps } from "../UserProfileFields";
|
||||||
|
import { UserFormFields } from "../form-state";
|
||||||
|
import { fieldName } from "../utils";
|
||||||
|
import { isRequiredAttribute } from "../utils/user-profile";
|
||||||
|
import { UserProfileGroup } from "./UserProfileGroup";
|
||||||
|
|
||||||
|
export const TextAreaComponent = ({
|
||||||
|
form,
|
||||||
|
attribute,
|
||||||
|
}: UserProfileFieldProps) => {
|
||||||
|
const isRequired = isRequiredAttribute(attribute);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<UserProfileGroup {...attr}>
|
<UserProfileGroup form={form} attribute={attribute}>
|
||||||
<KeycloakTextArea
|
<KeycloakTextArea
|
||||||
id={attr.name}
|
id={attribute.name}
|
||||||
data-testid={attr.name}
|
data-testid={attribute.name}
|
||||||
{...register(fieldName(attr))}
|
{...form.register(fieldName(attribute) as FieldPath<UserFormFields>)}
|
||||||
cols={attr.annotations?.["inputTypeCols"] as number}
|
cols={attribute.annotations?.["inputTypeCols"] as number}
|
||||||
rows={attr.annotations?.["inputTypeRows"] as number}
|
rows={attribute.annotations?.["inputTypeRows"] as number}
|
||||||
readOnly={attr.readOnly}
|
readOnly={attribute.readOnly}
|
||||||
|
isRequired={isRequired}
|
||||||
/>
|
/>
|
||||||
</UserProfileGroup>
|
</UserProfileGroup>
|
||||||
);
|
);
|
||||||
|
|
|
@ -1,25 +1,33 @@
|
||||||
import { UserProfileAttribute } from "@keycloak/keycloak-admin-client/lib/defs/userProfileConfig";
|
import { TextInputTypes } from "@patternfly/react-core";
|
||||||
import { useFormContext } from "react-hook-form";
|
import { FieldPath } from "react-hook-form";
|
||||||
import { KeycloakTextInput } from "ui-shared";
|
import { KeycloakTextInput } from "ui-shared";
|
||||||
|
|
||||||
|
import { UserProfileFieldProps } from "../UserProfileFields";
|
||||||
|
import { UserFormFields } from "../form-state";
|
||||||
import { fieldName } from "../utils";
|
import { fieldName } from "../utils";
|
||||||
|
import { isRequiredAttribute } from "../utils/user-profile";
|
||||||
import { UserProfileGroup } from "./UserProfileGroup";
|
import { UserProfileGroup } from "./UserProfileGroup";
|
||||||
|
|
||||||
export const TextComponent = (attr: UserProfileAttribute) => {
|
export const TextComponent = ({
|
||||||
const { register } = useFormContext();
|
form,
|
||||||
const inputType = attr.annotations?.["inputType"] as string | undefined;
|
inputType,
|
||||||
const type: any = inputType?.startsWith("html")
|
attribute,
|
||||||
? inputType.substring("html".length + 2)
|
}: UserProfileFieldProps) => {
|
||||||
|
const isRequired = isRequiredAttribute(attribute);
|
||||||
|
const type = inputType.startsWith("html")
|
||||||
|
? (inputType.substring("html".length + 2) as TextInputTypes)
|
||||||
: "text";
|
: "text";
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<UserProfileGroup {...attr}>
|
<UserProfileGroup form={form} attribute={attribute}>
|
||||||
<KeycloakTextInput
|
<KeycloakTextInput
|
||||||
id={attr.name}
|
id={attribute.name}
|
||||||
data-testid={attr.name}
|
data-testid={attribute.name}
|
||||||
type={type}
|
type={type}
|
||||||
placeholder={attr.annotations?.["inputTypePlaceholder"] as string}
|
placeholder={attribute.annotations?.["inputTypePlaceholder"] as string}
|
||||||
readOnly={attr.readOnly}
|
readOnly={attribute.readOnly}
|
||||||
{...register(fieldName(attr))}
|
isRequired={isRequired}
|
||||||
|
{...form.register(fieldName(attribute) as FieldPath<UserFormFields>)}
|
||||||
/>
|
/>
|
||||||
</UserProfileGroup>
|
</UserProfileGroup>
|
||||||
);
|
);
|
||||||
|
|
|
@ -1,43 +1,36 @@
|
||||||
import { UserProfileAttribute } from "@keycloak/keycloak-admin-client/lib/defs/userProfileConfig";
|
import { UserProfileAttributeMetadata } from "@keycloak/keycloak-admin-client/lib/defs/userProfileMetadata";
|
||||||
import { FormGroup } from "@patternfly/react-core";
|
import { FormGroup } from "@patternfly/react-core";
|
||||||
import { PropsWithChildren } from "react";
|
import { PropsWithChildren } from "react";
|
||||||
import { useFormContext } from "react-hook-form";
|
import { UseFormReturn } from "react-hook-form";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
|
|
||||||
import { HelpItem } from "ui-shared";
|
import { HelpItem } from "ui-shared";
|
||||||
import { label } from "../utils";
|
|
||||||
|
|
||||||
export type UserProfileFieldsProps = UserProfileAttribute & {
|
import { UserFormFields } from "../form-state";
|
||||||
roles?: string[];
|
import { label } from "../utils";
|
||||||
|
import { isRequiredAttribute } from "../utils/user-profile";
|
||||||
|
|
||||||
|
export type UserProfileGroupProps = {
|
||||||
|
form: UseFormReturn<UserFormFields>;
|
||||||
|
attribute: UserProfileAttributeMetadata;
|
||||||
};
|
};
|
||||||
|
|
||||||
type LengthValidator =
|
|
||||||
| {
|
|
||||||
min: number;
|
|
||||||
}
|
|
||||||
| undefined;
|
|
||||||
|
|
||||||
const isRequired = (attribute: UserProfileAttribute) =>
|
|
||||||
Object.keys(attribute.required || {}).length !== 0 ||
|
|
||||||
(((attribute.validators?.length as LengthValidator)?.min as number) || 0) > 0;
|
|
||||||
|
|
||||||
export const UserProfileGroup = ({
|
export const UserProfileGroup = ({
|
||||||
|
form,
|
||||||
|
attribute,
|
||||||
children,
|
children,
|
||||||
...attribute
|
}: PropsWithChildren<UserProfileGroupProps>) => {
|
||||||
}: PropsWithChildren<UserProfileFieldsProps>) => {
|
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const helpText = attribute.annotations?.["inputHelperTextBefore"] as string;
|
const helpText = attribute.annotations?.["inputHelperTextBefore"] as string;
|
||||||
|
|
||||||
const {
|
const {
|
||||||
formState: { errors },
|
formState: { errors },
|
||||||
} = useFormContext();
|
} = form;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<FormGroup
|
<FormGroup
|
||||||
key={attribute.name}
|
key={attribute.name}
|
||||||
label={label(attribute, t) || ""}
|
label={label(attribute, t) || ""}
|
||||||
fieldId={attribute.name}
|
fieldId={attribute.name}
|
||||||
isRequired={isRequired(attribute)}
|
isRequired={isRequiredAttribute(attribute)}
|
||||||
validated={errors.username ? "error" : "default"}
|
validated={errors.username ? "error" : "default"}
|
||||||
helperTextInvalid={t("required")}
|
helperTextInvalid={t("required")}
|
||||||
labelIcon={
|
labelIcon={
|
||||||
|
|
|
@ -5,19 +5,29 @@ import {
|
||||||
keyValueToArray,
|
keyValueToArray,
|
||||||
} from "../components/key-value-form/key-value-convert";
|
} from "../components/key-value-form/key-value-convert";
|
||||||
|
|
||||||
export type UserFormFields = Omit<UserRepresentation, "userProfileMetadata"> & {
|
export type UserFormFields = Omit<
|
||||||
attributes?: KeyValueType[];
|
UserRepresentation,
|
||||||
|
"attributes" | "userProfileMetadata"
|
||||||
|
> & {
|
||||||
|
attributes?: KeyValueType[] | Record<string, string | string[]>;
|
||||||
};
|
};
|
||||||
|
|
||||||
export function toUserFormFields(data: UserRepresentation): UserFormFields {
|
export function toUserFormFields(
|
||||||
const attributes = arrayToKeyValue(data.attributes);
|
data: UserRepresentation,
|
||||||
|
userProfileEnabled: boolean,
|
||||||
|
): UserFormFields {
|
||||||
|
const attributes = userProfileEnabled
|
||||||
|
? data.attributes
|
||||||
|
: arrayToKeyValue(data.attributes);
|
||||||
|
|
||||||
return { ...data, attributes };
|
return { ...data, attributes };
|
||||||
}
|
}
|
||||||
|
|
||||||
export function toUserRepresentation(data: UserFormFields): UserRepresentation {
|
export function toUserRepresentation(data: UserFormFields): UserRepresentation {
|
||||||
const username = data.username?.trim();
|
const username = data.username?.trim();
|
||||||
const attributes = keyValueToArray(data.attributes);
|
const attributes = Array.isArray(data.attributes)
|
||||||
|
? keyValueToArray(data.attributes)
|
||||||
|
: data.attributes;
|
||||||
|
|
||||||
return { ...data, username, attributes };
|
return { ...data, username, attributes };
|
||||||
}
|
}
|
||||||
|
|
|
@ -6,26 +6,39 @@ import {
|
||||||
SelectVariant,
|
SelectVariant,
|
||||||
} from "@patternfly/react-core";
|
} from "@patternfly/react-core";
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { Controller, useFormContext } from "react-hook-form";
|
import {
|
||||||
|
Control,
|
||||||
|
Controller,
|
||||||
|
FieldPathByValue,
|
||||||
|
FieldValues,
|
||||||
|
PathValue,
|
||||||
|
} from "react-hook-form";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { HelpItem } from "ui-shared";
|
import { HelpItem } from "ui-shared";
|
||||||
|
|
||||||
import { adminClient } from "../../admin-client";
|
import { adminClient } from "../../admin-client";
|
||||||
import { useFetch } from "../../utils/useFetch";
|
import { useFetch } from "../../utils/useFetch";
|
||||||
|
|
||||||
type RequiredActionMultiSelectProps = {
|
export type RequiredActionMultiSelectProps<
|
||||||
name: string;
|
T extends FieldValues,
|
||||||
|
P extends FieldPathByValue<T, string[] | undefined>,
|
||||||
|
> = {
|
||||||
|
control: Control<T>;
|
||||||
|
name: P;
|
||||||
label: string;
|
label: string;
|
||||||
help: string;
|
help: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const RequiredActionMultiSelect = ({
|
export const RequiredActionMultiSelect = <
|
||||||
|
T extends FieldValues,
|
||||||
|
P extends FieldPathByValue<T, string[] | undefined>,
|
||||||
|
>({
|
||||||
|
control,
|
||||||
name,
|
name,
|
||||||
label,
|
label,
|
||||||
help,
|
help,
|
||||||
}: RequiredActionMultiSelectProps) => {
|
}: RequiredActionMultiSelectProps<T, P>) => {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const { control } = useFormContext();
|
|
||||||
const [open, setOpen] = useState(false);
|
const [open, setOpen] = useState(false);
|
||||||
const [requiredActions, setRequiredActions] = useState<
|
const [requiredActions, setRequiredActions] = useState<
|
||||||
RequiredActionProviderRepresentation[]
|
RequiredActionProviderRepresentation[]
|
||||||
|
@ -50,7 +63,7 @@ export const RequiredActionMultiSelect = ({
|
||||||
>
|
>
|
||||||
<Controller
|
<Controller
|
||||||
name={name}
|
name={name}
|
||||||
defaultValue={[]}
|
defaultValue={[] as PathValue<T, P>}
|
||||||
control={control}
|
control={control}
|
||||||
render={({ field }) => (
|
render={({ field }) => (
|
||||||
<Select
|
<Select
|
||||||
|
@ -64,14 +77,15 @@ export const RequiredActionMultiSelect = ({
|
||||||
menuAppendTo="parent"
|
menuAppendTo="parent"
|
||||||
onToggle={(open) => setOpen(open)}
|
onToggle={(open) => setOpen(open)}
|
||||||
isOpen={open}
|
isOpen={open}
|
||||||
selections={field.value}
|
selections={field.value as string[]}
|
||||||
onSelect={(_, selectedValue) =>
|
onSelect={(_, selectedValue) => {
|
||||||
|
const value: string[] = field.value;
|
||||||
field.onChange(
|
field.onChange(
|
||||||
field.value.find((o: string) => o === selectedValue)
|
value.find((item) => item === selectedValue)
|
||||||
? field.value.filter((item: string) => item !== selectedValue)
|
? value.filter((item) => item !== selectedValue)
|
||||||
: [...field.value, selectedValue],
|
: [...value, selectedValue],
|
||||||
)
|
);
|
||||||
}
|
}}
|
||||||
onClear={(event) => {
|
onClear={(event) => {
|
||||||
event.stopPropagation();
|
event.stopPropagation();
|
||||||
field.onChange([]);
|
field.onChange([]);
|
||||||
|
|
|
@ -82,12 +82,13 @@ export const ResetCredentialDialog = ({
|
||||||
isHorizontal
|
isHorizontal
|
||||||
data-testid="credential-reset-modal"
|
data-testid="credential-reset-modal"
|
||||||
>
|
>
|
||||||
|
<RequiredActionMultiSelect
|
||||||
|
control={control}
|
||||||
|
name="actions"
|
||||||
|
label="resetAction"
|
||||||
|
help="resetActions"
|
||||||
|
/>
|
||||||
<FormProvider {...form}>
|
<FormProvider {...form}>
|
||||||
<RequiredActionMultiSelect
|
|
||||||
name="actions"
|
|
||||||
label="resetAction"
|
|
||||||
help="resetActions"
|
|
||||||
/>
|
|
||||||
<LifespanField />
|
<LifespanField />
|
||||||
</FormProvider>
|
</FormProvider>
|
||||||
</Form>
|
</Form>
|
||||||
|
|
|
@ -1,19 +1,21 @@
|
||||||
import { UserProfileAttribute } from "@keycloak/keycloak-admin-client/lib/defs/userProfileConfig";
|
import { UserProfileAttributeMetadata } from "@keycloak/keycloak-admin-client/lib/defs/userProfileMetadata";
|
||||||
import { TFunction } from "i18next";
|
import { TFunction } from "i18next";
|
||||||
|
|
||||||
export const isBundleKey = (displayName?: string) =>
|
export const isBundleKey = (displayName?: string) =>
|
||||||
displayName?.includes("${");
|
displayName?.includes("${");
|
||||||
export const unWrap = (key: string) => key.substring(2, key.length - 1);
|
export const unWrap = (key: string) => key.substring(2, key.length - 1);
|
||||||
|
|
||||||
export const label = (attribute: UserProfileAttribute, t: TFunction) =>
|
export const label = (attribute: UserProfileAttributeMetadata, t: TFunction) =>
|
||||||
(isBundleKey(attribute.displayName)
|
(isBundleKey(attribute.displayName)
|
||||||
? t(unWrap(attribute.displayName!))
|
? t(unWrap(attribute.displayName!))
|
||||||
: attribute.displayName) || attribute.name;
|
: attribute.displayName) || attribute.name;
|
||||||
|
|
||||||
const ROOT_ATTRIBUTES = ["username", "firstName", "lastName", "email"];
|
const ROOT_ATTRIBUTES = ["username", "firstName", "lastName", "email"];
|
||||||
|
|
||||||
const isRootAttribute = (attr?: string) =>
|
export const isRootAttribute = (attr?: string) =>
|
||||||
attr && ROOT_ATTRIBUTES.includes(attr);
|
attr && ROOT_ATTRIBUTES.includes(attr);
|
||||||
|
|
||||||
export const fieldName = (attribute: UserProfileAttribute) =>
|
export const fieldName = (attribute: UserProfileAttributeMetadata) =>
|
||||||
`${isRootAttribute(attribute.name) ? "" : "attributes."}${attribute.name}`;
|
isRootAttribute(attribute.name)
|
||||||
|
? attribute.name
|
||||||
|
: `attributes.${attribute.name}`;
|
||||||
|
|
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
|
// See: https://github.com/keycloak/keycloak/blob/main/services/src/main/java/org/keycloak/userprofile/config/UPConfig.java
|
||||||
export default interface UserProfileConfig {
|
export interface UserProfileConfig {
|
||||||
attributes?: UserProfileAttribute[];
|
attributes?: UserProfileAttribute[];
|
||||||
groups?: UserProfileGroup[];
|
groups?: UserProfileGroup[];
|
||||||
}
|
}
|
||||||
|
@ -7,11 +7,9 @@ export default interface UserProfileConfig {
|
||||||
// See: https://github.com/keycloak/keycloak/blob/main/services/src/main/java/org/keycloak/userprofile/config/UPAttribute.java
|
// See: https://github.com/keycloak/keycloak/blob/main/services/src/main/java/org/keycloak/userprofile/config/UPAttribute.java
|
||||||
export interface UserProfileAttribute {
|
export interface UserProfileAttribute {
|
||||||
name?: string;
|
name?: string;
|
||||||
validations?: Record<string, unknown>;
|
validations?: Record<string, Record<string, unknown>>;
|
||||||
validators?: Record<string, unknown>;
|
|
||||||
annotations?: Record<string, unknown>;
|
annotations?: Record<string, unknown>;
|
||||||
required?: UserProfileAttributeRequired;
|
required?: UserProfileAttributeRequired;
|
||||||
readOnly?: boolean;
|
|
||||||
permissions?: UserProfileAttributePermissions;
|
permissions?: UserProfileAttributePermissions;
|
||||||
selector?: UserProfileAttributeSelector;
|
selector?: UserProfileAttributeSelector;
|
||||||
displayName?: string;
|
displayName?: string;
|
||||||
|
|
|
@ -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 CredentialRepresentation from "./credentialRepresentation.js";
|
||||||
import type FederatedIdentityRepresentation from "./federatedIdentityRepresentation.js";
|
import type FederatedIdentityRepresentation from "./federatedIdentityRepresentation.js";
|
||||||
import type { RequiredActionAlias } from "./requiredActionProviderRepresentation.js";
|
import type { RequiredActionAlias } from "./requiredActionProviderRepresentation.js";
|
||||||
import type UserProfileConfig from "./userProfileConfig.js";
|
import type UserConsentRepresentation from "./userConsentRepresentation.js";
|
||||||
|
import type { UserProfileMetadata } from "./userProfileMetadata.js";
|
||||||
|
|
||||||
export default interface UserRepresentation {
|
export default interface UserRepresentation {
|
||||||
id?: string;
|
id?: string;
|
||||||
|
@ -31,5 +31,5 @@ export default interface UserRepresentation {
|
||||||
realmRoles?: string[];
|
realmRoles?: string[];
|
||||||
self?: string;
|
self?: string;
|
||||||
serviceAccountClientId?: string;
|
serviceAccountClientId?: string;
|
||||||
userProfileMetadata?: UserProfileConfig;
|
userProfileMetadata?: UserProfileMetadata;
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,16 +1,17 @@
|
||||||
import Resource from "./resource.js";
|
|
||||||
import type UserRepresentation from "../defs/userRepresentation.js";
|
|
||||||
import type UserConsentRepresentation from "../defs/userConsentRepresentation.js";
|
|
||||||
import type UserSessionRepresentation from "../defs/userSessionRepresentation.js";
|
|
||||||
import type { KeycloakAdminClient } from "../client.js";
|
import type { KeycloakAdminClient } from "../client.js";
|
||||||
import type MappingsRepresentation from "../defs/mappingsRepresentation.js";
|
import type CredentialRepresentation from "../defs/credentialRepresentation.js";
|
||||||
import type RoleRepresentation from "../defs/roleRepresentation.js";
|
|
||||||
import type { RoleMappingPayload } from "../defs/roleRepresentation.js";
|
|
||||||
import type { RequiredActionAlias } from "../defs/requiredActionProviderRepresentation.js";
|
|
||||||
import type FederatedIdentityRepresentation from "../defs/federatedIdentityRepresentation.js";
|
import type FederatedIdentityRepresentation from "../defs/federatedIdentityRepresentation.js";
|
||||||
import type GroupRepresentation from "../defs/groupRepresentation.js";
|
import type GroupRepresentation from "../defs/groupRepresentation.js";
|
||||||
import type CredentialRepresentation from "../defs/credentialRepresentation.js";
|
import type MappingsRepresentation from "../defs/mappingsRepresentation.js";
|
||||||
import type UserProfileConfig from "../defs/userProfileConfig.js";
|
import type { RequiredActionAlias } from "../defs/requiredActionProviderRepresentation.js";
|
||||||
|
import type RoleRepresentation from "../defs/roleRepresentation.js";
|
||||||
|
import type { RoleMappingPayload } from "../defs/roleRepresentation.js";
|
||||||
|
import type UserConsentRepresentation from "../defs/userConsentRepresentation.js";
|
||||||
|
import type { UserProfileConfig } from "../defs/userProfileConfig.js";
|
||||||
|
import type { UserProfileMetadata } from "../defs/userProfileMetadata.js";
|
||||||
|
import type UserRepresentation from "../defs/userRepresentation.js";
|
||||||
|
import type UserSessionRepresentation from "../defs/userSessionRepresentation.js";
|
||||||
|
import Resource from "./resource.js";
|
||||||
|
|
||||||
interface SearchQuery {
|
interface SearchQuery {
|
||||||
search?: string;
|
search?: string;
|
||||||
|
@ -90,6 +91,11 @@ export class Users extends Resource<{ realm?: string }> {
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
|
public getProfileMetadata = this.makeRequest<{}, UserProfileMetadata>({
|
||||||
|
method: "GET",
|
||||||
|
path: "/profile/metadata",
|
||||||
|
});
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* role mappings
|
* role mappings
|
||||||
*/
|
*/
|
||||||
|
|
|
@ -34,15 +34,17 @@ public final class AttributeContext {
|
||||||
private final Map.Entry<String, List<String>> attribute;
|
private final Map.Entry<String, List<String>> attribute;
|
||||||
private final UserModel user;
|
private final UserModel user;
|
||||||
private final AttributeMetadata metadata;
|
private final AttributeMetadata metadata;
|
||||||
|
private final Attributes attributes;
|
||||||
private UserProfileContext context;
|
private UserProfileContext context;
|
||||||
|
|
||||||
public AttributeContext(UserProfileContext context, KeycloakSession session, Map.Entry<String, List<String>> attribute,
|
public AttributeContext(UserProfileContext context, KeycloakSession session, Map.Entry<String, List<String>> attribute,
|
||||||
UserModel user, AttributeMetadata metadata) {
|
UserModel user, AttributeMetadata metadata, Attributes attributes) {
|
||||||
this.context = context;
|
this.context = context;
|
||||||
this.session = session;
|
this.session = session;
|
||||||
this.attribute = attribute;
|
this.attribute = attribute;
|
||||||
this.user = user;
|
this.user = user;
|
||||||
this.metadata = metadata;
|
this.metadata = metadata;
|
||||||
|
this.attributes = attributes;
|
||||||
}
|
}
|
||||||
|
|
||||||
public KeycloakSession getSession() {
|
public KeycloakSession getSession() {
|
||||||
|
@ -64,4 +66,8 @@ public final class AttributeContext {
|
||||||
public AttributeMetadata getMetadata() {
|
public AttributeMetadata getMetadata() {
|
||||||
return metadata;
|
return metadata;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public Attributes getAttributes() {
|
||||||
|
return attributes;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -232,11 +232,11 @@ public class DefaultAttributes extends HashMap<String, List<String>> implements
|
||||||
}
|
}
|
||||||
|
|
||||||
private AttributeContext createAttributeContext(Entry<String, List<String>> attribute, AttributeMetadata metadata) {
|
private AttributeContext createAttributeContext(Entry<String, List<String>> attribute, AttributeMetadata metadata) {
|
||||||
return new AttributeContext(context, session, attribute, user, metadata);
|
return new AttributeContext(context, session, attribute, user, metadata, this);
|
||||||
}
|
}
|
||||||
|
|
||||||
private AttributeContext createAttributeContext(String attributeName, AttributeMetadata metadata) {
|
private AttributeContext createAttributeContext(String attributeName, AttributeMetadata metadata) {
|
||||||
return new AttributeContext(context, session, createAttribute(attributeName), user, metadata);
|
return new AttributeContext(context, session, createAttribute(attributeName), user, metadata, this);
|
||||||
}
|
}
|
||||||
|
|
||||||
protected AttributeContext createAttributeContext(AttributeMetadata metadata) {
|
protected AttributeContext createAttributeContext(AttributeMetadata metadata) {
|
||||||
|
|
|
@ -19,6 +19,7 @@ package org.keycloak.userprofile;
|
||||||
|
|
||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
|
|
||||||
|
import org.keycloak.models.RealmModel;
|
||||||
import org.keycloak.models.UserModel;
|
import org.keycloak.models.UserModel;
|
||||||
import org.keycloak.provider.Provider;
|
import org.keycloak.provider.Provider;
|
||||||
|
|
||||||
|
@ -87,4 +88,12 @@ public interface UserProfileProvider extends Provider {
|
||||||
* @see #getConfiguration()
|
* @see #getConfiguration()
|
||||||
*/
|
*/
|
||||||
void setConfiguration(String configuration);
|
void setConfiguration(String configuration);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns whether the declarative provider is enabled to a realm
|
||||||
|
*
|
||||||
|
* @deprecated should be removed once {@link DeclarativeUserProfileProvider} becomes the default.
|
||||||
|
* @return {@code true} if the declarative provider is enabled. Otherwise, {@code false}.
|
||||||
|
*/
|
||||||
|
boolean isEnabled(RealmModel realm);
|
||||||
}
|
}
|
||||||
|
|
|
@ -306,7 +306,7 @@ public class FreeMarkerLoginFormsProvider implements LoginFormsProvider {
|
||||||
}
|
}
|
||||||
|
|
||||||
private boolean isDynamicUserProfile() {
|
private boolean isDynamicUserProfile() {
|
||||||
return session.getProvider(UserProfileProvider.class).getConfiguration() != null;
|
return session.getProvider(UserProfileProvider.class).isEnabled(realm);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
|
@ -80,11 +80,11 @@ import org.keycloak.services.managers.Auth;
|
||||||
import org.keycloak.services.managers.UserConsentManager;
|
import org.keycloak.services.managers.UserConsentManager;
|
||||||
import org.keycloak.services.messages.Messages;
|
import org.keycloak.services.messages.Messages;
|
||||||
import org.keycloak.services.resources.account.resources.ResourcesService;
|
import org.keycloak.services.resources.account.resources.ResourcesService;
|
||||||
|
import org.keycloak.services.resources.admin.UserProfileResource;
|
||||||
import org.keycloak.services.util.ResolveRelative;
|
import org.keycloak.services.util.ResolveRelative;
|
||||||
import org.keycloak.storage.ReadOnlyException;
|
import org.keycloak.storage.ReadOnlyException;
|
||||||
import org.keycloak.theme.Theme;
|
import org.keycloak.theme.Theme;
|
||||||
import org.keycloak.userprofile.AttributeMetadata;
|
import org.keycloak.userprofile.AttributeMetadata;
|
||||||
import org.keycloak.userprofile.AttributeValidatorMetadata;
|
|
||||||
import org.keycloak.userprofile.Attributes;
|
import org.keycloak.userprofile.Attributes;
|
||||||
import org.keycloak.userprofile.UserProfile;
|
import org.keycloak.userprofile.UserProfile;
|
||||||
import org.keycloak.userprofile.UserProfileContext;
|
import org.keycloak.userprofile.UserProfileContext;
|
||||||
|
@ -153,7 +153,7 @@ public class AccountRestService {
|
||||||
addReadableBuiltinAttributes(user, rep, profile.getAttributes().getReadable(true).keySet());
|
addReadableBuiltinAttributes(user, rep, profile.getAttributes().getReadable(true).keySet());
|
||||||
|
|
||||||
if(userProfileMetadata == null || userProfileMetadata.booleanValue())
|
if(userProfileMetadata == null || userProfileMetadata.booleanValue())
|
||||||
rep.setUserProfileMetadata(createUserProfileMetadata(profile));
|
rep.setUserProfileMetadata(UserProfileResource.createUserProfileMetadata(session, profile));
|
||||||
|
|
||||||
return rep;
|
return rep;
|
||||||
}
|
}
|
||||||
|
@ -173,37 +173,6 @@ public class AccountRestService {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private UserProfileMetadata createUserProfileMetadata(final UserProfile profile) {
|
|
||||||
Map<String, List<String>> am = profile.getAttributes().getReadable();
|
|
||||||
|
|
||||||
if(am == null)
|
|
||||||
return null;
|
|
||||||
|
|
||||||
List<UserProfileAttributeMetadata> attributes = am.keySet().stream()
|
|
||||||
.map(name -> profile.getAttributes().getMetadata(name))
|
|
||||||
.filter(Objects::nonNull)
|
|
||||||
.sorted((a,b) -> Integer.compare(a.getGuiOrder(), b.getGuiOrder()))
|
|
||||||
.map(sam -> toRestMetadata(sam, profile))
|
|
||||||
.collect(Collectors.toList());
|
|
||||||
return new UserProfileMetadata(attributes);
|
|
||||||
}
|
|
||||||
|
|
||||||
private UserProfileAttributeMetadata toRestMetadata(AttributeMetadata am, UserProfile profile) {
|
|
||||||
return new UserProfileAttributeMetadata(am.getName(),
|
|
||||||
am.getAttributeDisplayName(),
|
|
||||||
profile.getAttributes().isRequired(am.getName()),
|
|
||||||
profile.getAttributes().isReadOnly(am.getName()),
|
|
||||||
am.getAnnotations(),
|
|
||||||
toValidatorMetadata(am));
|
|
||||||
}
|
|
||||||
|
|
||||||
private Map<String, Map<String, Object>> toValidatorMetadata(AttributeMetadata am){
|
|
||||||
// we return only validators which are instance of ConfiguredProvider. Others are expected as internal.
|
|
||||||
return am.getValidators() == null ? null : am.getValidators().stream()
|
|
||||||
.filter(avm -> (Validators.validator(session, avm.getValidatorId()) instanceof ConfiguredProvider))
|
|
||||||
.collect(Collectors.toMap(AttributeValidatorMetadata::getValidatorId, AttributeValidatorMetadata::getValidatorConfig));
|
|
||||||
}
|
|
||||||
|
|
||||||
@Path("/")
|
@Path("/")
|
||||||
@POST
|
@POST
|
||||||
@Consumes(MediaType.APPLICATION_JSON)
|
@Consumes(MediaType.APPLICATION_JSON)
|
||||||
|
|
|
@ -16,6 +16,15 @@
|
||||||
*/
|
*/
|
||||||
package org.keycloak.services.resources.admin;
|
package org.keycloak.services.resources.admin;
|
||||||
|
|
||||||
|
import java.util.Collections;
|
||||||
|
import java.util.Comparator;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
import java.util.Objects;
|
||||||
|
import java.util.function.Function;
|
||||||
|
import java.util.stream.Collectors;
|
||||||
|
|
||||||
|
import jakarta.ws.rs.Path;
|
||||||
import org.eclipse.microprofile.openapi.annotations.Operation;
|
import org.eclipse.microprofile.openapi.annotations.Operation;
|
||||||
import jakarta.ws.rs.Consumes;
|
import jakarta.ws.rs.Consumes;
|
||||||
import jakarta.ws.rs.GET;
|
import jakarta.ws.rs.GET;
|
||||||
|
@ -29,10 +38,22 @@ import org.eclipse.microprofile.openapi.annotations.tags.Tag;
|
||||||
import org.keycloak.component.ComponentValidationException;
|
import org.keycloak.component.ComponentValidationException;
|
||||||
import org.keycloak.models.KeycloakSession;
|
import org.keycloak.models.KeycloakSession;
|
||||||
import org.keycloak.models.RealmModel;
|
import org.keycloak.models.RealmModel;
|
||||||
|
import org.keycloak.provider.ConfiguredProvider;
|
||||||
|
import org.keycloak.representations.idm.UserProfileAttributeGroupMetadata;
|
||||||
|
import org.keycloak.representations.idm.UserProfileAttributeMetadata;
|
||||||
|
import org.keycloak.representations.idm.UserProfileMetadata;
|
||||||
import org.keycloak.services.ErrorResponse;
|
import org.keycloak.services.ErrorResponse;
|
||||||
import org.keycloak.services.resources.KeycloakOpenAPI;
|
import org.keycloak.services.resources.KeycloakOpenAPI;
|
||||||
import org.keycloak.services.resources.admin.permissions.AdminPermissionEvaluator;
|
import org.keycloak.services.resources.admin.permissions.AdminPermissionEvaluator;
|
||||||
|
import org.keycloak.userprofile.AttributeMetadata;
|
||||||
|
import org.keycloak.userprofile.AttributeValidatorMetadata;
|
||||||
|
import org.keycloak.userprofile.UserProfile;
|
||||||
|
import org.keycloak.userprofile.UserProfileContext;
|
||||||
import org.keycloak.userprofile.UserProfileProvider;
|
import org.keycloak.userprofile.UserProfileProvider;
|
||||||
|
import org.keycloak.userprofile.config.UPConfig;
|
||||||
|
import org.keycloak.userprofile.config.UPGroup;
|
||||||
|
import org.keycloak.util.JsonSerialization;
|
||||||
|
import org.keycloak.validate.Validators;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @author Vlastimil Elias <velias@redhat.com>
|
* @author Vlastimil Elias <velias@redhat.com>
|
||||||
|
@ -60,6 +81,17 @@ public class UserProfileResource {
|
||||||
return session.getProvider(UserProfileProvider.class).getConfiguration();
|
return session.getProvider(UserProfileProvider.class).getConfiguration();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@GET
|
||||||
|
@Path("/metadata")
|
||||||
|
@Produces(MediaType.APPLICATION_JSON)
|
||||||
|
@Tag(name = KeycloakOpenAPI.Admin.Tags.USERS)
|
||||||
|
@Operation()
|
||||||
|
public UserProfileMetadata getMetadata() {
|
||||||
|
auth.requireAnyAdminRole();
|
||||||
|
UserProfile profile = session.getProvider(UserProfileProvider.class).create(UserProfileContext.USER_API, Collections.emptyMap());
|
||||||
|
return createUserProfileMetadata(session, profile);
|
||||||
|
}
|
||||||
|
|
||||||
@PUT
|
@PUT
|
||||||
@Consumes(MediaType.APPLICATION_JSON)
|
@Consumes(MediaType.APPLICATION_JSON)
|
||||||
@Tag(name = KeycloakOpenAPI.Admin.Tags.USERS)
|
@Tag(name = KeycloakOpenAPI.Admin.Tags.USERS)
|
||||||
|
@ -78,4 +110,58 @@ public class UserProfileResource {
|
||||||
return Response.ok(t.getConfiguration()).type(MediaType.APPLICATION_JSON).build();
|
return Response.ok(t.getConfiguration()).type(MediaType.APPLICATION_JSON).build();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public static UserProfileMetadata createUserProfileMetadata(KeycloakSession session, UserProfile profile) {
|
||||||
|
Map<String, List<String>> am = profile.getAttributes().getReadable();
|
||||||
|
|
||||||
|
if(am == null)
|
||||||
|
return null;
|
||||||
|
|
||||||
|
List<UserProfileAttributeMetadata> attributes = am.keySet().stream()
|
||||||
|
.map(name -> profile.getAttributes().getMetadata(name))
|
||||||
|
.filter(Objects::nonNull)
|
||||||
|
.sorted(Comparator.comparingInt(AttributeMetadata::getGuiOrder))
|
||||||
|
.map(sam -> toRestMetadata(sam, session, profile))
|
||||||
|
.collect(Collectors.toList());
|
||||||
|
|
||||||
|
UserProfileProvider provider = session.getProvider(UserProfileProvider.class);
|
||||||
|
UPConfig config;
|
||||||
|
|
||||||
|
try {
|
||||||
|
config = JsonSerialization.readValue(provider.getConfiguration(), UPConfig.class);
|
||||||
|
} catch (Exception cause) {
|
||||||
|
throw new RuntimeException("Failed to parse configuration", cause);
|
||||||
|
}
|
||||||
|
|
||||||
|
List<UserProfileAttributeGroupMetadata> groups = config.getGroups().stream().map(new Function<UPGroup, UserProfileAttributeGroupMetadata>() {
|
||||||
|
@Override
|
||||||
|
public UserProfileAttributeGroupMetadata apply(UPGroup upGroup) {
|
||||||
|
return new UserProfileAttributeGroupMetadata(upGroup.getName(), upGroup.getDisplayHeader(), upGroup.getDisplayDescription(), upGroup.getAnnotations());
|
||||||
|
}
|
||||||
|
}).collect(Collectors.toList());
|
||||||
|
|
||||||
|
return new UserProfileMetadata(attributes, groups);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static UserProfileAttributeMetadata toRestMetadata(AttributeMetadata am, KeycloakSession session, UserProfile profile) {
|
||||||
|
String group = null;
|
||||||
|
|
||||||
|
if (am.getAttributeGroupMetadata() != null) {
|
||||||
|
group = am.getAttributeGroupMetadata().getName();
|
||||||
|
}
|
||||||
|
|
||||||
|
return new UserProfileAttributeMetadata(am.getName(),
|
||||||
|
am.getAttributeDisplayName(),
|
||||||
|
profile.getAttributes().isRequired(am.getName()),
|
||||||
|
profile.getAttributes().isReadOnly(am.getName()),
|
||||||
|
group,
|
||||||
|
am.getAnnotations(),
|
||||||
|
toValidatorMetadata(am, session));
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Map<String, Map<String, Object>> toValidatorMetadata(AttributeMetadata am, KeycloakSession session){
|
||||||
|
// we return only validators which are instance of ConfiguredProvider. Others are expected as internal.
|
||||||
|
return am.getValidators() == null ? null : am.getValidators().stream()
|
||||||
|
.filter(avm -> (Validators.validator(session, avm.getValidatorId()) instanceof ConfiguredProvider))
|
||||||
|
.collect(Collectors.toMap(AttributeValidatorMetadata::getValidatorId, AttributeValidatorMetadata::getValidatorConfig));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -127,6 +127,7 @@ import java.util.stream.Stream;
|
||||||
|
|
||||||
import static org.keycloak.models.ImpersonationSessionNote.IMPERSONATOR_ID;
|
import static org.keycloak.models.ImpersonationSessionNote.IMPERSONATOR_ID;
|
||||||
import static org.keycloak.models.ImpersonationSessionNote.IMPERSONATOR_USERNAME;
|
import static org.keycloak.models.ImpersonationSessionNote.IMPERSONATOR_USERNAME;
|
||||||
|
import static org.keycloak.services.resources.admin.UserProfileResource.createUserProfileMetadata;
|
||||||
import static org.keycloak.userprofile.UserProfileContext.USER_API;
|
import static org.keycloak.userprofile.UserProfileContext.USER_API;
|
||||||
import static org.keycloak.utils.LockObjectsForModification.lockUserSessionsForModification;
|
import static org.keycloak.utils.LockObjectsForModification.lockUserSessionsForModification;
|
||||||
|
|
||||||
|
@ -336,7 +337,7 @@ public class UserResource {
|
||||||
}
|
}
|
||||||
|
|
||||||
if (userProfileMetadata) {
|
if (userProfileMetadata) {
|
||||||
rep.setUserProfileMetadata(createUserProfileMetadata(profile));
|
rep.setUserProfileMetadata(createUserProfileMetadata(session, profile));
|
||||||
}
|
}
|
||||||
|
|
||||||
return rep;
|
return rep;
|
||||||
|
@ -1068,35 +1069,4 @@ public class UserResource {
|
||||||
rep.setLastAccess(Time.toMillis(clientSession.getTimestamp()));
|
rep.setLastAccess(Time.toMillis(clientSession.getTimestamp()));
|
||||||
return rep;
|
return rep;
|
||||||
}
|
}
|
||||||
|
|
||||||
private UserProfileMetadata createUserProfileMetadata(final UserProfile profile) {
|
|
||||||
Map<String, List<String>> am = profile.getAttributes().getReadable();
|
|
||||||
|
|
||||||
if(am == null)
|
|
||||||
return null;
|
|
||||||
|
|
||||||
List<UserProfileAttributeMetadata> attributes = am.keySet().stream()
|
|
||||||
.map(name -> profile.getAttributes().getMetadata(name))
|
|
||||||
.filter(Objects::nonNull)
|
|
||||||
.sorted((a,b) -> Integer.compare(a.getGuiOrder(), b.getGuiOrder()))
|
|
||||||
.map(sam -> toRestMetadata(sam, profile))
|
|
||||||
.collect(Collectors.toList());
|
|
||||||
return new UserProfileMetadata(attributes);
|
|
||||||
}
|
|
||||||
|
|
||||||
private UserProfileAttributeMetadata toRestMetadata(AttributeMetadata am, UserProfile profile) {
|
|
||||||
return new UserProfileAttributeMetadata(am.getName(),
|
|
||||||
am.getAttributeDisplayName(),
|
|
||||||
profile.getAttributes().isRequired(am.getName()),
|
|
||||||
profile.getAttributes().isReadOnly(am.getName()),
|
|
||||||
am.getAnnotations(),
|
|
||||||
toValidatorMetadata(am));
|
|
||||||
}
|
|
||||||
|
|
||||||
private Map<String, Map<String, Object>> toValidatorMetadata(AttributeMetadata am){
|
|
||||||
// we return only validators which are instance of ConfiguredProvider. Others are expected as internal.
|
|
||||||
return am.getValidators() == null ? null : am.getValidators().stream()
|
|
||||||
.filter(avm -> (Validators.validator(session, avm.getValidatorId()) instanceof ConfiguredProvider))
|
|
||||||
.collect(Collectors.toMap(AttributeValidatorMetadata::getValidatorId, AttributeValidatorMetadata::getValidatorConfig));
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
|
@ -87,6 +87,11 @@ public abstract class AbstractUserProfileProvider<U extends UserProfileProvider>
|
||||||
if (realm.isRegistrationEmailAsUsername()) {
|
if (realm.isRegistrationEmailAsUsername()) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (isNewUser(c)) {
|
||||||
|
// when creating a user the username is always editable
|
||||||
|
return true;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return realm.isEditUsernameAllowed();
|
return realm.isEditUsernameAllowed();
|
||||||
|
@ -116,23 +121,15 @@ public abstract class AbstractUserProfileProvider<U extends UserProfileProvider>
|
||||||
private static boolean editEmailCondition(AttributeContext c) {
|
private static boolean editEmailCondition(AttributeContext c) {
|
||||||
RealmModel realm = c.getSession().getContext().getRealm();
|
RealmModel realm = c.getSession().getContext().getRealm();
|
||||||
|
|
||||||
if (REGISTRATION_PROFILE.equals(c.getContext())) {
|
if (REGISTRATION_PROFILE.equals(c.getContext()) || USER_API.equals(c.getContext())) {
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (USER_API.equals(c.getContext())) {
|
|
||||||
if (realm.isRegistrationEmailAsUsername()) {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (Profile.isFeatureEnabled(Feature.UPDATE_EMAIL)) {
|
if (Profile.isFeatureEnabled(Feature.UPDATE_EMAIL)) {
|
||||||
return !(UPDATE_PROFILE.equals(c.getContext()) || ACCOUNT.equals(c.getContext()));
|
return !(UPDATE_PROFILE.equals(c.getContext()) || ACCOUNT.equals(c.getContext()));
|
||||||
}
|
}
|
||||||
|
|
||||||
UserModel user = c.getUser();
|
if (!isNewUser(c) && realm.isRegistrationEmailAsUsername() && !realm.isEditUsernameAllowed()) {
|
||||||
|
|
||||||
if (user != null && realm.isRegistrationEmailAsUsername() && !realm.isEditUsernameAllowed()) {
|
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -142,7 +139,7 @@ public abstract class AbstractUserProfileProvider<U extends UserProfileProvider>
|
||||||
private static boolean readEmailCondition(AttributeContext c) {
|
private static boolean readEmailCondition(AttributeContext c) {
|
||||||
UserProfileContext context = c.getContext();
|
UserProfileContext context = c.getContext();
|
||||||
|
|
||||||
if (REGISTRATION_PROFILE.equals(context)) {
|
if (REGISTRATION_PROFILE.equals(context) || USER_API.equals(c.getContext())) {
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -183,6 +180,10 @@ public abstract class AbstractUserProfileProvider<U extends UserProfileProvider>
|
||||||
return realm.isInternationalizationEnabled();
|
return realm.isInternationalizationEnabled();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static boolean isNewUser(AttributeContext c) {
|
||||||
|
return c.getUser() == null;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* There are the declarations for creating the built-in validations for read-only attributes. Regardless of the context where
|
* There are the declarations for creating the built-in validations for read-only attributes. Regardless of the context where
|
||||||
* user profiles are used. They are related to internal attributes with hard conditions on them in terms of management.
|
* user profiles are used. They are related to internal attributes with hard conditions on them in terms of management.
|
||||||
|
|
|
@ -126,7 +126,9 @@ public class DeclarativeUserProfileProvider extends AbstractUserProfileProvider<
|
||||||
@Override
|
@Override
|
||||||
protected Attributes createAttributes(UserProfileContext context, Map<String, ?> attributes,
|
protected Attributes createAttributes(UserProfileContext context, Map<String, ?> attributes,
|
||||||
UserModel user, UserProfileMetadata metadata) {
|
UserModel user, UserProfileMetadata metadata) {
|
||||||
if (isEnabled(session)) {
|
RealmModel realm = session.getContext().getRealm();
|
||||||
|
|
||||||
|
if (isEnabled(realm)) {
|
||||||
if (user != null && user.getServiceAccountClientLink() != null) {
|
if (user != null && user.getServiceAccountClientLink() != null) {
|
||||||
return new LegacyAttributes(context, attributes, user, metadata, session);
|
return new LegacyAttributes(context, attributes, user, metadata, session);
|
||||||
}
|
}
|
||||||
|
@ -139,8 +141,9 @@ public class DeclarativeUserProfileProvider extends AbstractUserProfileProvider<
|
||||||
protected UserProfileMetadata configureUserProfile(UserProfileMetadata metadata, KeycloakSession session) {
|
protected UserProfileMetadata configureUserProfile(UserProfileMetadata metadata, KeycloakSession session) {
|
||||||
UserProfileContext context = metadata.getContext();
|
UserProfileContext context = metadata.getContext();
|
||||||
UserProfileMetadata decoratedMetadata = metadata.clone();
|
UserProfileMetadata decoratedMetadata = metadata.clone();
|
||||||
|
RealmModel realm = session.getContext().getRealm();
|
||||||
|
|
||||||
if (!isEnabled(session)) {
|
if (!isEnabled(realm)) {
|
||||||
if(!context.equals(UserProfileContext.USER_API)
|
if(!context.equals(UserProfileContext.USER_API)
|
||||||
&& !context.equals(UserProfileContext.REGISTRATION_USER_CREATION)
|
&& !context.equals(UserProfileContext.REGISTRATION_USER_CREATION)
|
||||||
&& !context.equals(UserProfileContext.UPDATE_EMAIL)) {
|
&& !context.equals(UserProfileContext.UPDATE_EMAIL)) {
|
||||||
|
@ -194,8 +197,10 @@ public class DeclarativeUserProfileProvider extends AbstractUserProfileProvider<
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public String getConfiguration() {
|
public String getConfiguration() {
|
||||||
if (!isEnabled(session)) {
|
RealmModel realm = session.getContext().getRealm();
|
||||||
return null;
|
|
||||||
|
if (!isEnabled(realm)) {
|
||||||
|
return defaultRawConfig;
|
||||||
}
|
}
|
||||||
|
|
||||||
String cfg = getConfigJsonFromComponentModel(getComponentModel());
|
String cfg = getConfigJsonFromComponentModel(getComponentModel());
|
||||||
|
@ -349,7 +354,31 @@ public class DeclarativeUserProfileProvider extends AbstractUserProfileProvider<
|
||||||
}
|
}
|
||||||
|
|
||||||
if (UserModel.USERNAME.equals(attributeName)) {
|
if (UserModel.USERNAME.equals(attributeName)) {
|
||||||
required = AttributeMetadata.ALWAYS_TRUE;
|
required = new Predicate<AttributeContext>() {
|
||||||
|
@Override
|
||||||
|
public boolean test(AttributeContext context) {
|
||||||
|
RealmModel realm = context.getSession().getContext().getRealm();
|
||||||
|
return !realm.isRegistrationEmailAsUsername();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (UserModel.EMAIL.equals(attributeName)) {
|
||||||
|
if (UserProfileContext.USER_API.equals(context)) {
|
||||||
|
required = new Predicate<AttributeContext>() {
|
||||||
|
@Override
|
||||||
|
public boolean test(AttributeContext context) {
|
||||||
|
UserModel user = context.getUser();
|
||||||
|
|
||||||
|
if (user != null && user.getServiceAccountClientLink() != null) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
RealmModel realm = context.getSession().getContext().getRealm();
|
||||||
|
return realm.isRegistrationEmailAsUsername();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Add ImmutableAttributeValidator to ensure that attributes that are configured
|
// Add ImmutableAttributeValidator to ensure that attributes that are configured
|
||||||
|
@ -505,14 +534,8 @@ public class DeclarativeUserProfileProvider extends AbstractUserProfileProvider<
|
||||||
model.getConfig().remove(UP_PIECES_COUNT_COMPONENT_CONFIG_KEY);
|
model.getConfig().remove(UP_PIECES_COUNT_COMPONENT_CONFIG_KEY);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
@Override
|
||||||
* Returns whether the declarative provider is enabled to a realm
|
public boolean isEnabled(RealmModel realm) {
|
||||||
*
|
return isDeclarativeConfigurationEnabled && realm.getAttribute(REALM_USER_PROFILE_ENABLED, false);
|
||||||
* @deprecated should be removed once {@link DeclarativeUserProfileProvider} becomes the default.
|
|
||||||
* @param session the session
|
|
||||||
* @return {@code true} if the declarative provider is enabled. Otherwise, {@code false}.
|
|
||||||
*/
|
|
||||||
private Boolean isEnabled(KeycloakSession session) {
|
|
||||||
return isDeclarativeConfigurationEnabled && session.getContext().getRealm().getAttribute(REALM_USER_PROFILE_ENABLED, false);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -19,6 +19,7 @@ package org.keycloak.userprofile.validator;
|
||||||
import static org.keycloak.validate.Validators.notBlankValidator;
|
import static org.keycloak.validate.Validators.notBlankValidator;
|
||||||
|
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
|
import java.util.Objects;
|
||||||
import java.util.stream.Collectors;
|
import java.util.stream.Collectors;
|
||||||
|
|
||||||
import org.keycloak.common.util.CollectionUtil;
|
import org.keycloak.common.util.CollectionUtil;
|
||||||
|
@ -57,7 +58,7 @@ public class ImmutableAttributeValidator implements SimpleValidator {
|
||||||
return context;
|
return context;
|
||||||
}
|
}
|
||||||
|
|
||||||
List<String> currentValue = user.getAttributeStream(inputHint).collect(Collectors.toList());
|
List<String> currentValue = user.getAttributeStream(inputHint).filter(Objects::nonNull).collect(Collectors.toList());
|
||||||
List<String> values = (List<String>) input;
|
List<String> values = (List<String>) input;
|
||||||
|
|
||||||
if (!CollectionUtil.collectionEquals(currentValue, values) && isReadOnly(attributeContext)) {
|
if (!CollectionUtil.collectionEquals(currentValue, values) && isReadOnly(attributeContext)) {
|
||||||
|
|
|
@ -24,8 +24,8 @@ import org.keycloak.models.utils.KeycloakModelUtils;
|
||||||
import org.keycloak.services.messages.Messages;
|
import org.keycloak.services.messages.Messages;
|
||||||
import org.keycloak.services.validation.Validation;
|
import org.keycloak.services.validation.Validation;
|
||||||
import org.keycloak.userprofile.AttributeContext;
|
import org.keycloak.userprofile.AttributeContext;
|
||||||
|
import org.keycloak.userprofile.Attributes;
|
||||||
import org.keycloak.userprofile.UserProfileAttributeValidationContext;
|
import org.keycloak.userprofile.UserProfileAttributeValidationContext;
|
||||||
import org.keycloak.userprofile.UserProfileContext;
|
|
||||||
import org.keycloak.validate.SimpleValidator;
|
import org.keycloak.validate.SimpleValidator;
|
||||||
import org.keycloak.validate.ValidationContext;
|
import org.keycloak.validate.ValidationContext;
|
||||||
import org.keycloak.validate.ValidationError;
|
import org.keycloak.validate.ValidationError;
|
||||||
|
@ -69,7 +69,8 @@ public class UsernameMutationValidator implements SimpleValidator {
|
||||||
if (! KeycloakModelUtils.isUsernameCaseSensitive(realm)) value = value.toLowerCase();
|
if (! KeycloakModelUtils.isUsernameCaseSensitive(realm)) value = value.toLowerCase();
|
||||||
|
|
||||||
if (!realm.isEditUsernameAllowed() && user != null && !value.equals(user.getFirstAttribute(UserModel.USERNAME))) {
|
if (!realm.isEditUsernameAllowed() && user != null && !value.equals(user.getFirstAttribute(UserModel.USERNAME))) {
|
||||||
if (realm.isRegistrationEmailAsUsername() && UserProfileContext.UPDATE_PROFILE.equals(attributeContext.getContext())) {
|
Attributes attributes = attributeContext.getAttributes();
|
||||||
|
if (realm.isRegistrationEmailAsUsername() && value.equals(attributes.getFirstValue(UserModel.EMAIL))) {
|
||||||
// if username changed is because email as username is allowed so no validation should happen for update profile
|
// if username changed is because email as username is allowed so no validation should happen for update profile
|
||||||
// it is expected that username changes when attributes are normalized by the provider
|
// it is expected that username changes when attributes are normalized by the provider
|
||||||
return context;
|
return context;
|
||||||
|
|
|
@ -113,12 +113,14 @@ public class AccountRestServiceTest extends AbstractRestServiceTest {
|
||||||
realmRep.setEditUsernameAllowed(true);
|
realmRep.setEditUsernameAllowed(true);
|
||||||
realm.update(realmRep);
|
realm.update(realmRep);
|
||||||
user = getUser();
|
user = getUser();
|
||||||
assertNotNull(user.getUserProfileMetadata());
|
if (isDeclarativeUserProfile()) {
|
||||||
// can write both username and email
|
assertNotNull(user.getUserProfileMetadata());
|
||||||
assertUserProfileAttributeMetadata(user, "username", "${username}", true, false);
|
// can write both username and email
|
||||||
assertUserProfileAttributeMetadata(user, "email", "${email}", true, false);
|
assertUserProfileAttributeMetadata(user, "username", "${username}", true, false);
|
||||||
assertUserProfileAttributeMetadata(user, "firstName", "${firstName}", true, false);
|
assertUserProfileAttributeMetadata(user, "email", "${email}", true, false);
|
||||||
assertUserProfileAttributeMetadata(user, "lastName", "${lastName}", true, false);
|
assertUserProfileAttributeMetadata(user, "firstName", "${firstName}", true, false);
|
||||||
|
assertUserProfileAttributeMetadata(user, "lastName", "${lastName}", true, false);
|
||||||
|
}
|
||||||
user.setUsername("changed-username");
|
user.setUsername("changed-username");
|
||||||
user.setEmail("changed-email@keycloak.org");
|
user.setEmail("changed-email@keycloak.org");
|
||||||
user = updateAndGet(user);
|
user = updateAndGet(user);
|
||||||
|
@ -129,10 +131,12 @@ public class AccountRestServiceTest extends AbstractRestServiceTest {
|
||||||
realmRep.setEditUsernameAllowed(false);
|
realmRep.setEditUsernameAllowed(false);
|
||||||
realm.update(realmRep);
|
realm.update(realmRep);
|
||||||
user = getUser();
|
user = getUser();
|
||||||
assertNotNull(user.getUserProfileMetadata());
|
if (isDeclarativeUserProfile()) {
|
||||||
// username is readonly but email is writable
|
assertNotNull(user.getUserProfileMetadata());
|
||||||
assertUserProfileAttributeMetadata(user, "username", "${username}", true, true);
|
// username is readonly but email is writable
|
||||||
assertUserProfileAttributeMetadata(user, "email", "${email}", true, false);
|
assertUserProfileAttributeMetadata(user, "username", "${username}", true, true);
|
||||||
|
assertUserProfileAttributeMetadata(user, "email", "${email}", true, false);
|
||||||
|
}
|
||||||
user.setUsername("should-not-change");
|
user.setUsername("should-not-change");
|
||||||
user.setEmail("changed-email@keycloak.org");
|
user.setEmail("changed-email@keycloak.org");
|
||||||
updateError(user, 400, Messages.READ_ONLY_USERNAME);
|
updateError(user, 400, Messages.READ_ONLY_USERNAME);
|
||||||
|
@ -141,10 +145,12 @@ public class AccountRestServiceTest extends AbstractRestServiceTest {
|
||||||
realmRep.setEditUsernameAllowed(true);
|
realmRep.setEditUsernameAllowed(true);
|
||||||
realm.update(realmRep);
|
realm.update(realmRep);
|
||||||
user = getUser();
|
user = getUser();
|
||||||
assertNotNull(user.getUserProfileMetadata());
|
if (isDeclarativeUserProfile()) {
|
||||||
// username is read-only and is the same as email, but email is writable
|
assertNotNull(user.getUserProfileMetadata());
|
||||||
assertUserProfileAttributeMetadata(user, "username", "${username}", true, true);
|
// username is read-only and is the same as email, but email is writable
|
||||||
assertUserProfileAttributeMetadata(user, "email", "${email}", true, false);
|
assertUserProfileAttributeMetadata(user, "username", "${username}", true, true);
|
||||||
|
assertUserProfileAttributeMetadata(user, "email", "${email}", true, false);
|
||||||
|
}
|
||||||
user.setUsername("should-be-the-email");
|
user.setUsername("should-be-the-email");
|
||||||
user.setEmail("user@keycloak.org");
|
user.setEmail("user@keycloak.org");
|
||||||
user = updateAndGet(user);
|
user = updateAndGet(user);
|
||||||
|
@ -155,15 +161,36 @@ public class AccountRestServiceTest extends AbstractRestServiceTest {
|
||||||
realmRep.setEditUsernameAllowed(false);
|
realmRep.setEditUsernameAllowed(false);
|
||||||
realm.update(realmRep);
|
realm.update(realmRep);
|
||||||
user = getUser();
|
user = getUser();
|
||||||
assertNotNull(user.getUserProfileMetadata());
|
if (isDeclarativeUserProfile()) {
|
||||||
// username is read-only and is the same as email, but email is read-only
|
assertNotNull(user.getUserProfileMetadata());
|
||||||
assertUserProfileAttributeMetadata(user, "username", "${username}", true, true);
|
// username is read-only and is the same as email, but email is read-only
|
||||||
assertUserProfileAttributeMetadata(user, "email", "${email}", true, true);
|
assertUserProfileAttributeMetadata(user, "username", "${username}", true, true);
|
||||||
|
assertUserProfileAttributeMetadata(user, "email", "${email}", true, true);
|
||||||
|
}
|
||||||
user.setUsername("should-be-the-email");
|
user.setUsername("should-be-the-email");
|
||||||
user.setEmail("should-not-change@keycloak.org");
|
user.setEmail("should-not-change@keycloak.org");
|
||||||
user = updateAndGet(user);
|
user = updateAndGet(user);
|
||||||
assertEquals("user@keycloak.org", user.getUsername());
|
assertEquals("user@keycloak.org", user.getUsername());
|
||||||
assertEquals("user@keycloak.org", user.getEmail());
|
assertEquals("user@keycloak.org", user.getEmail());
|
||||||
|
|
||||||
|
realmRep.setRegistrationEmailAsUsername(false);
|
||||||
|
realmRep.setEditUsernameAllowed(true);
|
||||||
|
realm.update(realmRep);
|
||||||
|
user = getUser();
|
||||||
|
user.setUsername("different-than-email");
|
||||||
|
user.setEmail("user@keycloak.org");
|
||||||
|
user = updateAndGet(user);
|
||||||
|
assertEquals("different-than-email", user.getUsername());
|
||||||
|
assertEquals("user@keycloak.org", user.getEmail());
|
||||||
|
|
||||||
|
realmRep.setRegistrationEmailAsUsername(true);
|
||||||
|
realmRep.setEditUsernameAllowed(false);
|
||||||
|
realm.update(realmRep);
|
||||||
|
user = getUser();
|
||||||
|
user.setEmail("should-not-change@keycloak.org");
|
||||||
|
user = updateAndGet(user);
|
||||||
|
assertEquals("different-than-email", user.getUsername());
|
||||||
|
assertEquals("user@keycloak.org", user.getEmail());
|
||||||
} finally {
|
} finally {
|
||||||
realmRep.setRegistrationEmailAsUsername(registrationEmailAsUsername);
|
realmRep.setRegistrationEmailAsUsername(registrationEmailAsUsername);
|
||||||
realmRep.setEditUsernameAllowed(editUsernameAllowed);
|
realmRep.setEditUsernameAllowed(editUsernameAllowed);
|
||||||
|
@ -189,24 +216,28 @@ public class AccountRestServiceTest extends AbstractRestServiceTest {
|
||||||
realm.update(realmRep);
|
realm.update(realmRep);
|
||||||
|
|
||||||
UserRepresentation user = getUser();
|
UserRepresentation user = getUser();
|
||||||
assertNotNull(user.getUserProfileMetadata());
|
if (isDeclarativeUserProfile()) {
|
||||||
UserProfileAttributeMetadata upm = assertUserProfileAttributeMetadata(user, "username", "${username}", true, true);
|
assertNotNull(user.getUserProfileMetadata());
|
||||||
//makes sure internal validators are not exposed
|
UserProfileAttributeMetadata upm = assertUserProfileAttributeMetadata(user, "username", "${username}", true, true);
|
||||||
Assert.assertEquals(0, upm.getValidators().size());
|
//makes sure internal validators are not exposed
|
||||||
|
Assert.assertEquals(0, upm.getValidators().size());
|
||||||
|
|
||||||
upm = assertUserProfileAttributeMetadata(user, "email", "${email}", true, false);
|
upm = assertUserProfileAttributeMetadata(user, "email", "${email}", true, false);
|
||||||
Assert.assertEquals(1, upm.getValidators().size());
|
Assert.assertEquals(1, upm.getValidators().size());
|
||||||
Assert.assertTrue(upm.getValidators().containsKey(EmailValidator.ID));
|
Assert.assertTrue(upm.getValidators().containsKey(EmailValidator.ID));
|
||||||
|
}
|
||||||
|
|
||||||
realmRep.setRegistrationEmailAsUsername(true);
|
realmRep.setRegistrationEmailAsUsername(true);
|
||||||
realm.update(realmRep);
|
realm.update(realmRep);
|
||||||
user = getUser();
|
user = getUser();
|
||||||
upm = assertUserProfileAttributeMetadata(user, "email", "${email}", true, true);
|
if (isDeclarativeUserProfile()) {
|
||||||
Assert.assertEquals(1, upm.getValidators().size());
|
UserProfileAttributeMetadata upm = assertUserProfileAttributeMetadata(user, "email", "${email}", true, true);
|
||||||
Assert.assertTrue(upm.getValidators().containsKey(EmailValidator.ID));
|
Assert.assertEquals(1, upm.getValidators().size());
|
||||||
|
Assert.assertTrue(upm.getValidators().containsKey(EmailValidator.ID));
|
||||||
|
|
||||||
assertUserProfileAttributeMetadata(user, "firstName", "${firstName}", true, false);
|
assertUserProfileAttributeMetadata(user, "firstName", "${firstName}", true, false);
|
||||||
assertUserProfileAttributeMetadata(user, "lastName", "${lastName}", true, false);
|
assertUserProfileAttributeMetadata(user, "lastName", "${lastName}", true, false);
|
||||||
|
}
|
||||||
} finally {
|
} finally {
|
||||||
RealmRepresentation realmRep = testRealm().toRepresentation();
|
RealmRepresentation realmRep = testRealm().toRepresentation();
|
||||||
realmRep.setEditUsernameAllowed(true);
|
realmRep.setEditUsernameAllowed(true);
|
||||||
|
|
|
@ -71,7 +71,7 @@ public class UserTestWithUserProfile extends UserTest {
|
||||||
assertNotNull(metadata);
|
assertNotNull(metadata);
|
||||||
|
|
||||||
for (String name : managedAttributes) {
|
for (String name : managedAttributes) {
|
||||||
assertNotNull(getAttributeMetadata(metadata, name));
|
assertNotNull(metadata.getAttributeMetadata(name));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -83,10 +83,10 @@ public class UserTestWithUserProfile extends UserTest {
|
||||||
UserRepresentation user = realm.users().get(userId).toRepresentation(true);
|
UserRepresentation user = realm.users().get(userId).toRepresentation(true);
|
||||||
UserProfileMetadata metadata = user.getUserProfileMetadata();
|
UserProfileMetadata metadata = user.getUserProfileMetadata();
|
||||||
assertNotNull(metadata);
|
assertNotNull(metadata);
|
||||||
UserProfileAttributeMetadata username = getAttributeMetadata(metadata, UserModel.USERNAME);
|
UserProfileAttributeMetadata username = metadata.getAttributeMetadata(UserModel.USERNAME);
|
||||||
assertNotNull(username);
|
assertNotNull(username);
|
||||||
assertTrue(username.isReadOnly());
|
assertTrue(username.isReadOnly());
|
||||||
UserProfileAttributeMetadata email = getAttributeMetadata(metadata, UserModel.EMAIL);
|
UserProfileAttributeMetadata email = metadata.getAttributeMetadata(UserModel.EMAIL);
|
||||||
assertNotNull(email);
|
assertNotNull(email);
|
||||||
assertFalse(email.isReadOnly());
|
assertFalse(email.isReadOnly());
|
||||||
}
|
}
|
||||||
|
@ -101,26 +101,14 @@ public class UserTestWithUserProfile extends UserTest {
|
||||||
UserRepresentation user = realm.users().get(userId).toRepresentation(true);
|
UserRepresentation user = realm.users().get(userId).toRepresentation(true);
|
||||||
UserProfileMetadata metadata = user.getUserProfileMetadata();
|
UserProfileMetadata metadata = user.getUserProfileMetadata();
|
||||||
assertNotNull(metadata);
|
assertNotNull(metadata);
|
||||||
UserProfileAttributeMetadata username = getAttributeMetadata(metadata, UserModel.USERNAME);
|
UserProfileAttributeMetadata username = metadata.getAttributeMetadata(UserModel.USERNAME);
|
||||||
assertNotNull(username);
|
assertNotNull(username);
|
||||||
assertTrue(username.isReadOnly());
|
assertTrue(username.isReadOnly());
|
||||||
UserProfileAttributeMetadata email = getAttributeMetadata(metadata, UserModel.EMAIL);
|
UserProfileAttributeMetadata email = metadata.getAttributeMetadata(UserModel.EMAIL);
|
||||||
assertNotNull(email);
|
assertNotNull(email);
|
||||||
assertFalse(email.isReadOnly());
|
assertFalse(email.isReadOnly());
|
||||||
}
|
}
|
||||||
|
|
||||||
@Nullable
|
|
||||||
private static UserProfileAttributeMetadata getAttributeMetadata(UserProfileMetadata metadata, String name) {
|
|
||||||
UserProfileAttributeMetadata attrMetadata = null;
|
|
||||||
|
|
||||||
for (UserProfileAttributeMetadata m : metadata.getAttributes()) {
|
|
||||||
if (name.equals(m.getName())) {
|
|
||||||
attrMetadata = m;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return attrMetadata;
|
|
||||||
}
|
|
||||||
|
|
||||||
private UPAttribute createAttributeMetadata(String name) {
|
private UPAttribute createAttributeMetadata(String name) {
|
||||||
UPAttribute attribute = new UPAttribute();
|
UPAttribute attribute = new UPAttribute();
|
||||||
attribute.setName(name);
|
attribute.setName(name);
|
||||||
|
|
|
@ -20,18 +20,31 @@
|
||||||
package org.keycloak.testsuite.admin.userprofile;
|
package org.keycloak.testsuite.admin.userprofile;
|
||||||
|
|
||||||
import static org.junit.Assert.assertEquals;
|
import static org.junit.Assert.assertEquals;
|
||||||
|
import static org.junit.Assert.assertFalse;
|
||||||
|
import static org.junit.Assert.assertNotNull;
|
||||||
|
import static org.junit.Assert.assertTrue;
|
||||||
import static org.keycloak.userprofile.DeclarativeUserProfileProvider.REALM_USER_PROFILE_ENABLED;
|
import static org.keycloak.userprofile.DeclarativeUserProfileProvider.REALM_USER_PROFILE_ENABLED;
|
||||||
import static org.keycloak.userprofile.config.UPConfigUtils.readDefaultConfig;
|
import static org.keycloak.userprofile.config.UPConfigUtils.readDefaultConfig;
|
||||||
|
|
||||||
import java.io.IOException;
|
import java.io.IOException;
|
||||||
import java.util.HashMap;
|
import java.util.HashMap;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
import org.junit.Test;
|
import org.junit.Test;
|
||||||
|
import org.keycloak.admin.client.resource.RealmResource;
|
||||||
import org.keycloak.admin.client.resource.UserProfileResource;
|
import org.keycloak.admin.client.resource.UserProfileResource;
|
||||||
import org.keycloak.common.Profile;
|
import org.keycloak.common.Profile;
|
||||||
|
import org.keycloak.models.UserModel;
|
||||||
import org.keycloak.representations.idm.RealmRepresentation;
|
import org.keycloak.representations.idm.RealmRepresentation;
|
||||||
|
import org.keycloak.representations.idm.UserProfileAttributeGroupMetadata;
|
||||||
|
import org.keycloak.representations.idm.UserProfileMetadata;
|
||||||
import org.keycloak.testsuite.admin.AbstractAdminTest;
|
import org.keycloak.testsuite.admin.AbstractAdminTest;
|
||||||
import org.keycloak.testsuite.arquillian.annotation.EnableFeature;
|
import org.keycloak.testsuite.arquillian.annotation.EnableFeature;
|
||||||
|
import org.keycloak.userprofile.config.UPAttribute;
|
||||||
|
import org.keycloak.userprofile.config.UPConfig;
|
||||||
|
import org.keycloak.userprofile.config.UPGroup;
|
||||||
|
import org.keycloak.util.JsonSerialization;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @author <a href="mailto:psilva@redhat.com">Pedro Igor</a>
|
* @author <a href="mailto:psilva@redhat.com">Pedro Igor</a>
|
||||||
|
@ -53,12 +66,111 @@ public class UserProfileAdminTest extends AbstractAdminTest {
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
public void testSetDefaultConfig() throws IOException {
|
public void testSetDefaultConfig() {
|
||||||
String rawConfig = "{\"attributes\": [{\"name\": \"test\"}]}";
|
String rawConfig = "{\"attributes\": [{\"name\": \"test\"}]}";
|
||||||
UserProfileResource userProfile = testRealm().users().userProfile();
|
UserProfileResource userProfile = testRealm().users().userProfile();
|
||||||
|
|
||||||
userProfile.update(rawConfig);
|
userProfile.update(rawConfig);
|
||||||
|
getCleanup().addCleanup(() -> testRealm().users().userProfile().update(null));
|
||||||
|
|
||||||
assertEquals(rawConfig, userProfile.getConfiguration());
|
assertEquals(rawConfig, userProfile.getConfiguration());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
public void testEmailRequiredIfEmailAsUsernameEnabled() {
|
||||||
|
RealmResource realm = testRealm();
|
||||||
|
RealmRepresentation realmRep = realm.toRepresentation();
|
||||||
|
Boolean registrationEmailAsUsername = realmRep.isRegistrationEmailAsUsername();
|
||||||
|
realmRep.setRegistrationEmailAsUsername(true);
|
||||||
|
realm.update(realmRep);
|
||||||
|
getCleanup().addCleanup(() -> {
|
||||||
|
realmRep.setRegistrationEmailAsUsername(registrationEmailAsUsername);
|
||||||
|
realm.update(realmRep);
|
||||||
|
});
|
||||||
|
UserProfileResource userProfile = realm.users().userProfile();
|
||||||
|
UserProfileMetadata metadata = userProfile.getMetadata();
|
||||||
|
assertTrue(metadata.getAttributeMetadata(UserModel.EMAIL).isRequired());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
public void testEmailNotRequiredIfEmailAsUsernameDisabled() {
|
||||||
|
RealmResource realm = testRealm();
|
||||||
|
RealmRepresentation realmRep = realm.toRepresentation();
|
||||||
|
Boolean registrationEmailAsUsername = realmRep.isRegistrationEmailAsUsername();
|
||||||
|
realmRep.setRegistrationEmailAsUsername(false);
|
||||||
|
realm.update(realmRep);
|
||||||
|
getCleanup().addCleanup(() -> {
|
||||||
|
realmRep.setRegistrationEmailAsUsername(registrationEmailAsUsername);
|
||||||
|
realm.update(realmRep);
|
||||||
|
});
|
||||||
|
UserProfileResource userProfile = realm.users().userProfile();
|
||||||
|
UserProfileMetadata metadata = userProfile.getMetadata();
|
||||||
|
assertFalse(metadata.getAttributeMetadata(UserModel.EMAIL).isRequired());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
public void testUsernameRequiredIfEmailAsUsernameDisabled() {
|
||||||
|
RealmResource realm = testRealm();
|
||||||
|
RealmRepresentation realmRep = realm.toRepresentation();
|
||||||
|
Boolean registrationEmailAsUsername = realmRep.isRegistrationEmailAsUsername();
|
||||||
|
realmRep.setRegistrationEmailAsUsername(false);
|
||||||
|
realm.update(realmRep);
|
||||||
|
getCleanup().addCleanup(() -> {
|
||||||
|
realmRep.setRegistrationEmailAsUsername(registrationEmailAsUsername);
|
||||||
|
realm.update(realmRep);
|
||||||
|
});
|
||||||
|
UserProfileResource userProfile = realm.users().userProfile();
|
||||||
|
UserProfileMetadata metadata = userProfile.getMetadata();
|
||||||
|
assertTrue(metadata.getAttributeMetadata(UserModel.USERNAME).isRequired());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
public void testUsernameNotRequiredIfEmailAsUsernameEnabled() {
|
||||||
|
RealmResource realm = testRealm();
|
||||||
|
RealmRepresentation realmRep = realm.toRepresentation();
|
||||||
|
Boolean registrationEmailAsUsername = realmRep.isRegistrationEmailAsUsername();
|
||||||
|
realmRep.setRegistrationEmailAsUsername(true);
|
||||||
|
realm.update(realmRep);
|
||||||
|
getCleanup().addCleanup(() -> {
|
||||||
|
realmRep.setRegistrationEmailAsUsername(registrationEmailAsUsername);
|
||||||
|
realm.update(realmRep);
|
||||||
|
});
|
||||||
|
UserProfileResource userProfile = realm.users().userProfile();
|
||||||
|
UserProfileMetadata metadata = userProfile.getMetadata();
|
||||||
|
assertFalse(metadata.getAttributeMetadata(UserModel.USERNAME).isRequired());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
public void testGroupsMetadata() throws IOException {
|
||||||
|
UPConfig config = JsonSerialization.readValue(testRealm().users().userProfile().getConfiguration(), UPConfig.class);
|
||||||
|
|
||||||
|
for (int i = 0; i < 3; i++) {
|
||||||
|
UPGroup group = new UPGroup();
|
||||||
|
group.setName("name-" + i);
|
||||||
|
group.setDisplayHeader("displayHeader-" + i);
|
||||||
|
group.setDisplayDescription("displayDescription-" + i);
|
||||||
|
group.setAnnotations(Map.of("k1", "v1", "k2", "v2", "k3", "v3"));
|
||||||
|
config.addGroup(group);
|
||||||
|
}
|
||||||
|
|
||||||
|
UPAttribute firstName = config.getAttribute(UserModel.FIRST_NAME);
|
||||||
|
firstName.setGroup(config.getGroups().get(0).getName());
|
||||||
|
UserProfileResource userProfile = testRealm().users().userProfile();
|
||||||
|
userProfile.update(JsonSerialization.writeValueAsString(config));
|
||||||
|
getCleanup().addCleanup(() -> testRealm().users().userProfile().update(null));
|
||||||
|
|
||||||
|
UserProfileMetadata metadata = testRealm().users().userProfile().getMetadata();
|
||||||
|
List<UserProfileAttributeGroupMetadata> groups = metadata.getGroups();
|
||||||
|
assertNotNull(groups);
|
||||||
|
assertFalse(groups.isEmpty());
|
||||||
|
assertEquals(config.getGroups().size(), groups.size());
|
||||||
|
for (UPGroup group : config.getGroups()) {
|
||||||
|
UserProfileAttributeGroupMetadata mGroup = metadata.getAttributeGroupMetadata(group.getName());
|
||||||
|
assertNotNull(mGroup);
|
||||||
|
assertEquals(group.getName(), mGroup.getName());
|
||||||
|
assertEquals(group.getDisplayHeader(), mGroup.getDisplayHeader());
|
||||||
|
assertEquals(group.getDisplayDescription(), mGroup.getDisplayDescription());
|
||||||
|
assertEquals(group.getAnnotations().size(), mGroup.getAnnotations().size());
|
||||||
|
}
|
||||||
|
assertEquals(config.getGroups().get(0).getName(), metadata.getAttributeMetadata(UserModel.FIRST_NAME).getGroup());
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -1314,6 +1314,76 @@ public class UserProfileTest extends AbstractUserProfileTest {
|
||||||
profile.validate();
|
profile.validate();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
public void testIgnoreReadOnlyAttribute() {
|
||||||
|
getTestingClient().server(TEST_REALM_NAME).run((RunOnServer) UserProfileTest::testIgnoreReadOnlyAttribute);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void testIgnoreReadOnlyAttribute(KeycloakSession session) throws IOException {
|
||||||
|
DeclarativeUserProfileProvider provider = getDynamicUserProfileProvider(session);
|
||||||
|
UPConfig config = new UPConfig();
|
||||||
|
UPAttribute firstName = new UPAttribute();
|
||||||
|
|
||||||
|
firstName.setName(UserModel.FIRST_NAME);
|
||||||
|
|
||||||
|
UPAttribute address = new UPAttribute();
|
||||||
|
|
||||||
|
address.setName(ATT_ADDRESS);
|
||||||
|
|
||||||
|
UPAttributeRequired requirements = new UPAttributeRequired();
|
||||||
|
requirements.setRoles(Collections.singleton(UPConfigUtils.ROLE_USER));
|
||||||
|
address.setRequired(requirements);
|
||||||
|
firstName.setRequired(requirements);
|
||||||
|
|
||||||
|
UPAttributePermissions permissions = new UPAttributePermissions();
|
||||||
|
permissions.setEdit(Collections.singleton(UPConfigUtils.ROLE_USER));
|
||||||
|
permissions.setView(Collections.singleton(ROLE_ADMIN));
|
||||||
|
address.setPermissions(permissions);
|
||||||
|
firstName.setPermissions(permissions);
|
||||||
|
|
||||||
|
config.addAttribute(address);
|
||||||
|
config.addAttribute(firstName);
|
||||||
|
|
||||||
|
provider.setConfiguration(JsonSerialization.writeValueAsString(config));
|
||||||
|
|
||||||
|
Map<String, Object> attributes = new HashMap<>();
|
||||||
|
|
||||||
|
attributes.put(UserModel.USERNAME, org.keycloak.models.utils.KeycloakModelUtils.generateId());
|
||||||
|
|
||||||
|
// Fails on USER context
|
||||||
|
UserProfile profile = provider.create(UserProfileContext.UPDATE_PROFILE, attributes);
|
||||||
|
try {
|
||||||
|
profile.validate();
|
||||||
|
fail("Should fail validation");
|
||||||
|
} catch (ValidationException ve) {
|
||||||
|
assertTrue(ve.isAttributeOnError(ATT_ADDRESS));
|
||||||
|
}
|
||||||
|
|
||||||
|
// attribute ignored for admin when not provided and creating user
|
||||||
|
profile = provider.create(UserProfileContext.USER_API, attributes);
|
||||||
|
profile.validate();
|
||||||
|
|
||||||
|
// attribute ignored for admin when empty and creating user
|
||||||
|
attributes.put(ATT_ADDRESS, List.of(""));
|
||||||
|
attributes.put(UserModel.FIRST_NAME, List.of(""));
|
||||||
|
profile = provider.create(UserProfileContext.USER_API, attributes);
|
||||||
|
UserModel user = profile.create();
|
||||||
|
|
||||||
|
// attribute ignored for admin when empty and updating user
|
||||||
|
profile = provider.create(UserProfileContext.USER_API, attributes, user);
|
||||||
|
profile.validate();
|
||||||
|
|
||||||
|
// attribute not ignored for admin when empty and updating user
|
||||||
|
user.setFirstName("alice");
|
||||||
|
profile = provider.create(UserProfileContext.USER_API, attributes, user);
|
||||||
|
try {
|
||||||
|
profile.validate();
|
||||||
|
fail("Should fail validation");
|
||||||
|
} catch (ValidationException ve) {
|
||||||
|
assertTrue(ve.isAttributeOnError(UserModel.FIRST_NAME));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
public void testReadOnlyInternalAttributeValidation() {
|
public void testReadOnlyInternalAttributeValidation() {
|
||||||
getTestingClient().server(TEST_REALM_NAME).run((RunOnServer) UserProfileTest::testReadOnlyInternalAttributeValidation);
|
getTestingClient().server(TEST_REALM_NAME).run((RunOnServer) UserProfileTest::testReadOnlyInternalAttributeValidation);
|
||||||
|
|
Loading…
Reference in a new issue