AbstractUserProfileBean.java

package org.keycloak.forms.login.freemarker.model;

import java.util.Collections;
import java.util.Comparator;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.Map.Entry;
import java.util.Objects;
import java.util.Optional;
import java.util.Set;
import java.util.stream.Collectors;
import java.util.stream.Stream;

import jakarta.ws.rs.core.MultivaluedMap;

import org.keycloak.models.KeycloakSession;
import org.keycloak.userprofile.AttributeGroupMetadata;
import org.keycloak.userprofile.AttributeMetadata;
import org.keycloak.userprofile.AttributeValidatorMetadata;
import org.keycloak.userprofile.Attributes;
import org.keycloak.userprofile.UserProfile;
import org.keycloak.userprofile.UserProfileProvider;

/**
 * Abstract base for Freemarker context bean providing information about user profile to render dynamic or crafted forms.  
 * 
 * @author Vlastimil Elias <velias@redhat.com>
 */
public abstract class AbstractUserProfileBean {


    private static final Comparator<Attribute> ATTRIBUTE_COMPARATOR = (a1, a2) -> {
        AttributeGroup g1 = a1.getGroup();
        AttributeGroup g2 = a2.getGroup();

        if (g1 == null && g2 == null) {
            return a1.compareTo(a2);
        }

        if (g1 != null && g1.equals(g2)) {
            return a1.compareTo(a2);
        }

        return Comparator.nullsFirst(AttributeGroup::compareTo).compare(g1, g2);
    };

    protected final MultivaluedMap<String, String> formData;
    protected UserProfile profile;
    protected List<Attribute> attributes;
    protected Map<String, Attribute> attributesByName;

    public AbstractUserProfileBean(MultivaluedMap<String, String> formData) {
        this.formData = formData;
    }
    
    /**
     * Subclass have to call this method at the end of constructor to init user profile data. 
     * 
     * @param session
     * @param writeableOnly if true then only writeable (no read-only) attributes are put into template, if false then all readable attributes are there 
     */
    protected void init(KeycloakSession session, boolean writeableOnly) {
        UserProfileProvider provider = session.getProvider(UserProfileProvider.class);
        this.profile = createUserProfile(provider);
        this.attributes = toAttributes(profile.getAttributes().getReadable(), writeableOnly);
        if(this.attributes != null)
            this.attributesByName = attributes.stream().collect(Collectors.toMap((a) -> a.getName(), (a) -> a));        
    }

    /**
     * Create UserProfile instance of the relevant type. Is called from {@link #init(KeycloakSession, boolean)}.
     * 
     * @param provider to create UserProfile from
     * @return user profile instance
     */
    protected abstract UserProfile createUserProfile(UserProfileProvider provider);

    /**
     * Get attribute default values to be pre-filled into the form on first show.
     * 
     * @param name of the attribute
     * @return attribute default value (can be null)
     */
    protected abstract Stream<String> getAttributeDefaultValues(String name);

    /**
     * Get context the template is used for, so view can be customized for distinct contexts. 
     * 
     * @return name of the context
     */
    public abstract String getContext();
    
    /**
     * All attributes to be shown in form sorted by the configured GUI order. Useful to render dynamic form.
     * 
     * @return list of attributes
     */
    public List<Attribute> getAttributes() {
        return attributes;
    }

    public Map<String, Object> getHtml5DataAnnotations() {
        return getAttributes().stream().map(Attribute::getHtml5DataAnnotations)
                .map(Map::entrySet)
                .flatMap(Set::stream)
                .collect(Collectors.toMap(Map.Entry::getKey, Map.Entry::getValue, (l, r) -> l));
    }
    
    /**
     * Get map of all attributes where attribute name is key. Useful to render crafted form.
     * 
     * @return map of attributes by name
     */
    public Map<String, Attribute> getAttributesByName() {
        return attributesByName;
    }

    private List<Attribute> toAttributes(Map<String, List<String>> attributes, boolean writeableOnly) {
        if(attributes == null)
            return null;
        Attributes profileAttributes = profile.getAttributes();
        return attributes.keySet().stream().map(profileAttributes::getMetadata)
                .filter(Objects::nonNull)
                .filter((am) -> writeableOnly ? !profileAttributes.isReadOnly(am.getName()) : true)
                .filter((am) -> !profileAttributes.getUnmanagedAttributes().containsKey(am.getName()))
                .map(Attribute::new)
                .sorted(ATTRIBUTE_COMPARATOR)
                .collect(Collectors.toList());
    }

