From f604e6496617c423b04c68472e7943665881d91a Mon Sep 17 00:00:00 2001 From: Thibaut Valentin Date: Thu, 25 Jan 2024 21:36:15 +0100 Subject: [PATCH 1/7] fix: mimetype to extension convert --- pom.xml | 6 ++-- src/main/docker/Dockerfile.native | 1 + .../titionfire/ffsaf/rest/CombEndpoints.java | 35 ++++++++++++------- 3 files changed, 26 insertions(+), 16 deletions(-) diff --git a/pom.xml b/pom.xml index bb6cffd..169f384 100644 --- a/pom.xml +++ b/pom.xml @@ -89,9 +89,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/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); From d84ec9e1b4e58ab81f29ae85fe2ec2d920913eb8 Mon Sep 17 00:00:00 2001 From: Thibaut Valentin Date: Thu, 25 Jan 2024 22:43:25 +0100 Subject: [PATCH 2/7] feat: notify Membre update to ws client --- .../ffsaf/domain/service/MembreService.java | 8 +++++- .../ffsaf/net2/request/SReqClub.java | 24 +++++++++++++++++ .../ffsaf/net2/request/SReqComb.java | 27 +++++++++++++++++++ 3 files changed, 58 insertions(+), 1 deletion(-) create mode 100644 src/main/java/fr/titionfire/ffsaf/net2/request/SReqClub.java create mode 100644 src/main/java/fr/titionfire/ffsaf/net2/request/SReqComb.java 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..7cb0f68 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,8 @@ public class MembreService { CombRepository repository; @Inject ClubRepository clubRepository; + @Inject + ServerCustom serverCustom; public SimpleCombModel find(int licence, String np) throws Throwable { return VertxContextSupport.subscribeAndAwait(() -> Panache.withTransaction(() -> @@ -59,6 +63,8 @@ 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))) + .map(__ -> "OK"); } } 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"); + } + } +} From 978c055834b489ba9eb549334df88a0b0aaa3e93 Mon Sep 17 00:00:00 2001 From: Thibaut Valentin Date: Sun, 28 Jan 2024 23:47:30 +0100 Subject: [PATCH 3/7] feat: add keycloak account creation and club groupe set --- pom.xml | 7 + .../ffsaf/data/model/ClubModel.java | 2 + .../ffsaf/data/model/MembreModel.java | 2 + .../ffsaf/domain/entity/ClubEntity.java | 4 +- .../ffsaf/domain/service/ClubService.java | 7 + .../ffsaf/domain/service/KeycloakService.java | 151 ++++++++++++++++++ .../ffsaf/domain/service/MembreService.java | 11 ++ .../titionfire/ffsaf/rest/AuthEndpoints.java | 12 +- .../ffsaf/rest/CompteEndpoints.java | 31 ++++ .../ffsaf/rest/data/SimpleMembre.java | 2 + .../titionfire/ffsaf/rest/data/UserInfo.java | 38 +++++ .../ffsaf/utils/KeycloakException.java | 8 + src/main/resources/application.properties | 7 +- src/main/webapp/package-lock.json | 23 ++- src/main/webapp/package.json | 3 +- src/main/webapp/src/App.jsx | 20 ++- .../webapp/src/components/ColoredCircle.css | 9 ++ .../webapp/src/components/ColoredCircle.jsx | 16 ++ src/main/webapp/src/components/Nav.jsx | 4 +- src/main/webapp/src/hooks/useAuth.jsx | 9 +- .../webapp/src/pages/admin/MemberPage.jsx | 71 +++++++- src/main/webapp/src/utils/auth.js | 9 +- 22 files changed, 422 insertions(+), 24 deletions(-) create mode 100644 src/main/java/fr/titionfire/ffsaf/domain/service/KeycloakService.java create mode 100644 src/main/java/fr/titionfire/ffsaf/rest/CompteEndpoints.java create mode 100644 src/main/java/fr/titionfire/ffsaf/rest/data/UserInfo.java create mode 100644 src/main/java/fr/titionfire/ffsaf/utils/KeycloakException.java create mode 100644 src/main/webapp/src/components/ColoredCircle.css create mode 100644 src/main/webapp/src/components/ColoredCircle.jsx diff --git a/pom.xml b/pom.xml index 169f384..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 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..4cbf7fe --- /dev/null +++ b/src/main/java/fr/titionfire/ffsaf/domain/service/KeycloakService.java @@ -0,0 +1,151 @@ +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 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.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()).toRepresentation().getSubGroups().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 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.setEnabled(true); + + //user.setRequiredActions(List.of(UserModel.RequiredAction.VERIFY_EMAIL.name(), + // UserModel.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))); + }).call(user -> membreService.setUserId(membreModel.getId(), user.getId())); + } + + 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 7cb0f68..78d796d 100644 --- a/src/main/java/fr/titionfire/ffsaf/domain/service/MembreService.java +++ b/src/main/java/fr/titionfire/ffsaf/domain/service/MembreService.java @@ -29,6 +29,8 @@ public class MembreService { ClubRepository clubRepository; @Inject ServerCustom serverCustom; + @Inject + KeycloakService keycloakService; public SimpleCombModel find(int licence, String np) throws Throwable { return VertxContextSupport.subscribeAndAwait(() -> Panache.withTransaction(() -> @@ -65,6 +67,15 @@ public class MembreService { return Panache.withTransaction(() -> repository.persist(m)); }) .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/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/CompteEndpoints.java b/src/main/java/fr/titionfire/ffsaf/rest/CompteEndpoints.java new file mode 100644 index 0000000..01c3440 --- /dev/null +++ b/src/main/java/fr/titionfire/ffsaf/rest/CompteEndpoints.java @@ -0,0 +1,31 @@ +package fr.titionfire.ffsaf.rest; + +import fr.titionfire.ffsaf.domain.service.KeycloakService; +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; + +@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); + } +} 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/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/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/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/pages/admin/MemberPage.jsx b/src/main/webapp/src/pages/admin/MemberPage.jsx index 24230c7..99e9770 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 {useFetch} from "../../hooks/useFetch.js"; +import {useFetch, useFetchPut} 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; @@ -41,9 +43,9 @@ export function MemberPage() { 'Content-Type': 'multipart/form-data', } }).then(data => { - console.log(data.data) + console.log(data.data) // TODO }).catch(e => { - console.log(e.response) + console.log(e.response) // TODO }).finally(() => { if (setLoading) setLoading(0) @@ -82,8 +84,18 @@ export function MemberPage() { } - function MemberForm({data, handleSubmit}) { + const creatAccount = () => { + toast.promise( + apiAxios.put(`/compte/${data.id}/init`), + { + pending: 'Création du compte en cours', + success: 'Compte créé avec succès 🎉', + error: 'Échec de la création du compte 😕' + } + ) + } + return
    @@ -98,6 +110,25 @@ function MemberForm({data, handleSubmit}) {
    +
    +
    Compte
    +
    + {data.userId + ? + : <> +
    +
    +
    Ce membre ne dispose pas de compte...
    +
    +
    +
    +
    + +
    +
    + } +
    +
    @@ -166,6 +197,38 @@ function MemberForm({data, handleSubmit}) {
    } +function CompteInfo({userId}) { + const setLoading = useLoadingSwitcher() + const {data, error} = useFetch(`/compte/${userId}`, setLoading, 1) + + return <> + {data + ? + : error && + } + +} + +function CompteInfoContent({data}) { + return <> +
    +
    +
    Identifiant: {data.login}
    +
    +
    +
    +
    +
    Activer:
    +
    +
    +
    +
    +
    Email vérifié:
    +
    +
    + +} + function BirthDayField({inti_date, inti_category}) { const [date, setDate] = useState(inti_date) const [category, setCategory] = useState(inti_category) 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}); }) } From 251d0e25d694f99e9226509268a9c8e726cd53dd Mon Sep 17 00:00:00 2001 From: Thibaut Valentin Date: Mon, 29 Jan 2024 22:25:26 +0100 Subject: [PATCH 4/7] =?UTF-8?q?add=20Keycloak=20action,=20auto=20assigne?= =?UTF-8?q?=20club=20on=20cr=C3=A9ation?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../ffsaf/domain/service/KeycloakService.java | 33 +++++++++++-------- .../ffsaf/utils/RequiredAction.java | 5 +++ 2 files changed, 24 insertions(+), 14 deletions(-) create mode 100644 src/main/java/fr/titionfire/ffsaf/utils/RequiredAction.java diff --git a/src/main/java/fr/titionfire/ffsaf/domain/service/KeycloakService.java b/src/main/java/fr/titionfire/ffsaf/domain/service/KeycloakService.java index 4cbf7fe..8c0ddcd 100644 --- a/src/main/java/fr/titionfire/ffsaf/domain/service/KeycloakService.java +++ b/src/main/java/fr/titionfire/ffsaf/domain/service/KeycloakService.java @@ -3,6 +3,7 @@ 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; @@ -56,7 +57,7 @@ public class KeycloakService { response.getStatusInfo().getReasonPhrase())); } - return keycloak.realm(realm).groups().group(clubGroup.getId()).toRepresentation().getSubGroups().stream() + 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() + "-"))); } @@ -112,22 +113,26 @@ public class KeycloakService { 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.setEnabled(true); + 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(UserModel.RequiredAction.VERIFY_EMAIL.name(), - // UserModel.RequiredAction.UPDATE_PASSWORD.name())); + 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())); - } + 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))); - }).call(user -> membreService.setUserId(membreModel.getId(), user.getId())); + 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) { 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 +} From e3306f4b5ac283d9b1235882fb800c6c90f0c526 Mon Sep 17 00:00:00 2001 From: Thibaut Valentin Date: Mon, 29 Jan 2024 22:26:11 +0100 Subject: [PATCH 5/7] init Membre prem card --- .../webapp/src/pages/admin/MemberPage.jsx | 306 +++++++++++------- 1 file changed, 185 insertions(+), 121 deletions(-) diff --git a/src/main/webapp/src/pages/admin/MemberPage.jsx b/src/main/webapp/src/pages/admin/MemberPage.jsx index 99e9770..3373412 100644 --- a/src/main/webapp/src/pages/admin/MemberPage.jsx +++ b/src/main/webapp/src/pages/admin/MemberPage.jsx @@ -20,7 +20,6 @@ export function MemberPage() { const handleSubmit = (event) => { event.preventDefault(); - setLoading(1) const formData = new FormData(); @@ -72,22 +71,151 @@ export function MemberPage() { } } + const handleSubmitPerm = (event) => { + + } + return <>

    Page membre

    {data - ? + ?
    +
    +
    + + +
    +
    + + +
    + + +
    +
    +
    +
    : error && } } -function MemberForm({data, handleSubmit}) { +function PhotoCard({data}) { + return
    +
    Licence n°{data.licence}
    +
    +
    + avatar +
    +
    +
    ; +} + +function InformationForm({handleSubmit, data}) { + return +
    +
    Information
    +
    + + + + + + +
    + +
    + + +
    +
    + + +
    +
    +
    +
    + +
    +
    +
    +
    + ; +} + +function PremForm({handleSubmitPerm}) { + return
    +
    +
    Permission
    +
    +
    +
    +
    FFSAF intra
    + +
    +
    +
    SAFCA
    + + + +
    +
    +
    +
    + +
    +
    +
    +
    +
    ; +} + +function LicenceCard() { + return
    +
    +
    Licence
    +
    +

    Web Design

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

    Web Design

    + +
    +
    +
    ; +} + +function CompteInfo({userData}) { + const creatAccount = () => { toast.promise( - apiAxios.put(`/compte/${data.id}/init`), + apiAxios.put(`/compte/${userData.id}/init`), { pending: 'Création du compte en cours', success: 'Compte créé avec succès 🎉', @@ -96,137 +224,53 @@ function MemberForm({data, handleSubmit}) { ) } - return
    -
    -
    -
    -
    Licence n°{data.licence}
    -
    + return
    +
    Compte
    +
    + {userData.userId + ? + : <> +
    - avatar +
    Ce membre ne dispose pas de compte...
    -
    -
    -
    Compte
    -
    - {data.userId - ? - : <> -
    -
    -
    Ce membre ne dispose pas de compte...
    -
    -
    -
    -
    - -
    -
    - } -
    -
    -
    -
    -
    -
    -
    Information
    -
    - - - - - - -
    - -
    - - -
    -
    - - -
    -
    -
    -
    - -
    -
    +
    +
    +
    - -
    -
    -
    -
    Licence
    -
    -

    Web Design

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

    Web Design

    - -
    -
    -
    -
    -
    + }
    + } -function CompteInfo({userId}) { +function CompteInfoContent({userData}) { const setLoading = useLoadingSwitcher() - const {data, error} = useFetch(`/compte/${userId}`, setLoading, 1) + const {data, error} = useFetch(`/compte/${userData.userId}`, setLoading, 1) return <> {data - ? + ? <> +
    +
    +
    Identifiant: {data.login}
    +
    +
    +
    +
    +
    Activer:
    +
    +
    +
    +
    +
    Email vérifié:
    +
    +
    + : error && - } - -} - -function CompteInfoContent({data}) { - return <> -
    -
    -
    Identifiant: {data.login}
    -
    -
    -
    -
    -
    Activer:
    -
    -
    -
    -
    -
    Email vérifié:
    -
    -
    - + } } function BirthDayField({inti_date, inti_category}) { @@ -286,4 +330,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 From 8824a547bc4a2c7338b24f29f6ac290fd1ed02ba Mon Sep 17 00:00:00 2001 From: Thibaut Valentin Date: Tue, 30 Jan 2024 20:04:41 +0100 Subject: [PATCH 6/7] feat: add keycloak role configuration --- .../ffsaf/domain/service/KeycloakService.java | 16 ++ .../ffsaf/rest/CompteEndpoints.java | 30 +++ .../ffsaf/rest/from/MemberPermForm.java | 21 ++ src/main/webapp/src/components/ClubSelect.jsx | 6 +- src/main/webapp/src/hooks/useLoading.jsx | 2 +- src/main/webapp/src/pages/admin/AdminRoot.jsx | 6 +- .../webapp/src/pages/admin/MemberPage.jsx | 219 +++++++++++------- 7 files changed, 213 insertions(+), 87 deletions(-) create mode 100644 src/main/java/fr/titionfire/ffsaf/rest/from/MemberPermForm.java diff --git a/src/main/java/fr/titionfire/ffsaf/domain/service/KeycloakService.java b/src/main/java/fr/titionfire/ffsaf/domain/service/KeycloakService.java index 8c0ddcd..6574fa5 100644 --- a/src/main/java/fr/titionfire/ffsaf/domain/service/KeycloakService.java +++ b/src/main/java/fr/titionfire/ffsaf/domain/service/KeycloakService.java @@ -13,6 +13,7 @@ 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; @@ -94,6 +95,21 @@ public class KeycloakService { }); } + 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) diff --git a/src/main/java/fr/titionfire/ffsaf/rest/CompteEndpoints.java b/src/main/java/fr/titionfire/ffsaf/rest/CompteEndpoints.java index 01c3440..299759b 100644 --- a/src/main/java/fr/titionfire/ffsaf/rest/CompteEndpoints.java +++ b/src/main/java/fr/titionfire/ffsaf/rest/CompteEndpoints.java @@ -1,6 +1,7 @@ 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; @@ -9,6 +10,9 @@ 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 { @@ -28,4 +32,30 @@ public class CompteEndpoints { 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/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/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/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..472c38f 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 './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 3373412..bcc3c75 100644 --- a/src/main/webapp/src/pages/admin/MemberPage.jsx +++ b/src/main/webapp/src/pages/admin/MemberPage.jsx @@ -1,5 +1,5 @@ import {useNavigate, useParams} from "react-router-dom"; -import {useLoadingSwitcher} from "../../hooks/useLoading.jsx"; +import {LoadingProvider, useLoadingSwitcher} from "../../hooks/useLoading.jsx"; import {useFetch, useFetchPut} from "../../hooks/useFetch.js"; import {AxiosError} from "../../components/AxiosError.jsx"; import {ClubSelect} from "../../components/ClubSelect.jsx"; @@ -18,63 +18,6 @@ export function MemberPage() { const setLoading = useLoadingSwitcher() const {data, error} = useFetch(`/member/${id}`, setLoading, 1) - const handleSubmit = (event) => { - event.preventDefault(); - setLoading(1) - - const formData = new FormData(); - formData.append("id", data.id); - formData.append("lname", event.target.lname?.value); - formData.append("fname", event.target.fname?.value); - formData.append("categorie", event.target.category?.value); - formData.append("club", event.target.club?.value); - formData.append("genre", event.target.genre?.value); - formData.append("country", event.target.country?.value); - formData.append("birth_date", new Date(event.target.birth_date?.value).toUTCString()); - formData.append("email", event.target.email?.value); - formData.append("role", event.target.role?.value); - formData.append("grade_arbitrage", event.target.grade_arbitrage?.value); - - const send = (formData_) => { - apiAxios.post(`/member/${id}`, formData_, { - headers: { - 'Accept': '*/*', - 'Content-Type': 'multipart/form-data', - } - }).then(data => { - console.log(data.data) // TODO - }).catch(e => { - console.log(e.response) // TODO - }).finally(() => { - if (setLoading) - setLoading(0) - }) - } - - const imageFile = event.target.url_photo.files[0]; - if (imageFile) { - console.log(`originalFile size ${imageFile.size / 1024 / 1024} MB`); - - const options = { - maxSizeMB: 1, - maxWidthOrHeight: 1920, - useWebWorker: true, - } - - imageCompression(imageFile, options).then(compressedFile => { - console.log(`compressedFile size ${compressedFile.size / 1024 / 1024} MB`); // smaller than maxSizeMB - formData.append("photo_data", compressedFile) - send(formData) - }); - } else { - send(formData) - } - } - - const handleSubmitPerm = (event) => { - - } - return <>

    Page membre

    + {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() { @@ -214,12 +266,15 @@ function SelectCard() { function CompteInfo({userData}) { const creatAccount = () => { + let err = {}; toast.promise( - apiAxios.put(`/compte/${userData.id}/init`), + 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 😕' + error: 'Échec de la création du compte 😕 (code: ' + err.response.status + ')' } ) } @@ -229,7 +284,8 @@ function CompteInfo({userData}) {
    {userData.userId ? - : <> + : + <>
    Ce membre ne dispose pas de compte...
    @@ -240,13 +296,16 @@ function CompteInfo({userData}) {
    - } + + }
    } -function CompteInfoContent({userData}) { +function CompteInfoContent({ + userData + }) { const setLoading = useLoadingSwitcher() const {data, error} = useFetch(`/compte/${userData.userId}`, setLoading, 1) From c3ff0dc95e80a8069adfb66053b14b9cdac71bf7 Mon Sep 17 00:00:00 2001 From: Thibaut Valentin Date: Tue, 30 Jan 2024 20:06:57 +0100 Subject: [PATCH 7/7] fix: clean import --- src/main/webapp/src/pages/admin/AdminRoot.jsx | 2 +- src/main/webapp/src/pages/admin/MemberPage.jsx | 9 ++++----- 2 files changed, 5 insertions(+), 6 deletions(-) diff --git a/src/main/webapp/src/pages/admin/AdminRoot.jsx b/src/main/webapp/src/pages/admin/AdminRoot.jsx index 472c38f..804f6fa 100644 --- a/src/main/webapp/src/pages/admin/AdminRoot.jsx +++ b/src/main/webapp/src/pages/admin/AdminRoot.jsx @@ -1,4 +1,4 @@ -import {NavLink, Outlet} from "react-router-dom"; +import {Outlet} from "react-router-dom"; import './AdminRoot.css' import {LoadingProvider} from "../../hooks/useLoading.jsx"; import {MemberList} from "./MemberList.jsx"; diff --git a/src/main/webapp/src/pages/admin/MemberPage.jsx b/src/main/webapp/src/pages/admin/MemberPage.jsx index bcc3c75..c06da70 100644 --- a/src/main/webapp/src/pages/admin/MemberPage.jsx +++ b/src/main/webapp/src/pages/admin/MemberPage.jsx @@ -1,6 +1,6 @@ import {useNavigate, useParams} from "react-router-dom"; import {LoadingProvider, useLoadingSwitcher} from "../../hooks/useLoading.jsx"; -import {useFetch, useFetchPut} from "../../hooks/useFetch.js"; +import {useFetch} from "../../hooks/useFetch.js"; import {AxiosError} from "../../components/AxiosError.jsx"; import {ClubSelect} from "../../components/ClubSelect.jsx"; import {useEffect, useState} from "react"; @@ -84,7 +84,7 @@ function InformationForm({data}) { 'Accept': '*/*', 'Content-Type': 'multipart/form-data', } - }).then(data => { + }).then(_ => { toast.success('Profile mis à jours avec succès 🎉'); }).catch(e => { console.log(e.response) @@ -176,7 +176,7 @@ function PremForm({userData}) { 'Accept': '*/*', 'Content-Type': 'form-data', } - }).then(data => { + }).then(_ => { toast.success('Permission mise à jours avec succès 🎉'); }).catch(e => { console.log(e.response) @@ -342,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'))) }