diff --git a/pom.xml b/pom.xml index bb6cffd..74191a9 100644 --- a/pom.xml +++ b/pom.xml @@ -72,6 +72,7 @@ test + io.quarkus quarkus-oidc @@ -81,6 +82,12 @@ quarkus-keycloak-authorization + + io.quarkus + quarkus-keycloak-admin-client-reactive + + + org.projectlombok lombok @@ -89,9 +96,9 @@ - org.apache.tika - tika-core - 3.0.0-BETA + org.jodd + jodd-util + 6.2.1 diff --git a/src/main/docker/Dockerfile.native b/src/main/docker/Dockerfile.native index eae266f..f30745c 100644 --- a/src/main/docker/Dockerfile.native +++ b/src/main/docker/Dockerfile.native @@ -21,6 +21,7 @@ RUN chown 1001 /work \ && chown 1001:root /work COPY --chown=1001:root ffsaf/target/*-runner /work/application COPY --chown=1001:root ffsaf/src/main/resources/cacerts /work/cacerts +RUN mkdir /work/media && chown -R 1001:root /work/media EXPOSE 8080 USER 1001 diff --git a/src/main/java/fr/titionfire/ffsaf/data/model/ClubModel.java b/src/main/java/fr/titionfire/ffsaf/data/model/ClubModel.java index e71a738..546f8f8 100644 --- a/src/main/java/fr/titionfire/ffsaf/data/model/ClubModel.java +++ b/src/main/java/fr/titionfire/ffsaf/data/model/ClubModel.java @@ -21,6 +21,8 @@ public class ClubModel { @GeneratedValue(strategy = GenerationType.IDENTITY) Long id; + String clubId; + String name; String country; diff --git a/src/main/java/fr/titionfire/ffsaf/data/model/MembreModel.java b/src/main/java/fr/titionfire/ffsaf/data/model/MembreModel.java index a321d16..145b192 100644 --- a/src/main/java/fr/titionfire/ffsaf/data/model/MembreModel.java +++ b/src/main/java/fr/titionfire/ffsaf/data/model/MembreModel.java @@ -25,6 +25,8 @@ public class MembreModel { @GeneratedValue(strategy = GenerationType.IDENTITY) Long id; + String userId; + String lname; String fname; diff --git a/src/main/java/fr/titionfire/ffsaf/domain/entity/ClubEntity.java b/src/main/java/fr/titionfire/ffsaf/domain/entity/ClubEntity.java index 45253dd..9fea3c8 100644 --- a/src/main/java/fr/titionfire/ffsaf/domain/entity/ClubEntity.java +++ b/src/main/java/fr/titionfire/ffsaf/domain/entity/ClubEntity.java @@ -16,6 +16,7 @@ import java.util.Map; public class ClubEntity { private long id; private String name; + private String clubId; private String country; private String shieldURL; private Map contact; @@ -35,6 +36,7 @@ public class ClubEntity { return ClubEntity.builder() .id(model.getId()) .name(model.getName()) + .clubId(model.getClubId()) .country(model.getCountry()) .shieldURL(model.getShieldURL()) .contact(model.getContact()) @@ -49,7 +51,7 @@ public class ClubEntity { } public ClubModel toModel () { - return new ClubModel(this.id, this.name, this.country, this.shieldURL, this.contact, this.training_location, + return new ClubModel(this.id, this.clubId, this.name, this.country, this.shieldURL, this.contact, this.training_location, this.training_day_time, this.contact_intern, this.RNA, this.SIRET, this.no_affiliation, this.international); } diff --git a/src/main/java/fr/titionfire/ffsaf/domain/service/ClubService.java b/src/main/java/fr/titionfire/ffsaf/domain/service/ClubService.java index 9dab233..4169c52 100644 --- a/src/main/java/fr/titionfire/ffsaf/domain/service/ClubService.java +++ b/src/main/java/fr/titionfire/ffsaf/domain/service/ClubService.java @@ -32,4 +32,11 @@ public class ClubService { public Uni> getAll() { return repository.listAll(); } + + public Uni setClubId(Long id, String id1) { + return repository.findById(id).chain(clubModel -> { + clubModel.setClubId(id1); + return Panache.withTransaction(() -> repository.persist(clubModel)); + }); + } } diff --git a/src/main/java/fr/titionfire/ffsaf/domain/service/KeycloakService.java b/src/main/java/fr/titionfire/ffsaf/domain/service/KeycloakService.java new file mode 100644 index 0000000..6574fa5 --- /dev/null +++ b/src/main/java/fr/titionfire/ffsaf/domain/service/KeycloakService.java @@ -0,0 +1,172 @@ +package fr.titionfire.ffsaf.domain.service; + +import fr.titionfire.ffsaf.data.model.ClubModel; +import fr.titionfire.ffsaf.data.model.MembreModel; +import fr.titionfire.ffsaf.utils.KeycloakException; +import fr.titionfire.ffsaf.utils.RequiredAction; +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.List; +import java.util.Optional; + +@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; + + @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 NullPointerException("No keycloak user linked to the user id=" + membreModel.getId())); + } + return Uni.createFrom().item(membreModel::getUserId); + } + + public Uni setClubGroupMembre(MembreModel membreModel, ClubModel club) { + 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 fetchCompte(String id) { + return vertx.getOrCreateContext().executeBlocking(() -> { + UserResource user = keycloak.realm(realm).users().get(id); + UserRepresentation user2 = user.toRepresentation(); + return new UserCompteState(user2.isEnabled(), user2.getUsername(), user2.isEmailVerified(), + user.roles().realmLevel().listEffective().stream().map(RoleRepresentation::getName).toList(), + user.groups().stream().map(GroupRepresentation::getName).toList()); + }); + } + + 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(__ -> "OK"); + } + + private Uni creatUser(MembreModel membreModel) { + String login = makeLogin(membreModel); + LOGGER.infof("Creation of user %s...", login); + return vertx.getOrCreateContext().executeBlocking(() -> { + UserRepresentation user = new UserRepresentation(); + user.setUsername(login); + user.setFirstName(membreModel.getFname()); + user.setLastName(membreModel.getLname()); + user.setEmail(membreModel.getEmail()); + user.setEnabled(true); + + user.setRequiredActions(List.of(RequiredAction.VERIFY_EMAIL.name(), + RequiredAction.UPDATE_PASSWORD.name())); + + 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())); + } + + return getUser(login).orElseThrow(() -> new KeycloakException("Fail to fetch user %s".formatted(login))); + }) + .invoke(user -> membreModel.setUserId(user.getId())) + .call(user -> membreService.setUserId(membreModel.getId(), user.getId())) + .call(user -> setClubGroupMembre(membreModel, membreModel.getClub())); + } + + private 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)); + } + + private String makeLogin(MembreModel model) { + return Normalizer.normalize((model.getFname().toLowerCase() + "." + model.getLname().toLowerCase()).replace(' ', '_'), Normalizer.Form.NFD) + .replaceAll("\\p{M}", ""); + + } + + public record UserCompteState(Boolean enabled, String login, Boolean emailVerified, List realmRoles, + List groups) { + } +} diff --git a/src/main/java/fr/titionfire/ffsaf/domain/service/MembreService.java b/src/main/java/fr/titionfire/ffsaf/domain/service/MembreService.java index 8fb96ae..78d796d 100644 --- a/src/main/java/fr/titionfire/ffsaf/domain/service/MembreService.java +++ b/src/main/java/fr/titionfire/ffsaf/domain/service/MembreService.java @@ -3,7 +3,9 @@ package fr.titionfire.ffsaf.domain.service; import fr.titionfire.ffsaf.data.model.MembreModel; import fr.titionfire.ffsaf.data.repository.ClubRepository; import fr.titionfire.ffsaf.data.repository.CombRepository; +import fr.titionfire.ffsaf.net2.ServerCustom; import fr.titionfire.ffsaf.net2.data.SimpleCombModel; +import fr.titionfire.ffsaf.net2.request.SReqComb; import fr.titionfire.ffsaf.rest.from.FullMemberForm; import fr.titionfire.ffsaf.utils.Pair; import io.quarkus.hibernate.reactive.panache.Panache; @@ -25,6 +27,10 @@ public class MembreService { CombRepository repository; @Inject ClubRepository clubRepository; + @Inject + ServerCustom serverCustom; + @Inject + KeycloakService keycloakService; public SimpleCombModel find(int licence, String np) throws Throwable { return VertxContextSupport.subscribeAndAwait(() -> Panache.withTransaction(() -> @@ -59,6 +65,17 @@ public class MembreService { m.setGrade_arbitrage(membre.getGrade_arbitrage()); m.setEmail(membre.getEmail()); return Panache.withTransaction(() -> repository.persist(m)); - }).map(__ -> "OK"); + }) + .invoke(membreModel -> SReqComb.sendIfNeed(serverCustom.clients, SimpleCombModel.fromModel(membreModel))) + .call(membreModel -> (membreModel.getUserId() != null) ? + keycloakService.setClubGroupMembre(membreModel, membreModel.getClub()) : Uni.createFrom().nullItem()) + .map(__ -> "OK"); + } + + public Uni setUserId(Long id, String id1) { + return repository.findById(id).chain(membreModel -> { + membreModel.setUserId(id1); + return Panache.withTransaction(() -> repository.persist(membreModel)); + }); } } diff --git a/src/main/java/fr/titionfire/ffsaf/net2/request/SReqClub.java b/src/main/java/fr/titionfire/ffsaf/net2/request/SReqClub.java new file mode 100644 index 0000000..7edf14d --- /dev/null +++ b/src/main/java/fr/titionfire/ffsaf/net2/request/SReqClub.java @@ -0,0 +1,24 @@ +package fr.titionfire.ffsaf.net2.request; + +import fr.titionfire.ffsaf.net2.Client_Thread; +import fr.titionfire.ffsaf.net2.data.SimpleClubModel; + +import java.util.ArrayList; + +public class SReqClub { + public static void sendIfNeed(ArrayList client_Thread, SimpleClubModel club) { + for (Client_Thread client : client_Thread) { + client.sendNotify(club, "sendClub"); + } + } + public static void sendAddIfNeed(ArrayList client_Thread, SimpleClubModel club) { + for (Client_Thread client : client_Thread) { + client.sendNotify(club, "sendAddClub"); + } + } + public static void sendRmIfNeed(ArrayList client_Thread, long club) { + for (Client_Thread client : client_Thread) { + client.sendNotify(club, "sendRmClub"); + } + } +} diff --git a/src/main/java/fr/titionfire/ffsaf/net2/request/SReqComb.java b/src/main/java/fr/titionfire/ffsaf/net2/request/SReqComb.java new file mode 100644 index 0000000..f0b841d --- /dev/null +++ b/src/main/java/fr/titionfire/ffsaf/net2/request/SReqComb.java @@ -0,0 +1,27 @@ +package fr.titionfire.ffsaf.net2.request; + +import fr.titionfire.ffsaf.net2.Client_Thread; +import fr.titionfire.ffsaf.net2.data.SimpleCombModel; + +import java.util.ArrayList; + +public class SReqComb { + + public static void sendIfNeed(ArrayList client_Thread, SimpleCombModel comb) { + for (Client_Thread client : client_Thread) { + client.sendNotify(comb, "sendComb"); + } + } + + public static void sendIfNeedAdd(ArrayList client_Thread, SimpleCombModel comb) { + for (Client_Thread client : client_Thread) { + client.sendNotify(comb, "sendAddComb"); + } + } + + public static void sendRm(ArrayList client_Thread, long id) { + for (Client_Thread client : client_Thread) { + client.sendNotify(id, "sendRmComb"); + } + } +} diff --git a/src/main/java/fr/titionfire/ffsaf/rest/AuthEndpoints.java b/src/main/java/fr/titionfire/ffsaf/rest/AuthEndpoints.java index aa52756..3371743 100644 --- a/src/main/java/fr/titionfire/ffsaf/rest/AuthEndpoints.java +++ b/src/main/java/fr/titionfire/ffsaf/rest/AuthEndpoints.java @@ -1,5 +1,6 @@ package fr.titionfire.ffsaf.rest; +import fr.titionfire.ffsaf.rest.data.UserInfo; import io.quarkus.security.Authenticated; import io.quarkus.security.identity.SecurityIdentity; import jakarta.inject.Inject; @@ -9,6 +10,7 @@ import jakarta.ws.rs.Produces; import jakarta.ws.rs.core.MediaType; import jakarta.ws.rs.core.Response; import org.eclipse.microprofile.config.inject.ConfigProperty; +import org.eclipse.microprofile.jwt.JsonWebToken; import java.net.URI; import java.net.URISyntaxException; @@ -22,6 +24,9 @@ public class AuthEndpoints { @Inject SecurityIdentity securityIdentity; + @Inject + JsonWebToken accessToken; + @GET @Produces(MediaType.TEXT_PLAIN) public Boolean auth() { @@ -30,9 +35,10 @@ public class AuthEndpoints { @GET @Path("/userinfo") - @Produces(MediaType.TEXT_PLAIN) - public String userinfo() { - return securityIdentity.getPrincipal().getName(); + @Authenticated + @Produces(MediaType.APPLICATION_JSON) + public UserInfo userinfo() { + return UserInfo.makeUserInfo(accessToken, securityIdentity); } @GET diff --git a/src/main/java/fr/titionfire/ffsaf/rest/CombEndpoints.java b/src/main/java/fr/titionfire/ffsaf/rest/CombEndpoints.java index a8e744f..3030d1f 100644 --- a/src/main/java/fr/titionfire/ffsaf/rest/CombEndpoints.java +++ b/src/main/java/fr/titionfire/ffsaf/rest/CombEndpoints.java @@ -12,17 +12,13 @@ import jakarta.ws.rs.*; import jakarta.ws.rs.core.HttpHeaders; import jakarta.ws.rs.core.MediaType; import jakarta.ws.rs.core.Response; -import org.apache.commons.io.FileUtils; -import org.apache.tika.Tika; -import org.apache.tika.mime.MimeTypeException; -import org.apache.tika.mime.MimeTypes; +import jodd.net.MimeTypes; import org.eclipse.microprofile.config.inject.ConfigProperty; -import java.io.File; -import java.io.FilenameFilter; -import java.io.IOException; +import java.io.*; import java.net.URI; import java.net.URISyntaxException; +import java.net.URLConnection; import java.nio.file.Files; import java.util.List; import java.util.concurrent.CompletableFuture; @@ -60,12 +56,25 @@ public class CombEndpoints { @Consumes(MediaType.MULTIPART_FORM_DATA) public Uni setAdminMembre(@PathParam("id") long id, FullMemberForm input) { Future future = CompletableFuture.supplyAsync(() -> { - try{ - String mimeType = new Tika().detect(input.getPhoto_data()); - String extension = MimeTypes.getDefaultMimeTypes().forName(mimeType).getExtension(); - FileUtils.writeByteArrayToFile(new File(media, "ppMembre/" + input.getId() + extension), input.getPhoto_data()); + try (InputStream is = new BufferedInputStream(new ByteArrayInputStream(input.getPhoto_data()))) { + String mimeType = URLConnection.guessContentTypeFromStream(is); + String[] detectedExtensions = MimeTypes.findExtensionsByMimeTypes(mimeType, false); + if (detectedExtensions.length == 0) + throw new IOException("Fail to detect file extension for MIME type " + mimeType); + + FilenameFilter filter = (directory, filename) -> filename.startsWith(String.valueOf(id)); + File[] files = new File(media, "ppMembre").listFiles(filter); + if (files != null) { + for (File file : files) { + //noinspection ResultOfMethodCallIgnored + file.delete(); + } + } + + String extension = "." + detectedExtensions[0]; + Files.write(new File(media, "ppMembre/" + input.getId() + extension).toPath(), input.getPhoto_data()); return "OK"; - } catch (IOException | MimeTypeException e) { + } catch (IOException e) { return e.getMessage(); } }); @@ -112,7 +121,7 @@ public class CombEndpoints { if (filePair == null) return Response.temporaryRedirect(uri).build(); - String mimeType = new Tika().detect(filePair.getKey().getName()); + String mimeType = URLConnection.guessContentTypeFromName(filePair.getKey().getName()); Response.ResponseBuilder resp = Response.ok(filePair.getValue()); resp.type(MediaType.APPLICATION_OCTET_STREAM); diff --git a/src/main/java/fr/titionfire/ffsaf/rest/CompteEndpoints.java b/src/main/java/fr/titionfire/ffsaf/rest/CompteEndpoints.java new file mode 100644 index 0000000..299759b --- /dev/null +++ b/src/main/java/fr/titionfire/ffsaf/rest/CompteEndpoints.java @@ -0,0 +1,61 @@ +package fr.titionfire.ffsaf.rest; + +import fr.titionfire.ffsaf.domain.service.KeycloakService; +import fr.titionfire.ffsaf.rest.from.MemberPermForm; +import io.smallrye.mutiny.Uni; +import jakarta.annotation.security.RolesAllowed; +import jakarta.inject.Inject; +import jakarta.ws.rs.GET; +import jakarta.ws.rs.PUT; +import jakarta.ws.rs.Path; +import jakarta.ws.rs.PathParam; + +import java.util.ArrayList; +import java.util.List; + +@Path("api/compte") +public class CompteEndpoints { + + @Inject + KeycloakService service; + + @GET + @Path("{id}") + @RolesAllowed("federation_admin") + public Uni getCompte(@PathParam("id") String id) { + return service.fetchCompte(id); + } + + @PUT + @Path("{id}/init") + @RolesAllowed("federation_admin") + public Uni initCompte(@PathParam("id") long id) { + return service.initCompte(id); + } + + @GET + @Path("{id}/roles") + @RolesAllowed("federation_admin") + public Uni getRole(@PathParam("id") String id) { + return service.fetchRole(id); + } + + @PUT + @Path("{id}/roles") + @RolesAllowed("federation_admin") + public Uni updateRole(@PathParam("id") String id, MemberPermForm form) { + List toAdd = new ArrayList<>(); + List toRemove = new ArrayList<>(); + + if (form.isFederation_admin()) toAdd.add("federation_admin"); + else toRemove.add("federation_admin"); + if (form.isSafca_super_admin()) toAdd.add("safca_super_admin"); + else toRemove.add("safca_super_admin"); + if (form.isSafca_user()) toAdd.add("safca_user"); + else toRemove.add("safca_user"); + if (form.isSafca_create_compet()) toAdd.add("safca_create_compet"); + else toRemove.add("safca_create_compet"); + + return service.updateRole(id, toAdd, toRemove); + } +} diff --git a/src/main/java/fr/titionfire/ffsaf/rest/data/SimpleMembre.java b/src/main/java/fr/titionfire/ffsaf/rest/data/SimpleMembre.java index a553c36..dc51d65 100644 --- a/src/main/java/fr/titionfire/ffsaf/rest/data/SimpleMembre.java +++ b/src/main/java/fr/titionfire/ffsaf/rest/data/SimpleMembre.java @@ -19,6 +19,7 @@ import java.util.Date; @RegisterForReflection public class SimpleMembre { private long id; + private String userId; private String lname = ""; private String fname = ""; private Categorie categorie; @@ -38,6 +39,7 @@ public class SimpleMembre { return new SimpleMembreBuilder() .id(model.getId()) + .userId(model.getUserId()) .lname(model.getLname()) .fname(model.getFname()) .categorie(model.getCategorie()) diff --git a/src/main/java/fr/titionfire/ffsaf/rest/data/UserInfo.java b/src/main/java/fr/titionfire/ffsaf/rest/data/UserInfo.java new file mode 100644 index 0000000..0c102f2 --- /dev/null +++ b/src/main/java/fr/titionfire/ffsaf/rest/data/UserInfo.java @@ -0,0 +1,38 @@ +package fr.titionfire.ffsaf.rest.data; + +import io.quarkus.runtime.annotations.RegisterForReflection; +import io.quarkus.security.identity.SecurityIdentity; +import lombok.Builder; +import lombok.Data; +import org.eclipse.microprofile.jwt.JsonWebToken; + +import java.util.Set; + +@Data +@Builder +@RegisterForReflection +public class UserInfo { + String id; + String name; + String givenName; + String familyName; + String email; + boolean emailVerified; + long expiration; + Set groups; + Set roles; + + public static UserInfo makeUserInfo(JsonWebToken accessToken, SecurityIdentity securityIdentity) { + UserInfo.UserInfoBuilder builder = UserInfo.builder(); + builder.id(accessToken.getSubject()); + builder.name(accessToken.getName()); + builder.givenName(accessToken.getClaim("given_name")); + builder.familyName(accessToken.getClaim("family_name")); + builder.email(accessToken.getClaim("email")); + builder.emailVerified(accessToken.getClaim("email_verified")); + builder.expiration(accessToken.getExpirationTime()); + builder.groups(accessToken.getGroups()); + builder.roles(securityIdentity.getRoles()); + return builder.build(); + } +} diff --git a/src/main/java/fr/titionfire/ffsaf/rest/from/MemberPermForm.java b/src/main/java/fr/titionfire/ffsaf/rest/from/MemberPermForm.java new file mode 100644 index 0000000..a99832d --- /dev/null +++ b/src/main/java/fr/titionfire/ffsaf/rest/from/MemberPermForm.java @@ -0,0 +1,21 @@ +package fr.titionfire.ffsaf.rest.from; + +import jakarta.ws.rs.FormParam; +import lombok.Getter; +import lombok.ToString; + +@Getter +@ToString +public class MemberPermForm { + @FormParam("federation_admin") + private boolean federation_admin; + + @FormParam("safca_user") + private boolean safca_user; + + @FormParam("safca_create_compet") + private boolean safca_create_compet; + + @FormParam("safca_super_admin") + private boolean safca_super_admin; +} diff --git a/src/main/java/fr/titionfire/ffsaf/utils/KeycloakException.java b/src/main/java/fr/titionfire/ffsaf/utils/KeycloakException.java new file mode 100644 index 0000000..f8a9673 --- /dev/null +++ b/src/main/java/fr/titionfire/ffsaf/utils/KeycloakException.java @@ -0,0 +1,8 @@ +package fr.titionfire.ffsaf.utils; + +public class KeycloakException extends Exception{ + + public KeycloakException(String msg) { + super(msg); + } +} diff --git a/src/main/java/fr/titionfire/ffsaf/utils/RequiredAction.java b/src/main/java/fr/titionfire/ffsaf/utils/RequiredAction.java new file mode 100644 index 0000000..ea4e752 --- /dev/null +++ b/src/main/java/fr/titionfire/ffsaf/utils/RequiredAction.java @@ -0,0 +1,5 @@ +package fr.titionfire.ffsaf.utils; + +public enum RequiredAction { + VERIFY_EMAIL, UPDATE_PROFILE, CONFIGURE_TOTP, UPDATE_PASSWORD, TERMS_AND_CONDITIONS +} diff --git a/src/main/resources/application.properties b/src/main/resources/application.properties index 3d42376..053cf03 100644 --- a/src/main/resources/application.properties +++ b/src/main/resources/application.properties @@ -17,7 +17,8 @@ quarkus.quartz.start-mode=forced %dev.quarkus.log.min-level=ALL %dev.quarkus.log.category."fr.titionfire.ffsaf".level=ALL -quarkus.oidc.auth-server-url=https://auth.safca.fr/auth/realms/safca + +quarkus.oidc.auth-server-url=https://auth.safca.fr/realms/safca quarkus.oidc.client-id=backend quarkus.oidc.credentials.secret=secret quarkus.oidc.tls.verification=required @@ -35,6 +36,7 @@ database.pass= #Login quarkus.oidc.token-state-manager.split-tokens=true +quarkus.oidc.token.refresh-expired=true quarkus.oidc.authentication.redirect-path=/api/auth/login quarkus.oidc.logout.path=/api/logout @@ -46,4 +48,5 @@ quarkus.http.auth.permission.authenticated.policy=authenticated # All users can see the welcome page: quarkus.http.auth.permission.public.paths=/index.html -quarkus.http.auth.permission.public.policy=permit \ No newline at end of file +quarkus.http.auth.permission.public.policy=permit +quarkus.keycloak.admin-client.server-url=https://auth.safca.fr diff --git a/src/main/webapp/package-lock.json b/src/main/webapp/package-lock.json index d1c274e..37c5149 100644 --- a/src/main/webapp/package-lock.json +++ b/src/main/webapp/package-lock.json @@ -18,7 +18,8 @@ "react": "^18.2.0", "react-dom": "^18.2.0", "react-loader-spinner": "^6.1.6", - "react-router-dom": "^6.21.2" + "react-router-dom": "^6.21.2", + "react-toastify": "^10.0.4" }, "devDependencies": { "@types/react": "^18.2.43", @@ -1631,6 +1632,14 @@ "node": ">=4" } }, + "node_modules/clsx": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.0.tgz", + "integrity": "sha512-m3iNNWpd9rl3jvvcBnu70ylMdrXt8Vlq4HYadnU5fwcOtvkSQWPmj7amUcDT2qYI7risszBjI5AUIUox9D16pg==", + "engines": { + "node": ">=6" + } + }, "node_modules/color-convert": { "version": "1.9.3", "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-1.9.3.tgz", @@ -3609,6 +3618,18 @@ "react-dom": ">=16.8" } }, + "node_modules/react-toastify": { + "version": "10.0.4", + "resolved": "https://registry.npmjs.org/react-toastify/-/react-toastify-10.0.4.tgz", + "integrity": "sha512-etR3RgueY8pe88SA67wLm8rJmL1h+CLqUGHuAoNsseW35oTGJEri6eBTyaXnFKNQ80v/eO10hBYLgz036XRGgA==", + "dependencies": { + "clsx": "^2.1.0" + }, + "peerDependencies": { + "react": ">=16", + "react-dom": ">=16" + } + }, "node_modules/reflect.getprototypeof": { "version": "1.0.4", "resolved": "https://registry.npmjs.org/reflect.getprototypeof/-/reflect.getprototypeof-1.0.4.tgz", diff --git a/src/main/webapp/package.json b/src/main/webapp/package.json index 2b2667a..534a96f 100644 --- a/src/main/webapp/package.json +++ b/src/main/webapp/package.json @@ -20,7 +20,8 @@ "react": "^18.2.0", "react-dom": "^18.2.0", "react-loader-spinner": "^6.1.6", - "react-router-dom": "^6.21.2" + "react-router-dom": "^6.21.2", + "react-toastify": "^10.0.4" }, "devDependencies": { "@types/react": "^18.2.43", diff --git a/src/main/webapp/src/App.jsx b/src/main/webapp/src/App.jsx index a8c8f61..04b7a97 100644 --- a/src/main/webapp/src/App.jsx +++ b/src/main/webapp/src/App.jsx @@ -1,5 +1,4 @@ import {useEffect, useRef} from 'react' -import './App.css' import {Nav} from "./components/Nav.jsx"; import {createBrowserRouter, Outlet, RouterProvider, useRouteError} from "react-router-dom"; import {Home} from "./pages/Homepage.jsx"; @@ -7,6 +6,10 @@ import {AdminRoot, getAdminChildren} from "./pages/admin/AdminRoot.jsx"; import {AuthCallback} from "./components/auhCallback.jsx"; import {KeycloakContextProvider, useAuthDispatch} from "./hooks/useAuth.jsx"; import {check_validity} from "./utils/auth.js"; +import {ToastContainer} from "react-toastify"; + +import './App.css' +import 'react-toastify/dist/ReactToastify.css'; const router = createBrowserRouter([ { @@ -49,7 +52,7 @@ function Root() { if (isInit.current) return; isInit.current = true - check_validity(b => dispatch({type: 'init', val: b})) + check_validity(data => dispatch({type: 'init', val: data})) }, []); return <> @@ -58,6 +61,19 @@ function Root() {
+
} diff --git a/src/main/webapp/src/components/ClubSelect.jsx b/src/main/webapp/src/components/ClubSelect.jsx index 667639e..9b7d906 100644 --- a/src/main/webapp/src/components/ClubSelect.jsx +++ b/src/main/webapp/src/components/ClubSelect.jsx @@ -1,13 +1,13 @@ -import {LoadingContextProvider, useLoadingSwitcher} from "../hooks/useLoading.jsx"; +import {LoadingProvider, useLoadingSwitcher} from "../hooks/useLoading.jsx"; import {useFetch} from "../hooks/useFetch.js"; import {AxiosError} from "./AxiosError.jsx"; export function ClubSelect({defaultValue, name}) { - return + return
-
+ } function ClubSelect_({defaultValue, name}) { diff --git a/src/main/webapp/src/components/ColoredCircle.css b/src/main/webapp/src/components/ColoredCircle.css new file mode 100644 index 0000000..99abd95 --- /dev/null +++ b/src/main/webapp/src/components/ColoredCircle.css @@ -0,0 +1,9 @@ +div .colored-circle { + display: inline-block; + margin-left: 5px; + margin-right: 5px; + margin-bottom: -2px; + border-radius: 50%; + height: 20px; + width: 20px; +} \ No newline at end of file diff --git a/src/main/webapp/src/components/ColoredCircle.jsx b/src/main/webapp/src/components/ColoredCircle.jsx new file mode 100644 index 0000000..2f9d574 --- /dev/null +++ b/src/main/webapp/src/components/ColoredCircle.jsx @@ -0,0 +1,16 @@ +import {Fragment} from "react"; +import './ColoredCircle.css' + +export const ColoredCircle = ({color, boolean}) => { + const styles = {backgroundColor: '#F00'}; + + if (boolean === undefined) { + styles.backgroundColor = color + } else { + styles.backgroundColor = (boolean) ? '#00c700' : '#e50000'; + } + + return + + +}; \ No newline at end of file diff --git a/src/main/webapp/src/components/Nav.jsx b/src/main/webapp/src/components/Nav.jsx index d5eb0c9..3b95604 100644 --- a/src/main/webapp/src/components/Nav.jsx +++ b/src/main/webapp/src/components/Nav.jsx @@ -32,9 +32,9 @@ export function Nav() { } function AdminMenu() { - const {is_authenticated, data} = useAuth() + const {is_authenticated, userinfo} = useAuth() - if (!is_authenticated || !data?.realm_access?.roles?.includes("federation_admin")) + if (!is_authenticated || !userinfo?.roles?.includes("federation_admin")) return <> return
  • diff --git a/src/main/webapp/src/hooks/useAuth.jsx b/src/main/webapp/src/hooks/useAuth.jsx index acbc082..ba9e67f 100644 --- a/src/main/webapp/src/hooks/useAuth.jsx +++ b/src/main/webapp/src/hooks/useAuth.jsx @@ -25,15 +25,14 @@ function authReducer(auth, action) { switch (action.type) { case 'init': { return { - is_authenticated: action.val, - data: {realm_access: {roles: ["federation_admin"]}} - //data: action.val ? JSON.parse(atob(token.split('.')[1])) : null + is_authenticated: action.val.state, + userinfo: action.val.userinfo } } case 'update': { return { ...auth, - data: {realm_access: {roles: ["federation_admin"]}} + // data: {realm_access: {roles: ["federation_admin"]}} // data: JSON.parse(atob(action.token.split('.')[1])) } } @@ -51,5 +50,5 @@ function authReducer(auth, action) { const initialAuth = { is_authenticated: undefined, - data: undefined, + userinfo: undefined, } \ No newline at end of file diff --git a/src/main/webapp/src/hooks/useLoading.jsx b/src/main/webapp/src/hooks/useLoading.jsx index c1bce06..f438387 100644 --- a/src/main/webapp/src/hooks/useLoading.jsx +++ b/src/main/webapp/src/hooks/useLoading.jsx @@ -13,7 +13,7 @@ export function useLoadingSwitcher() { return useContext(LoadingSwitcherContext); } -export function LoadingContextProvider({children}) { +export function LoadingProvider({children}) { const [showOverlay, setOverlay] = useState(0); return diff --git a/src/main/webapp/src/pages/admin/AdminRoot.jsx b/src/main/webapp/src/pages/admin/AdminRoot.jsx index 397fa27..804f6fa 100644 --- a/src/main/webapp/src/pages/admin/AdminRoot.jsx +++ b/src/main/webapp/src/pages/admin/AdminRoot.jsx @@ -1,15 +1,15 @@ -import {NavLink, Outlet} from "react-router-dom"; +import {Outlet} from "react-router-dom"; import './AdminRoot.css' -import {LoadingContextProvider} from "../../hooks/useLoading.jsx"; +import {LoadingProvider} from "../../hooks/useLoading.jsx"; import {MemberList} from "./MemberList.jsx"; import {MemberPage} from "./MemberPage.jsx"; export function AdminRoot() { return <>

    Espace administration

    - + - + } diff --git a/src/main/webapp/src/pages/admin/MemberPage.jsx b/src/main/webapp/src/pages/admin/MemberPage.jsx index 24230c7..c06da70 100644 --- a/src/main/webapp/src/pages/admin/MemberPage.jsx +++ b/src/main/webapp/src/pages/admin/MemberPage.jsx @@ -1,11 +1,13 @@ import {useNavigate, useParams} from "react-router-dom"; -import {useLoadingSwitcher} from "../../hooks/useLoading.jsx"; +import {LoadingProvider, useLoadingSwitcher} from "../../hooks/useLoading.jsx"; import {useFetch} from "../../hooks/useFetch.js"; import {AxiosError} from "../../components/AxiosError.jsx"; import {ClubSelect} from "../../components/ClubSelect.jsx"; import {useEffect, useState} from "react"; import {apiAxios, getCategoryFormBirthDate} from "../../utils/Tools.js"; import imageCompression from "browser-image-compression"; +import {ColoredCircle} from "../../components/ColoredCircle.jsx"; +import {toast} from "react-toastify"; const vite_url = import.meta.env.VITE_URL; @@ -16,9 +18,51 @@ export function MemberPage() { const setLoading = useLoadingSwitcher() const {data, error} = useFetch(`/member/${id}`, setLoading, 1) + return <> +

    Page membre

    + + {data + ?
    +
    +
    + + +
    +
    + + +
    + + +
    +
    +
    +
    + : error && + } + +} + +function PhotoCard({data}) { + return
    +
    Licence n°{data.licence}
    +
    +
    + avatar +
    +
    +
    ; +} + +function InformationForm({data}) { + const setLoading = useLoadingSwitcher() const handleSubmit = (event) => { event.preventDefault(); - setLoading(1) const formData = new FormData(); @@ -35,15 +79,16 @@ export function MemberPage() { formData.append("grade_arbitrage", event.target.grade_arbitrage?.value); const send = (formData_) => { - apiAxios.post(`/member/${id}`, formData_, { + apiAxios.post(`/member/${data.id}`, formData_, { headers: { 'Accept': '*/*', 'Content-Type': 'multipart/form-data', } - }).then(data => { - console.log(data.data) + }).then(_ => { + toast.success('Profile mis à jours avec succès 🎉'); }).catch(e => { console.log(e.response) + toast.error('Échec de la mise à jours du profile 😕 (code: ' + e.response.status + ')'); }).finally(() => { if (setLoading) setLoading(0) @@ -70,100 +115,221 @@ export function MemberPage() { } } - return <> -

    Page membre

    - - {data - ? - : error && - } - -} - - -function MemberForm({data, handleSubmit}) { - return
    -
    -
    -
    -
    Licence n°{data.licence}
    -
    -
    - avatar -
    + return
    +
    +
    Information
    +
    + + + + + + +
    + +
    + + +
    +
    + +
    -
    -
    - -
    -
    Information
    -
    - - - - - - -
    - -
    - - -
    -
    - - -
    -
    -
    -
    - -
    -
    -
    -
    -
    -
    -
    -
    Licence
    -
    -

    Web Design

    -
    -
    -
    -
    -
    -
    Sélection en équipe de France
    -
    -

    Web Design

    - -
    -
    +
    +
    + ; +} + +function PremForm({userData}) { + const setLoading = useLoadingSwitcher() + const handleSubmitPerm = (event) => { + event.preventDefault(); + setLoading(1) + + const formData = new FormData(); + formData.append("federation_admin", event.target.federation_admin?.checked); + formData.append("safca_user", event.target.safca_user?.checked); + formData.append("safca_create_compet", event.target.safca_create_compet?.checked); + formData.append("safca_super_admin", event.target.safca_super_admin?.checked); + + apiAxios.put(`/compte/${userData.userId}/roles`, formData, { + headers: { + 'Accept': '*/*', + 'Content-Type': 'form-data', + } + }).then(_ => { + toast.success('Permission mise à jours avec succès 🎉'); + }).catch(e => { + console.log(e.response) + toast.error('Échec de la mise à jours des permissions 😕 (code: ' + e.response.status + ')'); + }).finally(() => { + if (setLoading) + setLoading(0) + }) + } + + return
    +
    +
    Permission
    +
    +
    + {userData.userId + ? + :
    +
    +
    Ce membre ne dispose pas de compte...
    +
    +
    + } +
    +
    +
    + {userData.userId && } +
    +
    +
    +
    +
    +} + +function PremFormContent({userData}) { + const setLoading = useLoadingSwitcher() + const {data, error} = useFetch(`/compte/${userData.userId}/roles`, setLoading, 1) + + return <> +
    +
    FFSAF intra
    + {data + ? <> + + + : error && } +
    +
    +
    SAFCA
    + {data + ? <> + + + + + : error && } +
    + +} + +function LicenceCard() { + return
    +
    +
    Licence
    +
    +

    Web Design

    +
    +
    +
    ; +} + +function SelectCard() { + return
    +
    +
    Sélection en équipe de France
    +
    +

    Web Design

    + +
    +
    +
    ; +} + +function CompteInfo({userData}) { + + const creatAccount = () => { + let err = {}; + toast.promise( + apiAxios.put(`/compte/${userData.id}/init`).catch(e => { + err = e + }), + { + pending: 'Création du compte en cours', + success: 'Compte créé avec succès 🎉', + error: 'Échec de la création du compte 😕 (code: ' + err.response.status + ')' + } + ) + } + + return
    +
    Compte
    +
    + {userData.userId + ? + : + <> +
    +
    +
    Ce membre ne dispose pas de compte...
    +
    +
    +
    +
    + +
    +
    + + } +
    + +} + +function CompteInfoContent({ + userData + }) { + const setLoading = useLoadingSwitcher() + const {data, error} = useFetch(`/compte/${userData.userId}`, setLoading, 1) + + return <> + {data + ? <> +
    +
    +
    Identifiant: {data.login}
    +
    +
    +
    +
    +
    Activer:
    +
    +
    +
    +
    +
    Email vérifié:
    +
    +
    + + : error && + } } function BirthDayField({inti_date, inti_category}) { @@ -176,8 +342,7 @@ function BirthDayField({inti_date, inti_category}) { setCanUpdate(b) }, [date, category]) - const updateCat = (e) => { - console.log(date) + const updateCat = _ => { setCategory(getCategoryFormBirthDate(new Date(date), new Date('2023-09-01'))) } @@ -223,4 +388,24 @@ function TextField({name, text, value, placeholder, type = "text"}) { name={name} aria-describedby={name} defaultValue={value} required/>
    +} + +function CheckField({name, text, value, row = false}) { + return <>{ + row ? +
    +
    +
    + + +
    +
    +
    + :
    + + +
    + } + } \ No newline at end of file diff --git a/src/main/webapp/src/utils/auth.js b/src/main/webapp/src/utils/auth.js index 9034362..0b12240 100644 --- a/src/main/webapp/src/utils/auth.js +++ b/src/main/webapp/src/utils/auth.js @@ -5,10 +5,13 @@ const vite_url = import.meta.env.VITE_URL; export function check_validity(online_callback = () => { }) { return axios.get(`${vite_url}/api/auth`).then(data => { - console.log(data.data) - online_callback(data.data); + if (data.data) { + axios.get(`${vite_url}/api/auth/userinfo`).then(data => { + online_callback({state: true, userinfo: data.data}); + }) + } }).catch(() => { - online_callback(false); + online_callback({state: false}); }) }