    /**
     * Info about user profile attribute available in Freemarker template. 
     */
    public class Attribute implements Comparable<Attribute> {

        private final AttributeMetadata metadata;

        public Attribute(AttributeMetadata metadata) {
            this.metadata = metadata;
        }

        public String getName() {
            return metadata.getName();
        }

        public String getDisplayName() {
            return metadata.getAttributeDisplayName();
        }

        public boolean isMultivalued() {
            return metadata.isMultivalued();
        }

        public String getValue() {
            List<String> v = getValues();
            if (v == null || v.isEmpty()) {
                return null;
            } else {
                return v.get(0);
            }
        }
        
        public List<String> getValues() {
            List<String> v = formData != null ? formData.get(getName()) : null;
            if (v == null || v.isEmpty()) {
                Stream<String> vs = getAttributeDefaultValues(getName());
                if(vs == null)
                    return Collections.emptyList();
                else
                    return vs.collect(Collectors.toList());
            } else {
                return v;
            }
        }

        public boolean isRequired() {
            return profile.getAttributes().isRequired(getName());
        }

        public boolean isReadOnly() {
            return profile.getAttributes().isReadOnly(getName());
        }
        
        /** define value of the autocomplete attribute for html input tag. if null then no html input tag attribute is added */
        public String getAutocomplete() {
            if(getName().equals("email") || getName().equals("username"))
                return getName();
            else
                return null;    
            
        }

        public Map<String, Object> getAnnotations() {
            Map<String, Object> annotations = metadata.getAnnotations();

            if (annotations == null) {
                return Collections.emptyMap();
            }

            return annotations;
        }

        public Map<String, Object> getHtml5DataAnnotations() {
            Map<String, Object> groupAnnotations = Optional.ofNullable(getGroup()).map(AttributeGroup::getAnnotations).orElse(Map.of());
            Map<String, Object> annotations = Stream.concat(getAnnotations().entrySet().stream(), groupAnnotations.entrySet().stream())
                    .filter((entry) -> entry.getKey().startsWith("kc")).collect(Collectors.toMap(Entry::getKey, Entry::getValue));

            if (isMultivalued()) {
                annotations = new HashMap<>(annotations);
                annotations.put("kcMultivalued", "");
            }

            return annotations;
        }
      
        /**
         * Get info about validators applied to attribute.  
         * 
         * @return never null, map where key is validatorId and value is map with configuration for given validator (loaded from UserProfile configuration, never null)
         */
        public Map<String, Map<String, Object>> getValidators(){
            
            if(metadata.getValidators() == null) {
                return Collections.emptyMap();
            }
            return metadata.getValidators().stream().collect(Collectors.toMap(AttributeValidatorMetadata::getValidatorId, AttributeValidatorMetadata::getValidatorConfig));
        }

        public AttributeGroup getGroup() {
            AttributeGroupMetadata groupMetadata = metadata.getAttributeGroupMetadata();

            if (groupMetadata != null) {
                return new AttributeGroup(groupMetadata);
            }

            return null;
        }

        @Override
        public int compareTo(Attribute o) {
            return Integer.compare(metadata.getGuiOrder(), o.metadata.getGuiOrder());
        }
    }

    public class AttributeGroup implements Comparable<AttributeGroup> {

        private AttributeGroupMetadata metadata;

        AttributeGroup(AttributeGroupMetadata metadata) {
            this.metadata = metadata;
        }

        public String getName() {
            return metadata.getName();
        }

        public String getDisplayHeader() {
            return Optional.ofNullable(metadata.getDisplayHeader()).orElse(getName());
        }

        public String getDisplayDescription() {
            return metadata.getDisplayDescription();
        }

        public Map<String, Object> getAnnotations() {
            return Optional.ofNullable(metadata.getAnnotations()).orElse(Map.of());
        }

        public Map<String, Object> getHtml5DataAnnotations() {
            return getAnnotations().entrySet().stream()
                    .filter((entry) -> entry.getKey().startsWith("kc")).collect(Collectors.toMap(Entry::getKey, Entry::getValue));
        }

        @Override
        public boolean equals(Object o) {
            if (this == o) return true;
            if (o == null || getClass() != o.getClass()) return false;
            AttributeGroup that = (AttributeGroup) o;
            return Objects.equals(metadata.getName(), that.metadata.getName());
        }

        @Override
        public int hashCode() {
            return Objects.hash(metadata);
        }

        @Override
        public String toString() {
            return metadata.getName();
        }

        @Override
        public int compareTo(AttributeGroup o) {
            return getDisplayHeader().compareTo(o.getDisplayHeader());
        }
    }
}