package fr.titionfire.ffsaf.domain.service; import fr.titionfire.ffsaf.data.model.ClubModel; import fr.titionfire.ffsaf.data.model.MembreModel; import fr.titionfire.ffsaf.rest.exception.DInternalError; import fr.titionfire.ffsaf.utils.*; import io.quarkus.mailer.Mail; import io.quarkus.mailer.reactive.ReactiveMailer; import io.quarkus.runtime.annotations.RegisterForReflection; import io.smallrye.mutiny.Uni; import io.smallrye.mutiny.unchecked.Unchecked; import io.vertx.mutiny.core.Vertx; import jakarta.enterprise.context.ApplicationScoped; import jakarta.inject.Inject; import jakarta.ws.rs.core.Response; import org.eclipse.microprofile.config.inject.ConfigProperty; import org.jboss.logging.Logger; import org.keycloak.admin.client.Keycloak; import org.keycloak.admin.client.resource.RoleScopeResource; import org.keycloak.admin.client.resource.UserResource; import org.keycloak.representations.idm.GroupRepresentation; import org.keycloak.representations.idm.RoleRepresentation; import org.keycloak.representations.idm.UserRepresentation; import java.text.Normalizer; import java.util.ArrayList; import java.util.List; import java.util.Optional; import java.util.UUID; @ApplicationScoped public class KeycloakService { private static final Logger LOGGER = Logger.getLogger(KeycloakService.class); @Inject Keycloak keycloak; @Inject ClubService clubService; @Inject MembreService membreService; @ConfigProperty(name = "keycloak.realm") String realm; @ConfigProperty(name = "email.enabled") boolean enabled_email; @Inject ReactiveMailer reactiveMailer; @Inject Vertx vertx; public Uni getGroupFromClub(ClubModel club) { if (club.getClubId() == null) { LOGGER.infof("Creation of club group %d-%s...", club.getId(), club.getName()); return vertx.getOrCreateContext().executeBlocking(() -> { GroupRepresentation clubGroup = keycloak.realm(realm).groups().groups().stream().filter(g -> g.getName().equals("club")) .findAny() .orElseThrow(() -> new KeycloakException("Fail to fetch group %s".formatted("club"))); GroupRepresentation groupRepresentation = new GroupRepresentation(); groupRepresentation.setName(club.getId() + "-" + club.getName()); try (Response response = keycloak.realm(realm).groups().group(clubGroup.getId()).subGroup(groupRepresentation)) { if (!response.getStatusInfo().equals(Response.Status.CREATED) && !response.getStatusInfo() .equals(Response.Status.CONFLICT)) throw new KeycloakException( "Fail to set group parent for club: %s (reason=%s)".formatted(club.getName(), response.getStatusInfo().getReasonPhrase())); } return keycloak.realm(realm).groups().group(clubGroup.getId()).getSubGroups(0, 1000, true).stream() .filter(g -> g.getName().startsWith(club.getId() + "-")).findAny() .map(GroupRepresentation::getId) .orElseThrow( () -> new KeycloakException("Fail to fetch group %s*".formatted(club.getId() + "-"))); } ).call(id -> clubService.setClubId(club.getId(), id)); } return Uni.createFrom().item(club::getClubId); } public Uni getUserFromMember(MembreModel membreModel) { if (membreModel.getUserId() == null) { return Uni.createFrom() .failure(new DInternalError("No keycloak user linked to the user id=" + membreModel.getId())); } return Uni.createFrom().item(membreModel::getUserId); } public Uni setClubGroupMembre(MembreModel membreModel, ClubModel club) { if (club == null) return getUserFromMember(membreModel).chain( userId -> vertx.getOrCreateContext().executeBlocking(() -> { UserResource user = keycloak.realm(realm).users().get(userId); user.groups().stream().filter(g -> g.getPath().startsWith("/club")) .forEach(g -> user.leaveGroup(g.getId())); return "OK"; })); else return getGroupFromClub(club).chain( clubId -> getUserFromMember(membreModel).chain( userId -> vertx.getOrCreateContext().executeBlocking(() -> { UserResource user = keycloak.realm(realm).users().get(userId); user.groups().stream().filter(g -> g.getPath().startsWith("/club")) .forEach(g -> user.leaveGroup(g.getId())); user.joinGroup(clubId); LOGGER.infof("Set club \"%s\" to user %s (%s)", club.getName(), userId, user.toRepresentation().getUsername()); return "OK"; }))); } public Uni setEmail(String userId, String email) { return vertx.getOrCreateContext().executeBlocking(() -> { UserResource user = keycloak.realm(realm).users().get(userId); UserRepresentation user2 = user.toRepresentation(); String oldEmail = user2.getEmail(); if (email.equals(user2.getEmail())) return null; user2.setEmail(email); user2.setRequiredActions(List.of(RequiredAction.VERIFY_EMAIL.name())); user.update(user2); if (enabled_email) user.sendVerifyEmail(); return oldEmail; }).call(oldEmail -> oldEmail == null || !enabled_email ? Uni.createFrom().item("") : reactiveMailer.send( Mail.withText(oldEmail, "FFSAF - Changement de votre adresse email", String.format( """ Bonjour, Suite à la modification de votre adresse email fournie lors de votre (ré)inscription à la FFSAF, vous allez recevoir dans les prochaines minutes un email de vérification de votre nouvelle adresse sur celle-ci. Ancienne adresse email : %s Nouvelle adresse email : %s Si vous n'avez pas demandé cette modification, veuillez contacter le support à l'adresse support@ffsaf.fr. Cordialement, L'équipe de la FFSAF """, oldEmail, email) ).setFrom("FFSAF ").setReplyTo("support@ffsaf.fr") ).onFailure().invoke(e -> LOGGER.error("Fail to send email", e))); } public Uni setAutoRoleMembre(String id, RoleAsso role, GradeArbitrage gradeArbitrage) { List toRemove = new ArrayList<>(List.of("club_president", "club_tresorier", "club_secretaire", "club_respo_intra", "asseseur", "arbitre")); List toAdd = new ArrayList<>(); switch (role) { case PRESIDENT, VPRESIDENT -> toAdd.add("club_president"); case TRESORIER, VTRESORIER -> toAdd.add("club_tresorier"); case SECRETAIRE, VSECRETAIRE -> toAdd.add("club_secretaire"); case MEMBREBUREAU -> toAdd.add("club_respo_intra"); } switch (gradeArbitrage) { case ARBITRE -> toAdd.addAll(List.of("asseseur", "arbitre")); case ASSESSEUR -> toAdd.add("asseseur"); } toRemove.removeAll(toAdd); return updateRole(id, toAdd, toRemove); } public Uni> fetchCompte(String id) { return vertx.getOrCreateContext().executeBlocking(() -> { UserResource user = keycloak.realm(realm).users().get(id); UserRepresentation user2 = user.toRepresentation(); return new Pair<>(user, new UserCompteState(user2.isEnabled(), user2.getUsername(), user2.isEmailVerified())); }); } public Uni> fetchRole(String id) { return vertx.getOrCreateContext().executeBlocking(() -> keycloak.realm(realm).users().get(id).roles().realmLevel().listEffective().stream() .map(RoleRepresentation::getName).toList()); } public Uni updateRole(String id, List toAdd, List toRemove) { return vertx.getOrCreateContext().executeBlocking(() -> { RoleScopeResource resource = keycloak.realm(realm).users().get(id).roles().realmLevel(); List roles = keycloak.realm(realm).roles().list(); resource.add(roles.stream().filter(r -> toAdd.contains(r.getName())).toList()); resource.remove(roles.stream().filter(r -> toRemove.contains(r.getName())).toList()); return "OK"; }); } public Uni initCompte(long id) { return membreService.getById(id).invoke(Unchecked.consumer(membreModel -> { if (membreModel.getUserId() != null) throw new KeycloakException("User already linked to the user id=" + id); if (membreModel.getEmail() == null) throw new KeycloakException("User email is null"); if (membreModel.getFname() == null || membreModel.getLname() == null) throw new KeycloakException("User name is null"); })).chain(membreModel -> creatUser(membreModel).chain(user -> { LOGGER.infof("Set user id %s to membre %s", user.getId(), membreModel.getId()); return membreService.setUserId(membreModel.getId(), user.getId()).map(__ -> user.getId()); })); } private Uni creatUser(MembreModel membreModel) { return vertx.getOrCreateContext().executeBlocking(() -> { String login; int i = 1; do { login = makeLogin(membreModel); if (i > 1) { login += i; } i++; } while (!keycloak.realm(realm).users().searchByUsername(login, true).isEmpty()); LOGGER.infof("Creation of user %s...", login); UserRepresentation user = new UserRepresentation(); user.setUsername(login); user.setFirstName(membreModel.getFname()); user.setLastName(membreModel.getLname()); user.setEmail(membreModel.getEmail()); user.setEnabled(true); try (Response response = keycloak.realm(realm).users().create(user)) { if (!response.getStatusInfo().equals(Response.Status.CREATED) && !response.getStatusInfo() .equals(Response.Status.CONFLICT)) throw new KeycloakException("Fail to creat user %s (reason=%s)".formatted(login, response.getStatusInfo().getReasonPhrase())); } String finalLogin = login; return getUser(login).orElseThrow( () -> new KeycloakException("Fail to fetch user %s".formatted(finalLogin))); }) .invoke(user -> membreModel.setUserId(user.getId())) .call(user -> updateRole(user.getId(), List.of("safca_user"), List.of())) .call(user -> enabled_email ? reactiveMailer.send( Mail.withText(user.getEmail(), "FFSAF - Creation de votre compte sur l'intranet", String.format( """ Bonjour, Suite à votre première inscription %sà la Fédération Française de Soft Armored Fighting (FFSAF), votre compte intranet a été créé. Ce compte vous permettra de consulter vos informations et, dans un futur proche, de vous inscrire aux compétitions ainsi que d'en consulter les résultats. L'intranet est accessible à l'adresse suivante : https://intra.ffsaf.fr Votre nom d'utilisateur est : %s Pour définir votre mot de passe, rendez-vous sur l'intranet > "Connexion" > "Mot de passe oublié ?" Si vous n'avez pas demandé cette inscription, veuillez contacter le support à l'adresse support@ffsaf.fr. (Pas de panique, nous ne vous enverrons pas de message autre que ce concernant votre compte) Cordialement, L'équipe de la FFSAF """, membreModel.getRole() == RoleAsso.MEMBRE ? "par votre club (" + membreModel.getClub() .getName() + ") " : "", user.getUsername()) ).setFrom("FFSAF ").setReplyTo("support@ffsaf.fr") ) : Uni.createFrom().nullItem()) .call(user -> membreService.setUserId(membreModel.getId(), user.getId())) .call(user -> setClubGroupMembre(membreModel, membreModel.getClub())); } public Uni setId(long id, String nid) { return membreService.setUserId(id, nid).map(__ -> "OK"); } public Uni removeAccount(String userId) { return vertx.getOrCreateContext().executeBlocking(() -> { try (Response response = keycloak.realm(realm).users().delete(userId)) { System.out.println(response.getStatusInfo()); if (!response.getStatusInfo().equals(Response.Status.NO_CONTENT)) throw new KeycloakException("Fail to delete user %s (reason=%s)".formatted(userId, response.getStatusInfo().getReasonPhrase())); } return null; }); } public Uni removeClubGroup(String clubId) { return vertx.getOrCreateContext().executeBlocking(() -> { keycloak.realm(realm).groups().group(clubId).remove(); return null; }); } public Uni clearUser(String userId) { List toRemove = new ArrayList<>( List.of("club_president", "club_tresorier", "club_secretaire", "club_respo_intra")); return vertx.getOrCreateContext().executeBlocking(() -> { UserResource user = keycloak.realm(realm).users().get(userId); RoleScopeResource resource = user.roles().realmLevel(); List roles = keycloak.realm(realm).roles().list(); resource.remove(roles.stream().filter(r -> toRemove.contains(r.getName())).toList()); user.groups().stream().filter(g -> g.getPath().startsWith("/club")) .forEach(g -> user.leaveGroup(g.getId())); return "OK"; }); } public Optional getUser(String username) { List users = keycloak.realm(realm).users().searchByUsername(username, true); if (users.isEmpty()) return Optional.empty(); else return Optional.of(users.get(0)); } public Optional getUser(UUID userId) { UserResource user = keycloak.realm(realm).users().get(userId.toString()); if (user == null) return Optional.empty(); else return Optional.of(user.toRepresentation()); } private String makeLogin(MembreModel model) { return Normalizer.normalize( (model.getFname().toLowerCase() + "." + model.getLname().toLowerCase()).replace(' ', '_'), Normalizer.Form.NFD) .replaceAll("\\p{M}", ""); } @RegisterForReflection public record UserCompteState(Boolean enabled, String login, Boolean emailVerified) { } }