From 58c2134e357bc7b50a18979d55f498991a177204 Mon Sep 17 00:00:00 2001 From: Thibaut Valentin Date: Sat, 3 Feb 2024 19:05:33 +0100 Subject: [PATCH 1/6] feat: add set userId --- .../ffsaf/domain/service/KeycloakService.java | 22 +++++++-- .../ffsaf/rest/CompteEndpoints.java | 7 +++ .../webapp/src/pages/admin/MemberPage.jsx | 48 ++++++++++++++++++- 3 files changed, 71 insertions(+), 6 deletions(-) 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 22f26c7..bc8a9a4 100644 --- a/src/main/java/fr/titionfire/ffsaf/domain/service/KeycloakService.java +++ b/src/main/java/fr/titionfire/ffsaf/domain/service/KeycloakService.java @@ -104,7 +104,7 @@ public class KeycloakService { 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(); + 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"; @@ -127,9 +127,18 @@ public class KeycloakService { } private Uni creatUser(MembreModel membreModel) { - String login = makeLogin(membreModel); - LOGGER.infof("Creation of user %s...", login); return vertx.getOrCreateContext().executeBlocking(() -> { + String login; + int i = 1; + do { + login = makeLogin(membreModel); + if (i > 1) { + login += i; + } + i++; + } while (!keycloak.realm(realm).users().searchByUsername(login, true).isEmpty()); + LOGGER.infof("Creation of user %s...", login); + UserRepresentation user = new UserRepresentation(); user.setUsername(login); user.setFirstName(membreModel.getFname()); @@ -145,13 +154,18 @@ public class KeycloakService { 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))); + String finalLogin = login; + return getUser(login).orElseThrow(() -> new KeycloakException("Fail to fetch user %s".formatted(finalLogin))); }) .invoke(user -> membreModel.setUserId(user.getId())) .call(user -> membreService.setUserId(membreModel.getId(), user.getId())) .call(user -> setClubGroupMembre(membreModel, membreModel.getClub())); } + public Uni setId(long id, String nid) { + return membreService.setUserId(id, nid).map(__ -> "OK"); + } + private Optional getUser(String username) { List users = keycloak.realm(realm).users().searchByUsername(username, true); diff --git a/src/main/java/fr/titionfire/ffsaf/rest/CompteEndpoints.java b/src/main/java/fr/titionfire/ffsaf/rest/CompteEndpoints.java index 299759b..bc7fd12 100644 --- a/src/main/java/fr/titionfire/ffsaf/rest/CompteEndpoints.java +++ b/src/main/java/fr/titionfire/ffsaf/rest/CompteEndpoints.java @@ -33,6 +33,13 @@ public class CompteEndpoints { return service.initCompte(id); } + @PUT + @Path("{id}/setUUID/{nid}") + @RolesAllowed("federation_admin") + public Uni initCompte(@PathParam("id") long id, @PathParam("nid") String nid) { + return service.setId(id, nid); + } + @GET @Path("{id}/roles") @RolesAllowed("federation_admin") diff --git a/src/main/webapp/src/pages/admin/MemberPage.jsx b/src/main/webapp/src/pages/admin/MemberPage.jsx index c06da70..db5faec 100644 --- a/src/main/webapp/src/pages/admin/MemberPage.jsx +++ b/src/main/webapp/src/pages/admin/MemberPage.jsx @@ -278,16 +278,38 @@ function CompteInfo({userData}) { } ) } + const sendId = (event) => { + event.preventDefault(); + + toast.promise( + apiAxios.put(`/compte/${userData.id}/setUUID/${event.target.uuid?.value}`), + { + pending: "Définition de l'identifient en cours", + success: "Identifient défini avec succès 🎉", + error: "Échec de la définition de l'identifient 😕 " + } + ) + } return
-
Compte
+
+
+ +
    +
  • +
+
+
{userData.userId ? : <>
-
+
Ce membre ne dispose pas de compte...
@@ -299,6 +321,28 @@ function CompteInfo({userData}) { }
+
} From c6130cf65f28f02afe05f78e7f2bea43a3481769 Mon Sep 17 00:00:00 2001 From: Thibaut Valentin Date: Sat, 3 Feb 2024 19:23:37 +0100 Subject: [PATCH 2/6] feat: add data model for licence --- .../ffsaf/data/model/AffiliationModel.java | 26 ++++++++++++++++ .../ffsaf/data/model/ClubModel.java | 4 +++ .../ffsaf/data/model/LicenceModel.java | 30 +++++++++++++++++++ .../ffsaf/data/model/MembreModel.java | 4 +++ .../ffsaf/domain/entity/ClubEntity.java | 6 ---- 5 files changed, 64 insertions(+), 6 deletions(-) create mode 100644 src/main/java/fr/titionfire/ffsaf/data/model/AffiliationModel.java create mode 100644 src/main/java/fr/titionfire/ffsaf/data/model/LicenceModel.java diff --git a/src/main/java/fr/titionfire/ffsaf/data/model/AffiliationModel.java b/src/main/java/fr/titionfire/ffsaf/data/model/AffiliationModel.java new file mode 100644 index 0000000..d3d0dfd --- /dev/null +++ b/src/main/java/fr/titionfire/ffsaf/data/model/AffiliationModel.java @@ -0,0 +1,26 @@ +package fr.titionfire.ffsaf.data.model; + +import io.quarkus.runtime.annotations.RegisterForReflection; +import jakarta.persistence.*; +import lombok.*; + +@Getter +@Setter +@Builder +@AllArgsConstructor +@NoArgsConstructor +@RegisterForReflection + +@Entity +@Table(name = "affiliation") +public class AffiliationModel { + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + Long id; + + @ManyToOne(fetch = FetchType.EAGER) + @JoinColumn(name = "club", referencedColumnName = "id") + ClubModel club; + + int saison; +} 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 546f8f8..791c31a 100644 --- a/src/main/java/fr/titionfire/ffsaf/data/model/ClubModel.java +++ b/src/main/java/fr/titionfire/ffsaf/data/model/ClubModel.java @@ -5,6 +5,7 @@ import io.quarkus.runtime.annotations.RegisterForReflection; import jakarta.persistence.*; import lombok.*; +import java.util.List; import java.util.Map; @Getter @@ -49,4 +50,7 @@ public class ClubModel { String no_affiliation; boolean international; + + @OneToMany(mappedBy = "club", fetch = FetchType.EAGER) + List affiliations; } diff --git a/src/main/java/fr/titionfire/ffsaf/data/model/LicenceModel.java b/src/main/java/fr/titionfire/ffsaf/data/model/LicenceModel.java new file mode 100644 index 0000000..989c25e --- /dev/null +++ b/src/main/java/fr/titionfire/ffsaf/data/model/LicenceModel.java @@ -0,0 +1,30 @@ +package fr.titionfire.ffsaf.data.model; + +import io.quarkus.runtime.annotations.RegisterForReflection; +import jakarta.persistence.*; +import lombok.*; + +@Getter +@Setter +@Builder +@AllArgsConstructor +@NoArgsConstructor +@RegisterForReflection + +@Entity +@Table(name = "licence") +public class LicenceModel { + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + Long id; + + @ManyToOne(fetch = FetchType.EAGER) + @JoinColumn(name = "membre", referencedColumnName = "id") + MembreModel membre; + + int saison; + + boolean certificate; + + boolean validate; +} 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 145b192..819a1c0 100644 --- a/src/main/java/fr/titionfire/ffsaf/data/model/MembreModel.java +++ b/src/main/java/fr/titionfire/ffsaf/data/model/MembreModel.java @@ -9,6 +9,7 @@ import jakarta.persistence.*; import lombok.*; import java.util.Date; +import java.util.List; @Getter @Setter @@ -51,4 +52,7 @@ public class MembreModel { GradeArbitrage grade_arbitrage; String url_photo; + + @OneToMany(mappedBy = "membre", fetch = FetchType.EAGER) + List licences; } 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 9fea3c8..ab59f8b 100644 --- a/src/main/java/fr/titionfire/ffsaf/domain/entity/ClubEntity.java +++ b/src/main/java/fr/titionfire/ffsaf/domain/entity/ClubEntity.java @@ -49,10 +49,4 @@ public class ClubEntity { .international(model.isInternational()) .build(); } - - public ClubModel toModel () { - 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); - } } From 0c9020890aa86e5926e4efea24f508257c11afa6 Mon Sep 17 00:00:00 2001 From: Thibaut Valentin Date: Sun, 4 Feb 2024 13:49:01 +0100 Subject: [PATCH 3/6] feat: add licence admin gestion --- .../ffsaf/data/model/MembreModel.java | 2 +- .../data/repository/LicenceRepository.java | 9 + .../ffsaf/domain/service/LicenceService.java | 52 ++ .../ffsaf/domain/service/MembreService.java | 1 + .../titionfire/ffsaf/rest/CombEndpoints.java | 1 + .../ffsaf/rest/LicenceEndpoints.java | 44 ++ .../ffsaf/rest/data/SimpleLicence.java | 32 ++ .../ffsaf/rest/from/LicenceForm.java | 24 + src/main/webapp/src/pages/admin/AdminRoot.jsx | 2 +- .../webapp/src/pages/admin/MemberPage.jsx | 455 ------------------ .../src/pages/admin/member/CompteInfo.jsx | 120 +++++ .../pages/admin/member/InformationForm.jsx | 106 ++++ .../src/pages/admin/member/LicenceCard.jsx | 196 ++++++++ .../pages/admin/member/MemberCustomFiels.jsx | 80 +++ .../src/pages/admin/member/MemberPage.jsx | 72 +++ .../src/pages/admin/member/PremForm.jsx | 87 ++++ 16 files changed, 826 insertions(+), 457 deletions(-) create mode 100644 src/main/java/fr/titionfire/ffsaf/data/repository/LicenceRepository.java create mode 100644 src/main/java/fr/titionfire/ffsaf/domain/service/LicenceService.java create mode 100644 src/main/java/fr/titionfire/ffsaf/rest/LicenceEndpoints.java create mode 100644 src/main/java/fr/titionfire/ffsaf/rest/data/SimpleLicence.java create mode 100644 src/main/java/fr/titionfire/ffsaf/rest/from/LicenceForm.java delete mode 100644 src/main/webapp/src/pages/admin/MemberPage.jsx create mode 100644 src/main/webapp/src/pages/admin/member/CompteInfo.jsx create mode 100644 src/main/webapp/src/pages/admin/member/InformationForm.jsx create mode 100644 src/main/webapp/src/pages/admin/member/LicenceCard.jsx create mode 100644 src/main/webapp/src/pages/admin/member/MemberCustomFiels.jsx create mode 100644 src/main/webapp/src/pages/admin/member/MemberPage.jsx create mode 100644 src/main/webapp/src/pages/admin/member/PremForm.jsx 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 819a1c0..334c3fc 100644 --- a/src/main/java/fr/titionfire/ffsaf/data/model/MembreModel.java +++ b/src/main/java/fr/titionfire/ffsaf/data/model/MembreModel.java @@ -53,6 +53,6 @@ public class MembreModel { String url_photo; - @OneToMany(mappedBy = "membre", fetch = FetchType.EAGER) + @OneToMany(mappedBy = "membre", fetch = FetchType.LAZY) List licences; } diff --git a/src/main/java/fr/titionfire/ffsaf/data/repository/LicenceRepository.java b/src/main/java/fr/titionfire/ffsaf/data/repository/LicenceRepository.java new file mode 100644 index 0000000..77d9f34 --- /dev/null +++ b/src/main/java/fr/titionfire/ffsaf/data/repository/LicenceRepository.java @@ -0,0 +1,9 @@ +package fr.titionfire.ffsaf.data.repository; + +import fr.titionfire.ffsaf.data.model.LicenceModel; +import io.quarkus.hibernate.reactive.panache.PanacheRepositoryBase; +import jakarta.enterprise.context.ApplicationScoped; + +@ApplicationScoped +public class LicenceRepository implements PanacheRepositoryBase { +} diff --git a/src/main/java/fr/titionfire/ffsaf/domain/service/LicenceService.java b/src/main/java/fr/titionfire/ffsaf/domain/service/LicenceService.java new file mode 100644 index 0000000..b37aa03 --- /dev/null +++ b/src/main/java/fr/titionfire/ffsaf/domain/service/LicenceService.java @@ -0,0 +1,52 @@ +package fr.titionfire.ffsaf.domain.service; + +import fr.titionfire.ffsaf.data.model.LicenceModel; +import fr.titionfire.ffsaf.data.repository.CombRepository; +import fr.titionfire.ffsaf.data.repository.LicenceRepository; +import fr.titionfire.ffsaf.rest.from.LicenceForm; +import io.quarkus.hibernate.reactive.panache.Panache; +import io.quarkus.hibernate.reactive.panache.common.WithSession; +import io.smallrye.mutiny.Uni; +import jakarta.enterprise.context.ApplicationScoped; +import jakarta.inject.Inject; +import org.hibernate.reactive.mutiny.Mutiny; + +import java.util.List; + +@WithSession +@ApplicationScoped +public class LicenceService { + + @Inject + LicenceRepository repository; + + @Inject + CombRepository combRepository; + + public Uni> getLicence(long id) { + return combRepository.findById(id).chain(combRepository -> Mutiny.fetch(combRepository.getLicences())); + } + + public Uni setLicence(long id, LicenceForm form) { + if (form.getId() == -1) { + return combRepository.findById(id).chain(combRepository -> { + LicenceModel model = new LicenceModel(); + model.setMembre(combRepository); + model.setSaison(form.getSaison()); + model.setCertificate(form.isCertificate()); + model.setValidate(form.isValidate()); + return Panache.withTransaction(() -> repository.persist(model)); + }); + } else { + return repository.findById(form.getId()).chain(model -> { + model.setCertificate(form.isCertificate()); + model.setValidate(form.isValidate()); + return Panache.withTransaction(() -> repository.persist(model)); + }); + } + } + + public Uni deleteLicence(long id) { + return Panache.withTransaction(() -> repository.deleteById(id)); + } +} 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 cde7a34..15dfb48 100644 --- a/src/main/java/fr/titionfire/ffsaf/domain/service/MembreService.java +++ b/src/main/java/fr/titionfire/ffsaf/domain/service/MembreService.java @@ -79,4 +79,5 @@ public class MembreService { return Panache.withTransaction(() -> repository.persist(membreModel)); }); } + } diff --git a/src/main/java/fr/titionfire/ffsaf/rest/CombEndpoints.java b/src/main/java/fr/titionfire/ffsaf/rest/CombEndpoints.java index 3030d1f..f1be7f8 100644 --- a/src/main/java/fr/titionfire/ffsaf/rest/CombEndpoints.java +++ b/src/main/java/fr/titionfire/ffsaf/rest/CombEndpoints.java @@ -49,6 +49,7 @@ public class CombEndpoints { return membreService.getById(id).map(SimpleMembre::fromModel); } + @POST @Path("{id}") @RolesAllowed("federation_admin") diff --git a/src/main/java/fr/titionfire/ffsaf/rest/LicenceEndpoints.java b/src/main/java/fr/titionfire/ffsaf/rest/LicenceEndpoints.java new file mode 100644 index 0000000..5bfff5c --- /dev/null +++ b/src/main/java/fr/titionfire/ffsaf/rest/LicenceEndpoints.java @@ -0,0 +1,44 @@ +package fr.titionfire.ffsaf.rest; + +import fr.titionfire.ffsaf.domain.service.LicenceService; +import fr.titionfire.ffsaf.rest.data.SimpleLicence; +import fr.titionfire.ffsaf.rest.from.LicenceForm; +import io.smallrye.mutiny.Uni; +import jakarta.annotation.security.RolesAllowed; +import jakarta.inject.Inject; +import jakarta.ws.rs.*; +import jakarta.ws.rs.core.MediaType; + +import java.util.List; + +@Path("api/licence") +public class LicenceEndpoints { + + @Inject + LicenceService licenceService; + + @GET + @Path("{id}") + @RolesAllowed("federation_admin") + @Produces(MediaType.APPLICATION_JSON) + public Uni> getLicence(@PathParam("id") long id) { + return licenceService.getLicence(id).map(licenceModels -> licenceModels.stream().map(SimpleLicence::fromModel).toList()); + } + + @POST + @Path("{id}") + @RolesAllowed("federation_admin") + @Produces(MediaType.APPLICATION_JSON) + @Consumes(MediaType.MULTIPART_FORM_DATA) + public Uni setLicence(@PathParam("id") long id, LicenceForm form) { + return licenceService.setLicence(id, form).map(SimpleLicence::fromModel); + } + + @DELETE + @Path("{id}") + @RolesAllowed("federation_admin") + @Produces(MediaType.TEXT_PLAIN) + public Uni deleteLicence(@PathParam("id") long id) { + return licenceService.deleteLicence(id); + } +} diff --git a/src/main/java/fr/titionfire/ffsaf/rest/data/SimpleLicence.java b/src/main/java/fr/titionfire/ffsaf/rest/data/SimpleLicence.java new file mode 100644 index 0000000..c9fedce --- /dev/null +++ b/src/main/java/fr/titionfire/ffsaf/rest/data/SimpleLicence.java @@ -0,0 +1,32 @@ +package fr.titionfire.ffsaf.rest.data; + +import fr.titionfire.ffsaf.data.model.LicenceModel; +import io.quarkus.runtime.annotations.RegisterForReflection; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; + +@Data +@Builder +@AllArgsConstructor +@RegisterForReflection +public class SimpleLicence { + Long id; + Long membre; + int saison; + boolean certificate; + boolean validate; + + public static SimpleLicence fromModel(LicenceModel model) { + if (model == null) + return null; + + return new SimpleLicenceBuilder() + .id(model.getId()) + .membre(model.getMembre().getId()) + .saison(model.getSaison()) + .certificate(model.isCertificate()) + .validate(model.isValidate()) + .build(); + } +} diff --git a/src/main/java/fr/titionfire/ffsaf/rest/from/LicenceForm.java b/src/main/java/fr/titionfire/ffsaf/rest/from/LicenceForm.java new file mode 100644 index 0000000..678acc7 --- /dev/null +++ b/src/main/java/fr/titionfire/ffsaf/rest/from/LicenceForm.java @@ -0,0 +1,24 @@ +package fr.titionfire.ffsaf.rest.from; + +import jakarta.ws.rs.FormParam; +import lombok.Getter; +import lombok.ToString; + +@Getter +@ToString +public class LicenceForm { + @FormParam("id") + private long id; + + @FormParam("membre") + private long membre; + + @FormParam("saison") + private int saison; + + @FormParam("certificate") + private boolean certificate; + + @FormParam("validate") + private boolean validate; +} diff --git a/src/main/webapp/src/pages/admin/AdminRoot.jsx b/src/main/webapp/src/pages/admin/AdminRoot.jsx index 804f6fa..623032d 100644 --- a/src/main/webapp/src/pages/admin/AdminRoot.jsx +++ b/src/main/webapp/src/pages/admin/AdminRoot.jsx @@ -2,7 +2,7 @@ import {Outlet} from "react-router-dom"; import './AdminRoot.css' import {LoadingProvider} from "../../hooks/useLoading.jsx"; import {MemberList} from "./MemberList.jsx"; -import {MemberPage} from "./MemberPage.jsx"; +import {MemberPage} from "./member/MemberPage.jsx"; export function AdminRoot() { return <> diff --git a/src/main/webapp/src/pages/admin/MemberPage.jsx b/src/main/webapp/src/pages/admin/MemberPage.jsx deleted file mode 100644 index db5faec..0000000 --- a/src/main/webapp/src/pages/admin/MemberPage.jsx +++ /dev/null @@ -1,455 +0,0 @@ -import {useNavigate, useParams} from "react-router-dom"; -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; - -export function MemberPage() { - const {id} = useParams() - const navigate = useNavigate(); - - 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(); - 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/${data.id}`, formData_, { - headers: { - 'Accept': '*/*', - 'Content-Type': 'multipart/form-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) - }) - } - - 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) - } - } - - return
-
-
Information
-
- - - - - - -
- -
- - -
-
- - -
-
-
-
- -
-
-
-
-
; -} - -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 + ')' - } - ) - } - const sendId = (event) => { - event.preventDefault(); - - toast.promise( - apiAxios.put(`/compte/${userData.id}/setUUID/${event.target.uuid?.value}`), - { - pending: "Définition de l'identifient en cours", - success: "Identifient défini avec succès 🎉", - error: "Échec de la définition de l'identifient 😕 " - } - ) - } - - return
-
-
- -
    -
  • -
-
-
-
- {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}) { - const [date, setDate] = useState(inti_date) - const [category, setCategory] = useState(inti_category) - const [canUpdate, setCanUpdate] = useState(false) - useEffect(() => { - const b = category !== getCategoryFormBirthDate(new Date(date), new Date('2023-09-01')) - if (b !== canUpdate) - setCanUpdate(b) - }, [date, category]) - - const updateCat = _ => { - setCategory(getCategoryFormBirthDate(new Date(date), new Date('2023-09-01'))) - } - - - return <> -
- Date de naissance - setDate(e.target.value)}/> -
-
-
- Catégorie - - {canUpdate && } -
-
- -} - -function OptionField({name, text, values, value}) { - return
-
- - -
-
-} - -function TextField({name, text, value, placeholder, type = "text"}) { - return
-
- {text} - -
-
-} - -function CheckField({name, text, value, row = false}) { - return <>{ - row ? -
-
-
- - -
-
-
- :
- - -
- } - -} \ No newline at end of file diff --git a/src/main/webapp/src/pages/admin/member/CompteInfo.jsx b/src/main/webapp/src/pages/admin/member/CompteInfo.jsx new file mode 100644 index 0000000..7d825ce --- /dev/null +++ b/src/main/webapp/src/pages/admin/member/CompteInfo.jsx @@ -0,0 +1,120 @@ +import {toast} from "react-toastify"; +import {apiAxios} from "../../../utils/Tools.js"; +import {useLoadingSwitcher} from "../../../hooks/useLoading.jsx"; +import {useFetch} from "../../../hooks/useFetch.js"; +import {ColoredCircle} from "../../../components/ColoredCircle.jsx"; +import {AxiosError} from "../../../components/AxiosError.jsx"; + +export 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 + ')' + } + ) + } + const sendId = (event) => { + event.preventDefault(); + + toast.promise( + apiAxios.put(`/compte/${userData.id}/setUUID/${event.target.uuid?.value}`), + { + pending: "Définition de l'identifient en cours", + success: "Identifient défini avec succès 🎉", + error: "Échec de la définition de l'identifient 😕 " + } + ) + } + + return
+
+
+ +
    +
  • + +
  • +
+
+
+
+ {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 && + } +} diff --git a/src/main/webapp/src/pages/admin/member/InformationForm.jsx b/src/main/webapp/src/pages/admin/member/InformationForm.jsx new file mode 100644 index 0000000..e88630a --- /dev/null +++ b/src/main/webapp/src/pages/admin/member/InformationForm.jsx @@ -0,0 +1,106 @@ +import {useLoadingSwitcher} from "../../../hooks/useLoading.jsx"; +import {apiAxios} from "../../../utils/Tools.js"; +import {toast} from "react-toastify"; +import imageCompression from "browser-image-compression"; +import {BirthDayField, OptionField, TextField} from "./MemberCustomFiels.jsx"; +import {ClubSelect} from "../../../components/ClubSelect.jsx"; + +export function InformationForm({data}) { + const setLoading = useLoadingSwitcher() + 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/${data.id}`, formData_, { + headers: { + 'Accept': '*/*', + 'Content-Type': 'multipart/form-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) + }) + } + + 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) + } + } + + return
+
+
Information
+
+ + + + + + +
+ +
+ + +
+
+ + +
+
+
+
+ +
+
+
+
+
; +} \ No newline at end of file diff --git a/src/main/webapp/src/pages/admin/member/LicenceCard.jsx b/src/main/webapp/src/pages/admin/member/LicenceCard.jsx new file mode 100644 index 0000000..1346079 --- /dev/null +++ b/src/main/webapp/src/pages/admin/member/LicenceCard.jsx @@ -0,0 +1,196 @@ +import {useLoadingSwitcher} from "../../../hooks/useLoading.jsx"; +import {useFetch} from "../../../hooks/useFetch.js"; +import {useEffect, useReducer, useState} from "react"; +import {FontAwesomeIcon} from "@fortawesome/react-fontawesome"; +import {faPen} from "@fortawesome/free-solid-svg-icons"; +import {AxiosError} from "../../../components/AxiosError.jsx"; +import {CheckField, TextField} from "./MemberCustomFiels.jsx"; +import {apiAxios, getSaison} from "../../../utils/Tools.js"; +import {Input} from "../../../components/Input.jsx"; +import {toast} from "react-toastify"; + +function licenceReducer(licences, action) { + switch (action.type) { + case 'ADD': + return [ + ...licences, + action.payload + ] + case 'REMOVE': + return licences.filter(licence => licence.id !== action.payload) + case 'UPDATE_OR_ADD': + const index = licences.findIndex(licence => licence.id === action.payload.id) + if (index === -1) { + return [ + ...licences, + action.payload + ] + } else { + licences[index] = action.payload + return [...licences] + } + case 'SORT': + return licences.sort((a, b) => b.saison - a.saison) + default: + throw new Error() + } +} + +export function LicenceCard({userData}) { + const setLoading = useLoadingSwitcher() + const {data, error} = useFetch(`/licence/${userData.id}`, setLoading, 1) + + const [modalLicence, setModal] = useState({id: -1, membre: userData.id}) + const [licences, dispatch] = useReducer(licenceReducer, []) + + useEffect(() => { + if (!data) return + for (const dataKey of data) { + dispatch({type: 'UPDATE_OR_ADD', payload: dataKey}) + } + dispatch({type: 'SORT'}) + }, [data]); + + return
+
+
+
Licence
+
+ +
+
+
+
+
    + {licences.map((licence, index) => { + return
    +
    {licence?.saison}-{licence?.saison + 1}
    + +
    + })} + {error && } +
+
+ + +
; +} + +function sendLicence(event, dispatch) { + event.preventDefault(); + + const formData = new FormData(event.target); + toast.promise( + apiAxios.post(`/licence/${formData.get('membre')}`, formData), + { + pending: "Enregistrement de la licence en cours", + success: "Licence enregistrée avec succès 🎉", + error: "Échec de l'enregistrement de la licence 😕" + } + ).then(data => { + dispatch({type: 'UPDATE_OR_ADD', payload: data.data}) + dispatch({type: 'SORT'}) + }) + +} + +function removeLicence(id, dispatch) { + toast.promise( + apiAxios.delete(`/licence/${id}`), + { + pending: "Suppression de la licence en cours", + success: "Licence supprimée avec succès 🎉", + error: "Échec de la suppression de la licence 😕" + } + ).then(data => { + dispatch({type: 'REMOVE', payload: id}) + }) + console.log(id) +} + +function ModalContent({licence, dispatch}) { + const [saison, setSaison] = useState(0) + const [certificate, setCertificate] = useState(false) + const [validate, setValidate] = useState(false) + const [isNew, setNew] = useState(true) + const setSeason = (event) => { + setSaison(Number(event.target.value)) + } + const handleCertificateChange = (event) => { + setCertificate(event.target.value === 'true'); + } + const handleValidateChange = (event) => { + setValidate(event.target.value === 'true'); + } + + useEffect(() => { + if (licence.id !== -1) { + setNew(false) + setSaison(licence.saison) + setCertificate(licence.certificate) + setValidate(licence.validate) + } else { + setNew(true) + setSaison(getSaison()) + setCertificate(false) + setValidate(false) + } + }, [licence]); + + return
sendLicence(e, dispatch)}> + + +
+

Edition de la licence

+ +
+
+
+ {isNew + ? + : <>{saison} + } + - + {saison + 1} +
+ + +
+
+ + + {isNew || } +
+
+} + +function RadioGroupeOnOff({value, onChange, name, text}) { + return
+ {text} + + + + +
; +} \ No newline at end of file diff --git a/src/main/webapp/src/pages/admin/member/MemberCustomFiels.jsx b/src/main/webapp/src/pages/admin/member/MemberCustomFiels.jsx new file mode 100644 index 0000000..05490bf --- /dev/null +++ b/src/main/webapp/src/pages/admin/member/MemberCustomFiels.jsx @@ -0,0 +1,80 @@ +import {useEffect, useState} from "react"; +import {getCategoryFormBirthDate} from "../../../utils/Tools.js"; + +export function BirthDayField({inti_date, inti_category}) { + const [date, setDate] = useState(inti_date) + const [category, setCategory] = useState(inti_category) + const [canUpdate, setCanUpdate] = useState(false) + useEffect(() => { + const b = category !== getCategoryFormBirthDate(new Date(date), new Date('2023-09-01')) + if (b !== canUpdate) + setCanUpdate(b) + }, [date, category]) + + const updateCat = _ => { + setCategory(getCategoryFormBirthDate(new Date(date), new Date('2023-09-01'))) + } + + + return <> +
+ Date de naissance + setDate(e.target.value)}/> +
+
+
+ Catégorie + + {canUpdate && } +
+
+ +} + +export function OptionField({name, text, values, value}) { + return
+
+ + +
+
+} + +export function TextField({name, text, value, placeholder, type = "text"}) { + return
+
+ {text} + +
+
+} + +export function CheckField({name, text, value, row = false}) { + return <>{ + row ? +
+
+
+ + +
+
+
+ :
+ + +
+ } + +} \ No newline at end of file diff --git a/src/main/webapp/src/pages/admin/member/MemberPage.jsx b/src/main/webapp/src/pages/admin/member/MemberPage.jsx new file mode 100644 index 0000000..77b85d3 --- /dev/null +++ b/src/main/webapp/src/pages/admin/member/MemberPage.jsx @@ -0,0 +1,72 @@ +import {useNavigate, useParams} from "react-router-dom"; +import {LoadingProvider, useLoadingSwitcher} from "../../../hooks/useLoading.jsx"; +import {useFetch} from "../../../hooks/useFetch.js"; +import {AxiosError} from "../../../components/AxiosError.jsx"; +import {CompteInfo} from "./CompteInfo.jsx"; +import {PremForm} from "./PremForm.jsx"; +import {InformationForm} from "./InformationForm.jsx"; +import {LicenceCard} from "./LicenceCard.jsx"; + +const vite_url = import.meta.env.VITE_URL; + +export function MemberPage() { + const {id} = useParams() + const navigate = useNavigate(); + + 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 SelectCard() { + return
+
Sélection en équipe de France
+
+

Web Design

+ +
+
; +} diff --git a/src/main/webapp/src/pages/admin/member/PremForm.jsx b/src/main/webapp/src/pages/admin/member/PremForm.jsx new file mode 100644 index 0000000..8f5ab0d --- /dev/null +++ b/src/main/webapp/src/pages/admin/member/PremForm.jsx @@ -0,0 +1,87 @@ +import {useLoadingSwitcher} from "../../../hooks/useLoading.jsx"; +import {apiAxios} from "../../../utils/Tools.js"; +import {toast} from "react-toastify"; +import {useFetch} from "../../../hooks/useFetch.js"; +import {CheckField} from "./MemberCustomFiels.jsx"; +import {AxiosError} from "../../../components/AxiosError.jsx"; + +export 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 && } +
+ +} \ No newline at end of file From 2a59c22db69543aa2792588289ed57579f884df3 Mon Sep 17 00:00:00 2001 From: Thibaut Valentin Date: Sun, 4 Feb 2024 22:13:21 +0100 Subject: [PATCH 4/6] feat: add club member's gestion page --- .../java/fr/titionfire/ExampleResource.java | 5 + .../ffsaf/data/model/LicenceModel.java | 2 +- .../ffsaf/domain/service/KeycloakService.java | 44 ++++- .../ffsaf/domain/service/LicenceService.java | 38 +++- .../ffsaf/domain/service/MembreService.java | 52 +++++- .../titionfire/ffsaf/rest/ClubEndpoints.java | 4 +- .../titionfire/ffsaf/rest/CombEndpoints.java | 95 +++++++--- .../ffsaf/rest/CompteEndpoints.java | 32 +++- .../ffsaf/rest/LicenceEndpoints.java | 36 +++- .../titionfire/ffsaf/rest/data/UserInfo.java | 12 +- .../ffsaf/rest/from/ClubMemberForm.java | 61 ++++++ .../titionfire/ffsaf/utils/GroupeUtils.java | 25 +++ .../fr/titionfire/ffsaf/utils/RoleAsso.java | 18 +- .../java/fr/titionfire/ffsaf/utils/Utils.java | 22 +++ src/main/resources/application.properties | 2 +- src/main/webapp/src/App.jsx | 6 + .../webapp/src/components/ColoredCircle.jsx | 12 ++ .../MemberCustomFiels.jsx | 6 +- src/main/webapp/src/components/Nav.jsx | 19 ++ .../pages/admin/member/InformationForm.jsx | 2 +- .../src/pages/admin/member/LicenceCard.jsx | 5 +- .../src/pages/admin/member/PremForm.jsx | 4 +- src/main/webapp/src/pages/club/ClubRoot.jsx | 43 +++++ src/main/webapp/src/pages/club/MemberList.jsx | 60 ++++++ .../src/pages/club/member/CompteInfo.jsx | 55 ++++++ .../src/pages/club/member/InformationForm.jsx | 101 ++++++++++ .../src/pages/club/member/LicenceCard.jsx | 176 ++++++++++++++++++ .../src/pages/club/member/MemberPage.jsx | 70 +++++++ 28 files changed, 935 insertions(+), 72 deletions(-) create mode 100644 src/main/java/fr/titionfire/ffsaf/rest/from/ClubMemberForm.java create mode 100644 src/main/java/fr/titionfire/ffsaf/utils/GroupeUtils.java create mode 100644 src/main/java/fr/titionfire/ffsaf/utils/Utils.java rename src/main/webapp/src/{pages/admin/member => components}/MemberCustomFiels.jsx (94%) create mode 100644 src/main/webapp/src/pages/club/ClubRoot.jsx create mode 100644 src/main/webapp/src/pages/club/MemberList.jsx create mode 100644 src/main/webapp/src/pages/club/member/CompteInfo.jsx create mode 100644 src/main/webapp/src/pages/club/member/InformationForm.jsx create mode 100644 src/main/webapp/src/pages/club/member/LicenceCard.jsx create mode 100644 src/main/webapp/src/pages/club/member/MemberPage.jsx diff --git a/src/main/java/fr/titionfire/ExampleResource.java b/src/main/java/fr/titionfire/ExampleResource.java index c7aee61..617736e 100644 --- a/src/main/java/fr/titionfire/ExampleResource.java +++ b/src/main/java/fr/titionfire/ExampleResource.java @@ -79,6 +79,11 @@ public class ExampleResource { response.append("
  • scopes: ").append(this.accessToken.toString()).append("
  • "); } + + if (scopes != null) { + response.append("
  • scopes: ").append(this.accessToken.getClaim("user_groups").toString()).append("
  • "); + } + if (scopes != null) { response.append("
  • getRoles: ").append(this.securityIdentity.getRoles()).append("
  • "); } diff --git a/src/main/java/fr/titionfire/ffsaf/data/model/LicenceModel.java b/src/main/java/fr/titionfire/ffsaf/data/model/LicenceModel.java index 989c25e..ae5d0e7 100644 --- a/src/main/java/fr/titionfire/ffsaf/data/model/LicenceModel.java +++ b/src/main/java/fr/titionfire/ffsaf/data/model/LicenceModel.java @@ -18,7 +18,7 @@ public class LicenceModel { @GeneratedValue(strategy = GenerationType.IDENTITY) Long id; - @ManyToOne(fetch = FetchType.EAGER) + @ManyToOne(fetch = FetchType.LAZY) @JoinColumn(name = "membre", referencedColumnName = "id") MembreModel membre; 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 bc8a9a4..df578b9 100644 --- a/src/main/java/fr/titionfire/ffsaf/domain/service/KeycloakService.java +++ b/src/main/java/fr/titionfire/ffsaf/domain/service/KeycloakService.java @@ -2,8 +2,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 fr.titionfire.ffsaf.utils.*; import io.quarkus.runtime.annotations.RegisterForReflection; import io.smallrye.mutiny.Uni; import io.smallrye.mutiny.unchecked.Unchecked; @@ -21,6 +20,7 @@ import org.keycloak.representations.idm.RoleRepresentation; import org.keycloak.representations.idm.UserRepresentation; import java.text.Normalizer; +import java.util.ArrayList; import java.util.List; import java.util.Optional; @@ -86,13 +86,42 @@ public class KeycloakService { }))); } - public Uni fetchCompte(String id) { + public Uni setEmail(String userId, String email) { + return vertx.getOrCreateContext().executeBlocking(() -> { + UserResource user = keycloak.realm(realm).users().get(userId); + UserRepresentation user2 = user.toRepresentation(); + if (email.equals(user2.getEmail())) + return ""; + user2.setEmail(email); + user2.setRequiredActions(List.of(RequiredAction.VERIFY_EMAIL.name())); + user.update(user2); + return ""; + }); + } + + public Uni setAutoRoleMembre(String id, RoleAsso role, GradeArbitrage gradeArbitrage) { + List toRemove = new ArrayList<>(List.of("club_president", "club_tresorier", "club_secretaire", "asseseur", "arbitre")); + List toAdd = new ArrayList<>(); + + switch (role) { + case PRESIDENT -> toAdd.add("club_president"); + case TRESORIER -> toAdd.add("club_tresorier"); + case SECRETAIRE -> toAdd.add("club_secretaire"); + } + switch (gradeArbitrage) { + case ARBITRE -> toAdd.addAll(List.of("asseseur", "arbitre")); + case ASSESSEUR -> toAdd.add("asseseur"); + } + toRemove.removeAll(toAdd); + + return updateRole(id, toAdd, toRemove); + } + + public Uni> fetchCompte(String id) { return vertx.getOrCreateContext().executeBlocking(() -> { UserResource user = keycloak.realm(realm).users().get(id); UserRepresentation user2 = user.toRepresentation(); - return new UserCompteState(user2.isEnabled(), user2.getUsername(), user2.isEmailVerified(), - user.roles().realmLevel().listEffective().stream().map(RoleRepresentation::getName).toList(), - user.groups().stream().map(GroupRepresentation::getName).toList()); + return new Pair<>(user, new UserCompteState(user2.isEnabled(), user2.getUsername(), user2.isEmailVerified())) ; }); } @@ -182,7 +211,6 @@ public class KeycloakService { } @RegisterForReflection - public record UserCompteState(Boolean enabled, String login, Boolean emailVerified, List realmRoles, - List groups) { + public record UserCompteState(Boolean enabled, String login, Boolean emailVerified) { } } diff --git a/src/main/java/fr/titionfire/ffsaf/domain/service/LicenceService.java b/src/main/java/fr/titionfire/ffsaf/domain/service/LicenceService.java index b37aa03..15c9291 100644 --- a/src/main/java/fr/titionfire/ffsaf/domain/service/LicenceService.java +++ b/src/main/java/fr/titionfire/ffsaf/domain/service/LicenceService.java @@ -1,17 +1,22 @@ package fr.titionfire.ffsaf.domain.service; import fr.titionfire.ffsaf.data.model.LicenceModel; +import fr.titionfire.ffsaf.data.model.MembreModel; import fr.titionfire.ffsaf.data.repository.CombRepository; import fr.titionfire.ffsaf.data.repository.LicenceRepository; import fr.titionfire.ffsaf.rest.from.LicenceForm; +import fr.titionfire.ffsaf.utils.Utils; import io.quarkus.hibernate.reactive.panache.Panache; import io.quarkus.hibernate.reactive.panache.common.WithSession; import io.smallrye.mutiny.Uni; +import io.smallrye.mutiny.unchecked.Unchecked; import jakarta.enterprise.context.ApplicationScoped; import jakarta.inject.Inject; +import jakarta.ws.rs.BadRequestException; import org.hibernate.reactive.mutiny.Mutiny; import java.util.List; +import java.util.function.Consumer; @WithSession @ApplicationScoped @@ -23,8 +28,8 @@ public class LicenceService { @Inject CombRepository combRepository; - public Uni> getLicence(long id) { - return combRepository.findById(id).chain(combRepository -> Mutiny.fetch(combRepository.getLicences())); + public Uni> getLicence(long id, Consumer checkPerm) { + return combRepository.findById(id).invoke(checkPerm).chain(combRepository -> Mutiny.fetch(combRepository.getLicences())); } public Uni setLicence(long id, LicenceForm form) { @@ -49,4 +54,33 @@ public class LicenceService { public Uni deleteLicence(long id) { return Panache.withTransaction(() -> repository.deleteById(id)); } + + public Uni askLicence(long id, LicenceForm form, Consumer checkPerm) { + return combRepository.findById(id).invoke(checkPerm).chain(membreModel -> { + if (form.getId() == -1) { + return repository.find("saison = ?1", Utils.getSaison()).count().invoke(Unchecked.consumer(count -> { + if (count > 0) + throw new BadRequestException(); + })).chain(__ -> combRepository.findById(id).chain(combRepository -> { + LicenceModel model = new LicenceModel(); + model.setMembre(combRepository); + model.setSaison(Utils.getSaison()); + model.setCertificate(form.isCertificate()); + model.setValidate(false); + return Panache.withTransaction(() -> repository.persist(model)); + })); + } else { + return repository.findById(form.getId()).chain(model -> { + model.setCertificate(form.isCertificate()); + return Panache.withTransaction(() -> repository.persist(model)); + }); + } + }); + } + + public Uni deleteAskLicence(long id, Consumer checkPerm) { + return repository.findById(id) + .call(licenceModel -> Mutiny.fetch(licenceModel.getMembre()).invoke(checkPerm)) + .chain(__ -> Panache.withTransaction(() -> repository.deleteById(id))); + } } 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 15dfb48..0ac75b3 100644 --- a/src/main/java/fr/titionfire/ffsaf/domain/service/MembreService.java +++ b/src/main/java/fr/titionfire/ffsaf/domain/service/MembreService.java @@ -6,15 +6,21 @@ 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.ClubMemberForm; import fr.titionfire.ffsaf.rest.from.FullMemberForm; +import fr.titionfire.ffsaf.utils.GroupeUtils; import fr.titionfire.ffsaf.utils.Pair; +import fr.titionfire.ffsaf.utils.RoleAsso; import io.quarkus.hibernate.reactive.panache.Panache; import io.quarkus.hibernate.reactive.panache.common.WithSession; import io.quarkus.panache.common.Sort; import io.quarkus.vertx.VertxContextSupport; import io.smallrye.mutiny.Uni; +import io.smallrye.mutiny.unchecked.Unchecked; import jakarta.enterprise.context.ApplicationScoped; import jakarta.inject.Inject; +import jakarta.ws.rs.ForbiddenException; +import org.eclipse.microprofile.jwt.JsonWebToken; import java.util.List; @@ -46,6 +52,11 @@ public class MembreService { return repository.listAll(Sort.ascending("fname", "lname")); } + public Uni> getInClub(String subject) { + return repository.find("userId = ?1", subject).firstResult() + .chain(membreModel -> repository.find("club = ?1", membreModel.getClub()).list()); + } + public Uni getById(long id) { return repository.findById(id); } @@ -70,6 +81,46 @@ public class MembreService { .invoke(membreModel -> SReqComb.sendIfNeed(serverCustom.clients, SimpleCombModel.fromModel(membreModel))) .call(membreModel -> (membreModel.getUserId() != null) ? keycloakService.setClubGroupMembre(membreModel, membreModel.getClub()) : Uni.createFrom().nullItem()) + .call(membreModel -> (membreModel.getUserId() != null) ? + keycloakService.setAutoRoleMembre(membreModel.getUserId(), membreModel.getRole(), + membreModel.getGrade_arbitrage()) : Uni.createFrom().nullItem()) + .call(membreModel -> (membreModel.getUserId() != null) ? + keycloakService.setEmail(membreModel.getUserId(), membreModel.getEmail()) : Uni.createFrom().nullItem()) + .map(__ -> "OK"); + } + + public Uni update(long id, ClubMemberForm membre, JsonWebToken idToken) { + return repository.findById(id) + .invoke(Unchecked.consumer(membreModel -> { + if (!GroupeUtils.isInClubGroup(membreModel.getClub().getId(), idToken)) + throw new ForbiddenException(); + })) + .invoke(Unchecked.consumer(membreModel -> { + RoleAsso source = RoleAsso.MEMBRE; + if (idToken.getGroups().contains("club_president")) source = RoleAsso.PRESIDENT; + else if (idToken.getGroups().contains("club_secretaire")) source = RoleAsso.SECRETAIRE; + else if (idToken.getGroups().contains("club_respo_intra")) source = RoleAsso.SECRETAIRE; + if (!membre.getRole().equals(membreModel.getRole()) && membre.getRole().level > source.level) + throw new ForbiddenException(); + })) + .onItem().transformToUni(target -> { + target.setFname(membre.getFname()); + target.setLname(membre.getLname()); + target.setCountry(membre.getCountry()); + target.setBirth_date(membre.getBirth_date()); + target.setGenre(membre.getGenre()); + target.setCategorie(membre.getCategorie()); + target.setEmail(membre.getEmail()); + if (!idToken.getSubject().equals(target.getUserId())) + target.setRole(membre.getRole()); + return Panache.withTransaction(() -> repository.persist(target)); + }) + .invoke(membreModel -> SReqComb.sendIfNeed(serverCustom.clients, SimpleCombModel.fromModel(membreModel))) + .call(membreModel -> (membreModel.getUserId() != null) ? + keycloakService.setAutoRoleMembre(membreModel.getUserId(), membreModel.getRole(), + membreModel.getGrade_arbitrage()) : Uni.createFrom().nullItem()) + .call(membreModel -> (membreModel.getUserId() != null) ? + keycloakService.setEmail(membreModel.getUserId(), membreModel.getEmail()) : Uni.createFrom().nullItem()) .map(__ -> "OK"); } @@ -79,5 +130,4 @@ public class MembreService { return Panache.withTransaction(() -> repository.persist(membreModel)); }); } - } diff --git a/src/main/java/fr/titionfire/ffsaf/rest/ClubEndpoints.java b/src/main/java/fr/titionfire/ffsaf/rest/ClubEndpoints.java index 966f954..20aee87 100644 --- a/src/main/java/fr/titionfire/ffsaf/rest/ClubEndpoints.java +++ b/src/main/java/fr/titionfire/ffsaf/rest/ClubEndpoints.java @@ -2,8 +2,8 @@ package fr.titionfire.ffsaf.rest; import fr.titionfire.ffsaf.domain.service.ClubService; import fr.titionfire.ffsaf.net2.data.SimpleClubModel; +import io.quarkus.security.Authenticated; import io.smallrye.mutiny.Uni; -import jakarta.annotation.security.RolesAllowed; import jakarta.inject.Inject; import jakarta.ws.rs.GET; import jakarta.ws.rs.Path; @@ -20,7 +20,7 @@ public class ClubEndpoints { @GET @Path("/no_detail") - @RolesAllowed("federation_admin") + @Authenticated @Produces(MediaType.APPLICATION_JSON) public Uni> getAll() { return clubService.getAll().map(clubModels -> clubModels.stream().map(SimpleClubModel::fromModel).toList()); diff --git a/src/main/java/fr/titionfire/ffsaf/rest/CombEndpoints.java b/src/main/java/fr/titionfire/ffsaf/rest/CombEndpoints.java index f1be7f8..b27dd28 100644 --- a/src/main/java/fr/titionfire/ffsaf/rest/CombEndpoints.java +++ b/src/main/java/fr/titionfire/ffsaf/rest/CombEndpoints.java @@ -1,9 +1,14 @@ package fr.titionfire.ffsaf.rest; +import fr.titionfire.ffsaf.data.model.MembreModel; import fr.titionfire.ffsaf.domain.service.MembreService; import fr.titionfire.ffsaf.rest.data.SimpleMembre; +import fr.titionfire.ffsaf.rest.from.ClubMemberForm; import fr.titionfire.ffsaf.rest.from.FullMemberForm; +import fr.titionfire.ffsaf.utils.GroupeUtils; import fr.titionfire.ffsaf.utils.Pair; +import io.quarkus.oidc.IdToken; +import io.quarkus.security.Authenticated; import io.smallrye.mutiny.Uni; import io.smallrye.mutiny.unchecked.Unchecked; import jakarta.annotation.security.RolesAllowed; @@ -14,6 +19,7 @@ import jakarta.ws.rs.core.MediaType; import jakarta.ws.rs.core.Response; import jodd.net.MimeTypes; import org.eclipse.microprofile.config.inject.ConfigProperty; +import org.eclipse.microprofile.jwt.JsonWebToken; import java.io.*; import java.net.URI; @@ -23,7 +29,9 @@ import java.nio.file.Files; import java.util.List; import java.util.concurrent.CompletableFuture; import java.util.concurrent.Future; +import java.util.function.Consumer; +@Authenticated @Path("api/member") public class CombEndpoints { @@ -33,6 +41,15 @@ public class CombEndpoints { @ConfigProperty(name = "upload_dir") String media; + @Inject + @IdToken + JsonWebToken idToken; + + Consumer checkPerm = Unchecked.consumer(membreModel -> { + if (!idToken.getGroups().contains("federation_admin") && !GroupeUtils.isInClubGroup(membreModel.getClub().getId(), idToken)) + throw new ForbiddenException(); + }); + @GET @Path("/all") @RolesAllowed("federation_admin") @@ -42,22 +59,62 @@ public class CombEndpoints { } @GET - @Path("{id}") - @RolesAllowed("federation_admin") + @Path("/club") + @RolesAllowed({"club_president", "club_secretaire", "club_respo_intra"}) @Produces(MediaType.APPLICATION_JSON) - public Uni getById(@PathParam("id") long id) { - return membreService.getById(id).map(SimpleMembre::fromModel); + public Uni> getClub() { + return membreService.getInClub(idToken.getSubject()).map(membreModels -> membreModels.stream().map(SimpleMembre::fromModel).toList()); } + @GET + @Path("{id}") + @RolesAllowed({"federation_admin", "club_president", "club_secretaire", "club_respo_intra"}) + @Produces(MediaType.APPLICATION_JSON) + public Uni getById(@PathParam("id") long id) { + return membreService.getById(id).onItem().invoke(checkPerm).map(SimpleMembre::fromModel); + } @POST @Path("{id}") - @RolesAllowed("federation_admin") + @RolesAllowed({"federation_admin"}) @Produces(MediaType.TEXT_PLAIN) @Consumes(MediaType.MULTIPART_FORM_DATA) public Uni setAdminMembre(@PathParam("id") long id, FullMemberForm input) { - Future future = CompletableFuture.supplyAsync(() -> { - try (InputStream is = new BufferedInputStream(new ByteArrayInputStream(input.getPhoto_data()))) { + return membreService.update(id, input) + .invoke(Unchecked.consumer(out -> { + if (!out.equals("OK")) throw new InternalError("Fail to update data: " + out); + })).chain(() -> { + if (input.getPhoto_data().length > 0) + return Uni.createFrom().future(replacePhoto(id, input.getPhoto_data())).invoke(Unchecked.consumer(out -> { + if (!out.equals("OK")) throw new InternalError("Fail to get MimeType " + out); + })); + else + return Uni.createFrom().nullItem(); + }); + } + + @POST + @Path("club/{id}") + @RolesAllowed({"club_president", "club_secretaire", "club_respo_intra"}) + @Produces(MediaType.TEXT_PLAIN) + @Consumes(MediaType.MULTIPART_FORM_DATA) + public Uni setMembre(@PathParam("id") long id, ClubMemberForm input) { + return membreService.update(id, input, idToken) + .invoke(Unchecked.consumer(out -> { + if (!out.equals("OK")) throw new InternalError("Fail to update data: " + out); + })).chain(() -> { + if (input.getPhoto_data().length > 0) + return Uni.createFrom().future(replacePhoto(id, input.getPhoto_data())).invoke(Unchecked.consumer(out -> { + if (!out.equals("OK")) throw new InternalError("Fail to get MimeType " + out); + })); + else + return Uni.createFrom().nullItem(); + }); + } + + private Future replacePhoto(long id, byte[] input) { + return CompletableFuture.supplyAsync(() -> { + try (InputStream is = new BufferedInputStream(new ByteArrayInputStream(input))) { String mimeType = URLConnection.guessContentTypeFromStream(is); String[] detectedExtensions = MimeTypes.findExtensionsByMimeTypes(mimeType, false); if (detectedExtensions.length == 0) @@ -73,33 +130,17 @@ public class CombEndpoints { } String extension = "." + detectedExtensions[0]; - Files.write(new File(media, "ppMembre/" + input.getId() + extension).toPath(), input.getPhoto_data()); + Files.write(new File(media, "ppMembre/" + id + extension).toPath(), input); return "OK"; } catch (IOException e) { return e.getMessage(); } }); - - if (input.getPhoto_data().length > 0) { - return membreService.update(id, input) - .invoke(Unchecked.consumer(out -> { - if (!out.equals("OK")) throw new InternalError("Fail to update data: " + out); - })) - .chain(() -> Uni.createFrom().future(future)) - .invoke(Unchecked.consumer(out -> { - if (!out.equals("OK")) throw new InternalError("Fail to get MimeType " + out); - })); - } else { - return membreService.update(id, input) - .invoke(Unchecked.consumer(out -> { - if (!out.equals("OK")) throw new InternalError("Fail to update data: " + out); - })); - } } @GET @Path("{id}/photo") - @RolesAllowed("federation_admin") + @RolesAllowed({"federation_admin", "club_president", "club_secretaire", "club_respo_intra"}) public Uni getPhoto(@PathParam("id") long id) throws URISyntaxException { Future> future = CompletableFuture.supplyAsync(() -> { FilenameFilter filter = (directory, filename) -> filename.startsWith(String.valueOf(id)); @@ -117,7 +158,7 @@ public class CombEndpoints { URI uri = new URI("https://mdbcdn.b-cdn.net/img/Photos/new-templates/bootstrap-chat/ava2.webp"); - return Uni.createFrom().future(future) + return membreService.getById(id).onItem().invoke(checkPerm).chain(__ -> Uni.createFrom().future(future) .map(filePair -> { if (filePair == null) return Response.temporaryRedirect(uri).build(); @@ -131,7 +172,7 @@ public class CombEndpoints { resp.header(HttpHeaders.CONTENT_DISPOSITION, "inline; "); return resp.build(); - }); + })); } } diff --git a/src/main/java/fr/titionfire/ffsaf/rest/CompteEndpoints.java b/src/main/java/fr/titionfire/ffsaf/rest/CompteEndpoints.java index bc7fd12..33dc88a 100644 --- a/src/main/java/fr/titionfire/ffsaf/rest/CompteEndpoints.java +++ b/src/main/java/fr/titionfire/ffsaf/rest/CompteEndpoints.java @@ -2,13 +2,16 @@ package fr.titionfire.ffsaf.rest; import fr.titionfire.ffsaf.domain.service.KeycloakService; import fr.titionfire.ffsaf.rest.from.MemberPermForm; +import fr.titionfire.ffsaf.utils.GroupeUtils; +import fr.titionfire.ffsaf.utils.Pair; +import io.quarkus.oidc.IdToken; import io.smallrye.mutiny.Uni; +import io.vertx.mutiny.core.Vertx; 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 jakarta.ws.rs.*; +import org.eclipse.microprofile.jwt.JsonWebToken; +import org.keycloak.representations.idm.GroupRepresentation; import java.util.ArrayList; import java.util.List; @@ -19,11 +22,26 @@ public class CompteEndpoints { @Inject KeycloakService service; + @Inject + JsonWebToken accessToken; + + @Inject + @IdToken + JsonWebToken idToken; + + @Inject + Vertx vertx; + @GET @Path("{id}") - @RolesAllowed("federation_admin") - public Uni getCompte(@PathParam("id") String id) { - return service.fetchCompte(id); + @RolesAllowed({"federation_admin", "club_president", "club_secretaire", "club_respo_intra"}) + public Uni getCompte(@PathParam("id") String id) { + return service.fetchCompte(id).call(pair -> vertx.getOrCreateContext().executeBlocking(() -> { + if (!idToken.getGroups().contains("federation_admin") && !pair.getKey().groups().stream().map(GroupRepresentation::getPath) + .anyMatch(s -> s.startsWith("/club/") && GroupeUtils.contains(s, accessToken))) + throw new ForbiddenException(); + return pair; + })).map(Pair::getValue); } @PUT diff --git a/src/main/java/fr/titionfire/ffsaf/rest/LicenceEndpoints.java b/src/main/java/fr/titionfire/ffsaf/rest/LicenceEndpoints.java index 5bfff5c..7583287 100644 --- a/src/main/java/fr/titionfire/ffsaf/rest/LicenceEndpoints.java +++ b/src/main/java/fr/titionfire/ffsaf/rest/LicenceEndpoints.java @@ -1,15 +1,21 @@ package fr.titionfire.ffsaf.rest; +import fr.titionfire.ffsaf.data.model.MembreModel; import fr.titionfire.ffsaf.domain.service.LicenceService; import fr.titionfire.ffsaf.rest.data.SimpleLicence; import fr.titionfire.ffsaf.rest.from.LicenceForm; +import fr.titionfire.ffsaf.utils.GroupeUtils; +import io.quarkus.oidc.IdToken; import io.smallrye.mutiny.Uni; +import io.smallrye.mutiny.unchecked.Unchecked; import jakarta.annotation.security.RolesAllowed; import jakarta.inject.Inject; import jakarta.ws.rs.*; import jakarta.ws.rs.core.MediaType; +import org.eclipse.microprofile.jwt.JsonWebToken; import java.util.List; +import java.util.function.Consumer; @Path("api/licence") public class LicenceEndpoints { @@ -17,12 +23,21 @@ public class LicenceEndpoints { @Inject LicenceService licenceService; + @Inject + @IdToken + JsonWebToken idToken; + + Consumer checkPerm = Unchecked.consumer(membreModel -> { + if (!idToken.getGroups().contains("federation_admin") && !GroupeUtils.isInClubGroup(membreModel.getClub().getId(), idToken)) + throw new ForbiddenException(); + }); + @GET @Path("{id}") - @RolesAllowed("federation_admin") + @RolesAllowed({"federation_admin", "club_president", "club_secretaire", "club_respo_intra"}) @Produces(MediaType.APPLICATION_JSON) public Uni> getLicence(@PathParam("id") long id) { - return licenceService.getLicence(id).map(licenceModels -> licenceModels.stream().map(SimpleLicence::fromModel).toList()); + return licenceService.getLicence(id, checkPerm).map(licenceModels -> licenceModels.stream().map(SimpleLicence::fromModel).toList()); } @POST @@ -41,4 +56,21 @@ public class LicenceEndpoints { public Uni deleteLicence(@PathParam("id") long id) { return licenceService.deleteLicence(id); } + + @POST + @Path("club/{id}") + @RolesAllowed({"club_president", "club_secretaire", "club_respo_intra"}) + @Produces(MediaType.APPLICATION_JSON) + @Consumes(MediaType.MULTIPART_FORM_DATA) + public Uni askLicence(@PathParam("id") long id, LicenceForm form) { + return licenceService.askLicence(id, form, checkPerm).map(SimpleLicence::fromModel); + } + + @DELETE + @Path("club/{id}") + @RolesAllowed({"club_president", "club_secretaire", "club_respo_intra"}) + @Produces(MediaType.TEXT_PLAIN) + public Uni deleteAskLicence(@PathParam("id") long id) { + return licenceService.deleteAskLicence(id, checkPerm); + } } diff --git a/src/main/java/fr/titionfire/ffsaf/rest/data/UserInfo.java b/src/main/java/fr/titionfire/ffsaf/rest/data/UserInfo.java index 0c102f2..cc8dc72 100644 --- a/src/main/java/fr/titionfire/ffsaf/rest/data/UserInfo.java +++ b/src/main/java/fr/titionfire/ffsaf/rest/data/UserInfo.java @@ -6,6 +6,8 @@ import lombok.Builder; import lombok.Data; import org.eclipse.microprofile.jwt.JsonWebToken; +import java.util.ArrayList; +import java.util.List; import java.util.Set; @Data @@ -19,7 +21,7 @@ public class UserInfo { String email; boolean emailVerified; long expiration; - Set groups; + List groups; Set roles; public static UserInfo makeUserInfo(JsonWebToken accessToken, SecurityIdentity securityIdentity) { @@ -31,7 +33,13 @@ public class UserInfo { builder.email(accessToken.getClaim("email")); builder.emailVerified(accessToken.getClaim("email_verified")); builder.expiration(accessToken.getExpirationTime()); - builder.groups(accessToken.getGroups()); + List groups = new ArrayList<>(); + if (accessToken.getClaim("user_groups") instanceof Iterable) { + for (Object str : (Iterable) accessToken.getClaim("user_groups")) { + groups.add(str.toString().substring(1, str.toString().length() - 1)); + } + } + builder.groups(groups); builder.roles(securityIdentity.getRoles()); return builder.build(); } diff --git a/src/main/java/fr/titionfire/ffsaf/rest/from/ClubMemberForm.java b/src/main/java/fr/titionfire/ffsaf/rest/from/ClubMemberForm.java new file mode 100644 index 0000000..ac752d3 --- /dev/null +++ b/src/main/java/fr/titionfire/ffsaf/rest/from/ClubMemberForm.java @@ -0,0 +1,61 @@ +package fr.titionfire.ffsaf.rest.from; + +import fr.titionfire.ffsaf.utils.Categorie; +import fr.titionfire.ffsaf.utils.Genre; +import fr.titionfire.ffsaf.utils.RoleAsso; +import jakarta.ws.rs.FormParam; +import jakarta.ws.rs.core.MediaType; +import lombok.Getter; +import org.jboss.resteasy.reactive.PartType; + +import java.util.Date; + +@Getter +public class ClubMemberForm { + @FormParam("id") + private String id = null; + + @FormParam("lname") + private String lname = null; + + @FormParam("fname") + private String fname = null; + + @FormParam("categorie") + private Categorie categorie = null; + + @FormParam("genre") + private Genre genre; + + @FormParam("country") + private String country; + + @FormParam("birth_date") + private Date birth_date; + + @FormParam("email") + private String email; + + @FormParam("role") + private RoleAsso role; + + @FormParam("photo_data") + @PartType(MediaType.APPLICATION_OCTET_STREAM) + private byte[] photo_data = new byte[0]; + + @Override + public String toString() { + return "ClubMemberForm{" + + "id='" + id + '\'' + + ", lname='" + lname + '\'' + + ", fname='" + fname + '\'' + + ", categorie=" + categorie + + ", genre=" + genre + + ", country='" + country + '\'' + + ", birth_date=" + birth_date + + ", email='" + email + '\'' + + ", role=" + role + + ", url_photo=" + photo_data.length + + '}'; + } +} diff --git a/src/main/java/fr/titionfire/ffsaf/utils/GroupeUtils.java b/src/main/java/fr/titionfire/ffsaf/utils/GroupeUtils.java new file mode 100644 index 0000000..3910ea9 --- /dev/null +++ b/src/main/java/fr/titionfire/ffsaf/utils/GroupeUtils.java @@ -0,0 +1,25 @@ +package fr.titionfire.ffsaf.utils; + +import org.eclipse.microprofile.jwt.JsonWebToken; + +public class GroupeUtils { + public static boolean isInClubGroup(long id, JsonWebToken accessToken) { + if (accessToken.getClaim("user_groups") instanceof Iterable) { + for (Object str : (Iterable) accessToken.getClaim("user_groups")) { + if (str.toString().substring(1, str.toString().length() - 1).startsWith("/club/" + id + "-")) + return true; + } + } + return false; + } + + public static boolean contains(String string, JsonWebToken accessToken) { + if (accessToken.getClaim("user_groups") instanceof Iterable) { + for (Object str : (Iterable) accessToken.getClaim("user_groups")) { + if (str.toString().substring(1, str.toString().length() - 1).contains(string)) + return true; + } + } + return false; + } +} diff --git a/src/main/java/fr/titionfire/ffsaf/utils/RoleAsso.java b/src/main/java/fr/titionfire/ffsaf/utils/RoleAsso.java index 1051a16..84991b1 100644 --- a/src/main/java/fr/titionfire/ffsaf/utils/RoleAsso.java +++ b/src/main/java/fr/titionfire/ffsaf/utils/RoleAsso.java @@ -4,19 +4,17 @@ import io.quarkus.runtime.annotations.RegisterForReflection; @RegisterForReflection public enum RoleAsso { - MEMBRE("Membre"), - PRESIDENT("Président"), - TRESORIER("Trésorier"), - SECRETAIRE("Secrétaire"); + MEMBRE("Membre", 0), + PRESIDENT("Président", 3), + TRESORIER("Trésorier", 1), + SECRETAIRE("Secrétaire", 2); - public String name; + public final String name; + public final int level; - RoleAsso(String name) { - this.name = name; - } - - public void setName(String name) { + RoleAsso(String name, int level) { this.name = name; + this.level = level; } @Override diff --git a/src/main/java/fr/titionfire/ffsaf/utils/Utils.java b/src/main/java/fr/titionfire/ffsaf/utils/Utils.java new file mode 100644 index 0000000..1a33bb2 --- /dev/null +++ b/src/main/java/fr/titionfire/ffsaf/utils/Utils.java @@ -0,0 +1,22 @@ +package fr.titionfire.ffsaf.utils; + +import java.util.Calendar; +import java.util.Date; + +public class Utils { + + public static int getSaison() { + return getSaison(new Date()); + } + + public static int getSaison(Date date) { + Calendar calendar = Calendar.getInstance(); + calendar.setTime(date); + + if (calendar.get(Calendar.MONTH) >= Calendar.SEPTEMBER) { + return calendar.get(Calendar.YEAR); + } else { + return calendar.get(Calendar.YEAR) - 1; + } + } +} diff --git a/src/main/resources/application.properties b/src/main/resources/application.properties index 053cf03..69feeed 100644 --- a/src/main/resources/application.properties +++ b/src/main/resources/application.properties @@ -40,7 +40,7 @@ quarkus.oidc.token.refresh-expired=true quarkus.oidc.authentication.redirect-path=/api/auth/login quarkus.oidc.logout.path=/api/logout -quarkus.oidc.logout.post-logout-path=/index.html +quarkus.oidc.logout.post-logout-path=/ # Only the authenticated users can initiate a logout: quarkus.http.auth.permission.authenticated.paths=api/logout,api/auth/login diff --git a/src/main/webapp/src/App.jsx b/src/main/webapp/src/App.jsx index 04b7a97..9c0ff8d 100644 --- a/src/main/webapp/src/App.jsx +++ b/src/main/webapp/src/App.jsx @@ -10,6 +10,7 @@ import {ToastContainer} from "react-toastify"; import './App.css' import 'react-toastify/dist/ReactToastify.css'; +import {ClubRoot, getClubChildren} from "./pages/club/ClubRoot.jsx"; const router = createBrowserRouter([ { @@ -25,6 +26,11 @@ const router = createBrowserRouter([ path: 'admin', element: , children: getAdminChildren() + }, + { + path: 'club', + element: , + children: getClubChildren() } ] }, diff --git a/src/main/webapp/src/components/ColoredCircle.jsx b/src/main/webapp/src/components/ColoredCircle.jsx index 2f9d574..55fab61 100644 --- a/src/main/webapp/src/components/ColoredCircle.jsx +++ b/src/main/webapp/src/components/ColoredCircle.jsx @@ -13,4 +13,16 @@ export const ColoredCircle = ({color, boolean}) => { return +}; + +export const ColoredText = ({boolean, text={true: "Oui", false: "Non"}}) => { + const styles = {color: '#F00'}; + + if (boolean !== undefined) { + styles.color = (boolean) ? '#00c700' : '#e50000'; + } + + return + {text[boolean]} + }; \ No newline at end of file diff --git a/src/main/webapp/src/pages/admin/member/MemberCustomFiels.jsx b/src/main/webapp/src/components/MemberCustomFiels.jsx similarity index 94% rename from src/main/webapp/src/pages/admin/member/MemberCustomFiels.jsx rename to src/main/webapp/src/components/MemberCustomFiels.jsx index 05490bf..1b7eb64 100644 --- a/src/main/webapp/src/pages/admin/member/MemberCustomFiels.jsx +++ b/src/main/webapp/src/components/MemberCustomFiels.jsx @@ -1,5 +1,5 @@ import {useEffect, useState} from "react"; -import {getCategoryFormBirthDate} from "../../../utils/Tools.js"; +import {getCategoryFormBirthDate} from "../utils/Tools.js"; export function BirthDayField({inti_date, inti_category}) { const [date, setDate] = useState(inti_date) @@ -36,11 +36,11 @@ export function BirthDayField({inti_date, inti_category}) { } -export function OptionField({name, text, values, value}) { +export function OptionField({name, text, values, value, disabled=false}) { return
    - {Object.keys(values).map((key, _) => { return () })} diff --git a/src/main/webapp/src/components/Nav.jsx b/src/main/webapp/src/components/Nav.jsx index 3b95604..886644d 100644 --- a/src/main/webapp/src/components/Nav.jsx +++ b/src/main/webapp/src/components/Nav.jsx @@ -22,6 +22,7 @@ export function Nav() {
    • Accueil
    • +
    @@ -31,6 +32,24 @@ export function Nav() { } +function ClubMenu() { + const {is_authenticated, userinfo} = useAuth() + + if (!is_authenticated || !(userinfo?.roles?.includes("club_president") + || userinfo?.roles?.includes("club_secretaire") || userinfo?.roles?.includes("club_tresorier"))) + return <> + + return
  • + +
      +
    • Member
    • +
    • B
    • +
    +
  • +} + function AdminMenu() { const {is_authenticated, userinfo} = useAuth() diff --git a/src/main/webapp/src/pages/admin/member/InformationForm.jsx b/src/main/webapp/src/pages/admin/member/InformationForm.jsx index e88630a..265decc 100644 --- a/src/main/webapp/src/pages/admin/member/InformationForm.jsx +++ b/src/main/webapp/src/pages/admin/member/InformationForm.jsx @@ -2,7 +2,7 @@ import {useLoadingSwitcher} from "../../../hooks/useLoading.jsx"; import {apiAxios} from "../../../utils/Tools.js"; import {toast} from "react-toastify"; import imageCompression from "browser-image-compression"; -import {BirthDayField, OptionField, TextField} from "./MemberCustomFiels.jsx"; +import {BirthDayField, OptionField, TextField} from "../../../components/MemberCustomFiels.jsx"; import {ClubSelect} from "../../../components/ClubSelect.jsx"; export function InformationForm({data}) { diff --git a/src/main/webapp/src/pages/admin/member/LicenceCard.jsx b/src/main/webapp/src/pages/admin/member/LicenceCard.jsx index 1346079..fd11ec3 100644 --- a/src/main/webapp/src/pages/admin/member/LicenceCard.jsx +++ b/src/main/webapp/src/pages/admin/member/LicenceCard.jsx @@ -4,7 +4,7 @@ import {useEffect, useReducer, useState} from "react"; import {FontAwesomeIcon} from "@fortawesome/react-fontawesome"; import {faPen} from "@fortawesome/free-solid-svg-icons"; import {AxiosError} from "../../../components/AxiosError.jsx"; -import {CheckField, TextField} from "./MemberCustomFiels.jsx"; +import {CheckField, TextField} from "../../../components/MemberCustomFiels.jsx"; import {apiAxios, getSaison} from "../../../utils/Tools.js"; import {Input} from "../../../components/Input.jsx"; import {toast} from "react-toastify"; @@ -115,10 +115,9 @@ function removeLicence(id, dispatch) { success: "Licence supprimée avec succès 🎉", error: "Échec de la suppression de la licence 😕" } - ).then(data => { + ).then(_ => { dispatch({type: 'REMOVE', payload: id}) }) - console.log(id) } function ModalContent({licence, dispatch}) { diff --git a/src/main/webapp/src/pages/admin/member/PremForm.jsx b/src/main/webapp/src/pages/admin/member/PremForm.jsx index 8f5ab0d..59525e9 100644 --- a/src/main/webapp/src/pages/admin/member/PremForm.jsx +++ b/src/main/webapp/src/pages/admin/member/PremForm.jsx @@ -2,7 +2,7 @@ import {useLoadingSwitcher} from "../../../hooks/useLoading.jsx"; import {apiAxios} from "../../../utils/Tools.js"; import {toast} from "react-toastify"; import {useFetch} from "../../../hooks/useFetch.js"; -import {CheckField} from "./MemberCustomFiels.jsx"; +import {CheckField} from "../../../components/MemberCustomFiels.jsx"; import {AxiosError} from "../../../components/AxiosError.jsx"; export function PremForm({userData}) { @@ -66,7 +66,7 @@ function PremFormContent({userData}) {
    FFSAF intra
    {data ? <> - : error && } diff --git a/src/main/webapp/src/pages/club/ClubRoot.jsx b/src/main/webapp/src/pages/club/ClubRoot.jsx new file mode 100644 index 0000000..5c92a4a --- /dev/null +++ b/src/main/webapp/src/pages/club/ClubRoot.jsx @@ -0,0 +1,43 @@ +import {Outlet} from "react-router-dom"; +import {LoadingProvider} from "../../hooks/useLoading.jsx"; +import {MemberList} from "./MemberList.jsx"; +import {MemberPage} from "./member/MemberPage.jsx"; +import {useAuth} from "../../hooks/useAuth.jsx"; + +export function ClubRoot() { + const {userinfo} = useAuth() + let club = "" + if (userinfo?.groups) { + for (let group of userinfo.groups) { + if (group.startsWith("/club/")) { + club = group.slice(group.indexOf("-") + 1) + break + } + } + } + + return <> +
    +

    Espace club

    {club}

    + + + + +} + +export function getClubChildren() { + return [ + { + path: 'member', + element: + }, + { + path: 'member/:id', + element: + }, + { + path: 'b', + element:
    Club B
    + } + ] +} \ No newline at end of file diff --git a/src/main/webapp/src/pages/club/MemberList.jsx b/src/main/webapp/src/pages/club/MemberList.jsx new file mode 100644 index 0000000..a486f6b --- /dev/null +++ b/src/main/webapp/src/pages/club/MemberList.jsx @@ -0,0 +1,60 @@ +import {useLoadingSwitcher} from "../../hooks/useLoading.jsx"; +import {useFetch} from "../../hooks/useFetch.js"; +import {AxiosError} from "../../components/AxiosError.jsx"; +import {ThreeDots} from "react-loader-spinner"; +import {useState} from "react"; +import {Input} from "../../components/Input.jsx"; +import {useNavigate} from "react-router-dom"; + +const removeDiacritics = str => { + return str + .normalize('NFD') + .replace(/[\u0300-\u036f]/g, '') +} + +export function MemberList() { + const setLoading = useLoadingSwitcher() + const {data, error} = useFetch(`/member/club`, setLoading, 1) + const [searchInput, setSearchInput] = useState(""); + const navigate = useNavigate(); + + const visibleMember = data ? data.filter(member => { + const lo = removeDiacritics(searchInput).toLowerCase() + return !searchInput + || (removeDiacritics(member.fname).toLowerCase().startsWith(lo) + || removeDiacritics(member.lname).toLowerCase().startsWith(lo)); + }) : []; + + return <> + + {data + ?
    + {visibleMember.map(member => ( + navigate("/club/member/" + member.id)} + className="list-group-item list-group-item-action">{member.fname} {member.lname}))} +
    + : error + ? + : + } + +} + +function SearchBar({searchInput, onSearchInputChange}) { + return
    +
    + +
    +
    +} + +function Def() { + return
    +
  • +
  • +
  • +
  • +
  • +
    +} \ No newline at end of file diff --git a/src/main/webapp/src/pages/club/member/CompteInfo.jsx b/src/main/webapp/src/pages/club/member/CompteInfo.jsx new file mode 100644 index 0000000..e9128c9 --- /dev/null +++ b/src/main/webapp/src/pages/club/member/CompteInfo.jsx @@ -0,0 +1,55 @@ +import {toast} from "react-toastify"; +import {apiAxios} from "../../../utils/Tools.js"; +import {useLoadingSwitcher} from "../../../hooks/useLoading.jsx"; +import {useFetch} from "../../../hooks/useFetch.js"; +import {ColoredCircle} from "../../../components/ColoredCircle.jsx"; +import {AxiosError} from "../../../components/AxiosError.jsx"; + +export function CompteInfo({userData}) { + + return
    +
    Compte
    +
    + {userData.userId + ? + : + <> +
    +
    +
    Ce membre ne dispose pas de compte...
    + Un compte sera créé par la fédération lors de la validation de sa première licence +
    +
    +
    + + } +
    +
    +} + +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 && + } +} diff --git a/src/main/webapp/src/pages/club/member/InformationForm.jsx b/src/main/webapp/src/pages/club/member/InformationForm.jsx new file mode 100644 index 0000000..4fd541c --- /dev/null +++ b/src/main/webapp/src/pages/club/member/InformationForm.jsx @@ -0,0 +1,101 @@ +// noinspection DuplicatedCode + +import {useLoadingSwitcher} from "../../../hooks/useLoading.jsx"; +import {apiAxios} from "../../../utils/Tools.js"; +import {toast} from "react-toastify"; +import imageCompression from "browser-image-compression"; +import {BirthDayField, OptionField, TextField} from "../../../components/MemberCustomFiels.jsx"; +import {ClubSelect} from "../../../components/ClubSelect.jsx"; + +export function InformationForm({data}) { + const setLoading = useLoadingSwitcher() + 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("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); + + const send = (formData_) => { + apiAxios.post(`/member/club/${data.id}`, formData_, { + headers: { + 'Accept': '*/*', + 'Content-Type': 'multipart/form-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) + }) + } + + 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) + } + } + + return
    +
    +
    Information
    +
    + + + + + + + + +
    +
    + + +
    +
    +
    +
    + +
    +
    +
    +
    +
    ; +} \ No newline at end of file diff --git a/src/main/webapp/src/pages/club/member/LicenceCard.jsx b/src/main/webapp/src/pages/club/member/LicenceCard.jsx new file mode 100644 index 0000000..48023ea --- /dev/null +++ b/src/main/webapp/src/pages/club/member/LicenceCard.jsx @@ -0,0 +1,176 @@ +import {useLoadingSwitcher} from "../../../hooks/useLoading.jsx"; +import {useFetch} from "../../../hooks/useFetch.js"; +import {useEffect, useReducer, useState} from "react"; +import {FontAwesomeIcon} from "@fortawesome/react-fontawesome"; +import {faInfo, faPen} from "@fortawesome/free-solid-svg-icons"; +import {AxiosError} from "../../../components/AxiosError.jsx"; +import {apiAxios, getSaison} from "../../../utils/Tools.js"; +import {toast} from "react-toastify"; +import {ColoredText} from "../../../components/ColoredCircle.jsx"; + +function licenceReducer(licences, action) { + switch (action.type) { + case 'REMOVE': + return licences.filter(licence => licence.id !== action.payload) + case 'UPDATE_OR_ADD': + const index = licences.findIndex(licence => licence.id === action.payload.id) + if (index === -1) { + return [ + ...licences, + action.payload + ] + } else { + licences[index] = action.payload + return [...licences] + } + case 'SORT': + return licences.sort((a, b) => b.saison - a.saison) + default: + throw new Error() + } +} + +export function LicenceCard({userData}) { + const defaultLicence = {id: -1, membre: userData.id, validate: false, saison: getSaison(), certificate: false} + + const setLoading = useLoadingSwitcher() + const {data, error} = useFetch(`/licence/${userData.id}`, setLoading, 1) + const [modalLicence, setModal] = useState(defaultLicence) + const [licences, dispatch] = useReducer(licenceReducer, []) + + useEffect(() => { + if (!data) return + for (const dataKey of data) { + dispatch({type: 'UPDATE_OR_ADD', payload: dataKey}) + } + dispatch({type: 'SORT'}) + }, [data]); + + return
    +
    +
    +
    Licence
    +
    + +
    +
    +
    +
    +
      + {licences.map((licence, index) => { + return
      +
      {licence?.saison}-{licence?.saison + 1}
      + +
      + })} + {error && } +
    +
    + + +
    ; +} + +function sendLicence(event, dispatch) { + event.preventDefault(); + + const formData = new FormData(event.target); + toast.promise( + apiAxios.post(`/licence/club/${formData.get('membre')}`, formData), + { + pending: "Enregistrement de la demande de licence en cours", + success: "Demande de licence enregistrée avec succès 🎉", + error: "Échec de la demande de licence 😕" + } + ).then(data => { + dispatch({type: 'UPDATE_OR_ADD', payload: data.data}) + dispatch({type: 'SORT'}) + }) + +} + +function removeLicence(id, dispatch) { + toast.promise( + apiAxios.delete(`/licence/club/${id}`), + { + pending: "Suppression de la demande en cours", + success: "Demande supprimée avec succès 🎉", + error: "Échec de la suppression de la demande de licence 😕" + } + ).then(_ => { + dispatch({type: 'REMOVE', payload: id}) + }) +} + +function ModalContent({licence, dispatch}) { + const [certificate, setCertificate] = useState(false) + const [isNew, setNew] = useState(true) + + const handleCertificateChange = (event) => { + setCertificate(event.target.value === 'true'); + } + + useEffect(() => { + if (licence.id !== -1) { + setNew(false) + setCertificate(licence.certificate) + } else { + setNew(true) + setCertificate(false) + } + }, [licence]); + + const currentSaison = licence.saison === getSaison(); + + return
    sendLicence(e, dispatch)}> + + +
    +

    + {isNew ? "Demande de licence " : "Edition de la demande "} + (saison {licence.saison}-{licence.saison + 1})

    + +
    +
    +
    + Certificat médical + + + + +
    + +
    +
    Validation de la licence:
    +
    +
    +
    + {currentSaison && + } + + {currentSaison && licence.validate === false && + } +
    +
    +} diff --git a/src/main/webapp/src/pages/club/member/MemberPage.jsx b/src/main/webapp/src/pages/club/member/MemberPage.jsx new file mode 100644 index 0000000..3f34d2f --- /dev/null +++ b/src/main/webapp/src/pages/club/member/MemberPage.jsx @@ -0,0 +1,70 @@ +import {useNavigate, useParams} from "react-router-dom"; +import {LoadingProvider, useLoadingSwitcher} from "../../../hooks/useLoading.jsx"; +import {useFetch} from "../../../hooks/useFetch.js"; +import {AxiosError} from "../../../components/AxiosError.jsx"; +import {CompteInfo} from "./CompteInfo.jsx"; +import {InformationForm} from "./InformationForm.jsx"; +import {LicenceCard} from "./LicenceCard.jsx"; + +const vite_url = import.meta.env.VITE_URL; + +export function MemberPage() { + const {id} = useParams() + const navigate = useNavigate(); + + 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 SelectCard() { + return
    +
    Sélection en équipe de France
    +
    +

    Soon

    + +
    +
    ; +} From 40427b8cfb257bd0e76e8ba5ddcc7b709e8001c5 Mon Sep 17 00:00:00 2001 From: Thibaut Valentin Date: Mon, 5 Feb 2024 21:42:50 +0100 Subject: [PATCH 5/6] feat: start affiliation system --- pom.xml | 4 + .../titionfire/ffsaf/rest/AssoEndpoints.java | 28 +++ .../ffsaf/rest/client/SirenService.java | 19 ++ .../ffsaf/rest/data/UniteLegaleRoot.java | 107 ++++++++++ .../rest/from/AffiliationRequestForm.java | 31 +++ src/main/resources/application.properties | 3 + src/main/webapp/src/App.jsx | 14 ++ src/main/webapp/src/components/Nav.jsx | 10 + src/main/webapp/src/pages/DemandeAff.jsx | 187 ++++++++++++++++++ 9 files changed, 403 insertions(+) create mode 100644 src/main/java/fr/titionfire/ffsaf/rest/AssoEndpoints.java create mode 100644 src/main/java/fr/titionfire/ffsaf/rest/client/SirenService.java create mode 100644 src/main/java/fr/titionfire/ffsaf/rest/data/UniteLegaleRoot.java create mode 100644 src/main/java/fr/titionfire/ffsaf/rest/from/AffiliationRequestForm.java create mode 100644 src/main/webapp/src/pages/DemandeAff.jsx diff --git a/pom.xml b/pom.xml index b7b140a..64b4168 100644 --- a/pom.xml +++ b/pom.xml @@ -56,6 +56,10 @@ io.quarkus quarkus-resteasy-reactive + + io.quarkus + quarkus-rest-client-reactive-jackson + io.vertx diff --git a/src/main/java/fr/titionfire/ffsaf/rest/AssoEndpoints.java b/src/main/java/fr/titionfire/ffsaf/rest/AssoEndpoints.java new file mode 100644 index 0000000..d254a1b --- /dev/null +++ b/src/main/java/fr/titionfire/ffsaf/rest/AssoEndpoints.java @@ -0,0 +1,28 @@ +package fr.titionfire.ffsaf.rest; + +import fr.titionfire.ffsaf.rest.client.SirenService; +import fr.titionfire.ffsaf.rest.data.UniteLegaleRoot; +import io.smallrye.mutiny.Uni; +import jakarta.ws.rs.*; +import jakarta.ws.rs.core.MediaType; +import org.eclipse.microprofile.rest.client.inject.RestClient; + +@Path("api/asso") +public class AssoEndpoints { + + @RestClient + SirenService sirenService; + + @GET + @Path("siren/{siren}") + @Produces(MediaType.APPLICATION_JSON) + public Uni getInfoSiren(@PathParam("siren") String siren) { + return sirenService.get_unite(siren).onFailure().transform(throwable -> { + if (throwable instanceof WebApplicationException exception){ + if (exception.getResponse().getStatus() == 400) + return new BadRequestException("Not found"); + } + return throwable; + }); + } +} diff --git a/src/main/java/fr/titionfire/ffsaf/rest/client/SirenService.java b/src/main/java/fr/titionfire/ffsaf/rest/client/SirenService.java new file mode 100644 index 0000000..413ddc7 --- /dev/null +++ b/src/main/java/fr/titionfire/ffsaf/rest/client/SirenService.java @@ -0,0 +1,19 @@ +package fr.titionfire.ffsaf.rest.client; + +import fr.titionfire.ffsaf.rest.data.UniteLegaleRoot; +import io.smallrye.mutiny.Uni; +import jakarta.ws.rs.GET; +import jakarta.ws.rs.Path; +import jakarta.ws.rs.PathParam; +import org.eclipse.microprofile.rest.client.annotation.ClientHeaderParam; +import org.eclipse.microprofile.rest.client.inject.RegisterRestClient; + +@Path("/") +@RegisterRestClient +@ClientHeaderParam(name = "X-Client-Secret", value = "${siren-api.key}") +public interface SirenService { + + @GET + @Path("/v3/unites_legales/{SIREN}") + Uni get_unite(@PathParam("SIREN") String siren); +} diff --git a/src/main/java/fr/titionfire/ffsaf/rest/data/UniteLegaleRoot.java b/src/main/java/fr/titionfire/ffsaf/rest/data/UniteLegaleRoot.java new file mode 100644 index 0000000..2b5c34a --- /dev/null +++ b/src/main/java/fr/titionfire/ffsaf/rest/data/UniteLegaleRoot.java @@ -0,0 +1,107 @@ +package fr.titionfire.ffsaf.rest.data; + +import io.quarkus.runtime.annotations.RegisterForReflection; +import lombok.Data; + +import java.util.ArrayList; +import java.util.Date; + +@Data +@RegisterForReflection +public class UniteLegaleRoot { + public UniteLegale unite_legale; + + @Data + @RegisterForReflection + public static class UniteLegale { + public String activite_principale; + public Object annee_categorie_entreprise; + public Object annee_effectifs; + public Object caractere_employeur; + public Object categorie_entreprise; + public String categorie_juridique; + public String date_creation; + public String date_debut; + public Date date_dernier_traitement; + public String denomination; + public Object denomination_usuelle_1; + public Object denomination_usuelle_2; + public Object denomination_usuelle_3; + public String economie_sociale_solidaire; + public Etablissement etablissement_siege; + public ArrayList etablissements; + public String etat_administratif; + public String identifiant_association; + public String nic_siege; + public Object nom; + public Object nom_usage; + public int nombre_periodes; + public String nomenclature_activite_principale; + public Object prenom_1; + public Object prenom_2; + public Object prenom_3; + public Object prenom_4; + public Object prenom_usuel; + public Object pseudonyme; + public Object sexe; + public Object sigle; + public String siren; + public String societe_mission; + public String statut_diffusion; + public Object tranche_effectifs; + public Object unite_purgee; + } + + @Data + @RegisterForReflection + public static class Etablissement { + private String activite_principale; + private Object activite_principale_registre_metiers; + private Object annee_effectifs; + private String caractere_employeur; + private Object code_cedex; + private Object code_cedex_2; + private String code_commune; + private Object code_commune_2; + private Object code_pays_etranger; + private Object code_pays_etranger_2; + private String code_postal; + private Object code_postal_2; + private Object complement_adresse; + private Object complement_adresse2; + private String date_creation; + private String date_debut; + private Date date_dernier_traitement; + private Object denomination_usuelle; + private Object distribution_speciale; + private Object distribution_speciale_2; + private Object enseigne_1; + private Object enseigne_2; + private Object enseigne_3; + private boolean etablissement_siege; + private String etat_administratif; + private Object indice_repetition; + private Object indice_repetition_2; + private Object libelle_cedex; + private Object libelle_cedex_2; + private String libelle_commune; + private Object libelle_commune_2; + private Object libelle_commune_etranger; + private Object libelle_commune_etranger_2; + private Object libelle_pays_etranger; + private Object libelle_pays_etranger_2; + private String libelle_voie; + private Object libelle_voie_2; + private String nic; + private int nombre_periodes; + private String nomenclature_activite_principale; + private String numero_voie; + private Object numero_voie_2; + private String siren; + private String siret; + private String statut_diffusion; + private Object tranche_effectifs; + private String type_voie; + private Object type_voie_2; + } +} \ No newline at end of file diff --git a/src/main/java/fr/titionfire/ffsaf/rest/from/AffiliationRequestForm.java b/src/main/java/fr/titionfire/ffsaf/rest/from/AffiliationRequestForm.java new file mode 100644 index 0000000..16d34fa --- /dev/null +++ b/src/main/java/fr/titionfire/ffsaf/rest/from/AffiliationRequestForm.java @@ -0,0 +1,31 @@ +package fr.titionfire.ffsaf.rest.from; + +import jakarta.ws.rs.FormParam; +import jakarta.ws.rs.core.MediaType; +import lombok.Getter; +import lombok.ToString; +import org.jboss.resteasy.reactive.PartType; + +@Getter +@ToString +public class AffiliationRequestForm { + @FormParam("name") + private String name = null; + + @FormParam("siren") + private String siren = null; + + @FormParam("rna") + private String rna = null; + + @FormParam("adresse") + private String adresse = null; + + @FormParam("status") + @PartType(MediaType.APPLICATION_OCTET_STREAM) + private byte[] status = new byte[0]; + + @FormParam("logo") + @PartType(MediaType.APPLICATION_OCTET_STREAM) + private byte[] logo = new byte[0]; +} diff --git a/src/main/resources/application.properties b/src/main/resources/application.properties index 69feeed..92aa416 100644 --- a/src/main/resources/application.properties +++ b/src/main/resources/application.properties @@ -34,6 +34,9 @@ database.port=3306 database.user=root database.pass= +siren-api.key=siren-ap +quarkus.rest-client."fr.titionfire.ffsaf.rest.client.SirenService".url=https://data.siren-api.fr/ + #Login quarkus.oidc.token-state-manager.split-tokens=true quarkus.oidc.token.refresh-expired=true diff --git a/src/main/webapp/src/App.jsx b/src/main/webapp/src/App.jsx index 9c0ff8d..e5e6d24 100644 --- a/src/main/webapp/src/App.jsx +++ b/src/main/webapp/src/App.jsx @@ -11,6 +11,7 @@ import {ToastContainer} from "react-toastify"; import './App.css' import 'react-toastify/dist/ReactToastify.css'; import {ClubRoot, getClubChildren} from "./pages/club/ClubRoot.jsx"; +import {DemandeAff, DemandeAffOk} from "./pages/DemandeAff.jsx"; const router = createBrowserRouter([ { @@ -31,6 +32,19 @@ const router = createBrowserRouter([ path: 'club', element: , children: getClubChildren() + }, + { + path: 'affiliation', + children: [ + { + path: '', + element: + }, + { + path: 'ok', + element: + } + ] } ] }, diff --git a/src/main/webapp/src/components/Nav.jsx b/src/main/webapp/src/components/Nav.jsx index 886644d..3e5c6fb 100644 --- a/src/main/webapp/src/components/Nav.jsx +++ b/src/main/webapp/src/components/Nav.jsx @@ -24,6 +24,7 @@ export function Nav() {
  • Accueil
  • +
    @@ -32,6 +33,15 @@ export function Nav() { } + +function AffiliationMenu() { + const {is_authenticated} = useAuth() + + if (is_authenticated) + return <> + return
  • Demande d'affiliation
  • +} + function ClubMenu() { const {is_authenticated, userinfo} = useAuth() diff --git a/src/main/webapp/src/pages/DemandeAff.jsx b/src/main/webapp/src/pages/DemandeAff.jsx new file mode 100644 index 0000000..585bae7 --- /dev/null +++ b/src/main/webapp/src/pages/DemandeAff.jsx @@ -0,0 +1,187 @@ +import {useState} from "react"; +import {apiAxios} from "../utils/Tools.js"; +import {toast} from "react-toastify"; +import {useNavigate} from "react-router-dom"; + +function reconstruireAdresse(infos) { + let adresseReconstruite = ""; + adresseReconstruite += infos.numero_voie + ' ' + infos.type_voie + ' '; + adresseReconstruite += infos.libelle_voie + ', '; + adresseReconstruite += infos.code_postal + ' ' + infos.libelle_commune + ', '; + + if (infos.complement_adresse) { + adresseReconstruite += infos.complement_adresse + ', '; + } + if (infos.code_cedex && infos.libelle_cedex) { + adresseReconstruite += 'Cedex ' + infos.code_cedex + ' - ' + infos.libelle_cedex; + } + + if (adresseReconstruite.endsWith(', ')) { + adresseReconstruite = adresseReconstruite.slice(0, -2); + } + + return adresseReconstruite; +} + + +export function DemandeAff() { + const navigate = useNavigate(); + + const submit = (event) => { + event.preventDefault() + const formData = new FormData(event.target) + toast.promise( + apiAxios.post(`asso/affiliation`, formData), + { + pending: "Enregistrement de la demande d'affiliation en cours", + success: "Demande d'affiliation enregistrée avec succès 🎉", + error: "Échec de la demande d'affiliation 😕" + } + ).then(_ => { + navigate("/affiliation/ok") + }) + } + + return
    +

    Demande d'affiliation

    +

    L'affiliation est annuelle et valable pour une saison sportive : du 1er septembre au 31 août de l’année suivante.

    + Pour s’affilier, une association sportive doit réunir les conditions suivantes : +
      +
    • Avoir son siège social en France ou Principauté de Monaco
    • +
    • Être constituée conformément au chapitre 1er du titre II du livre 1er du Code du Sport
    • +
    • Poursuivre un objet social entrant dans la définition de l’article 1 des statuts de la Fédération
    • +
    • Disposer de statuts compatibles avec les principes d’organisation et de fonctionnement de la Fédération
    • +
    • Assurer en son sein la liberté d’opinion et le respect des droits de la défense, et s’interdire toute discrimination
    • +
    • Respecter les règles d’encadrement, d’hygiène et de sécurité établies par les règlements de la Fédération
    • +
    + +
    +
    +
    +

    L'association

    + +

    Le président

    + +

    Le trésorier

    + +

    Le secrétaire

    + + +
    +

    Après validation de votre demande, vous recevrez un login et mot de passe provisoire pour accéder à votre espace FFSAF

    + Notez que pour finaliser votre affiliation, il vous faudra : +
      +
    • Disposer d’au moins trois membres licenciés, dont le président, le trésorier et le secrétaire
    • +
    • S'être acquitté des cotisations prévues par les règlements fédéraux
    • +
    +
    +
    +
    + +
    +
    +
    +
    +
    +
    +} + +function AssoInfo() { + const [denomination, setDenomination] = useState("") + const [siren, setSiren] = useState("") + const [rna, setRna] = useState("") + const [rnaEnable, setRnaEnable] = useState(false) + const [adresse, setAdresse] = useState("") + + const fetchSiren = () => { + toast.promise( + apiAxios.get(`asso/siren/${siren}`), + { + pending: "Recherche de l'association en cours", + success: "Association trouvée avec succès 🎉", + error: "Échec de la recherche de l'association 😕" + } + ).then(data => { + const data2 = data.data.unite_legale + setDenomination(data2.denomination) + setRnaEnable(data2.identifiant_association === null) + setRna(data2.identifiant_association ? data2.identifiant_association : "") + setAdresse(reconstruireAdresse(data2.etablissement_siege)) + }) + } + + return <> +
    + Nom de l'association* + +
    + +
    + N° SIREN* + setSiren(e.target.value)}/> + +
    + +
    + Dénomination + +
    + +
    + RNA + setRna(e.target.value)}/> +
    + +
    + Adresse* + setAdresse(e.target.value)}/> +
    + +
    + + +
    + +
    + + +
    + ; +} + +function MembreInfo({role}) { + return
    +
    +
    + + +
    +
    +
    +
    + + +
    +
    +
    +
    + + +
    +
    +
    +} + +export function DemandeAffOk() { + return ( +
    +

    Demande d'affiliation envoyée avec succès

    +

    Une fois votre demande validée, vous recevrez un login et mot de passe provisoire pour accéder à votre espace FFSAF

    +
    + ); +} \ No newline at end of file From 2fd6ef3c2e0f883e7f590fdde48acb5708660e9c Mon Sep 17 00:00:00 2001 From: Thibaut Valentin Date: Tue, 6 Feb 2024 21:39:42 +0100 Subject: [PATCH 6/6] feat: add membre list filter --- .../ffsaf/domain/service/LicenceService.java | 12 +- .../ffsaf/domain/service/MembreService.java | 59 ++++- .../titionfire/ffsaf/rest/CombEndpoints.java | 34 ++- .../ffsaf/rest/CompteEndpoints.java | 9 +- .../ffsaf/rest/LicenceEndpoints.java | 22 +- .../fr/titionfire/ffsaf/utils/PageResult.java | 17 ++ .../src/components/MemberCustomFiels.jsx | 13 +- src/main/webapp/src/hooks/useFetch.js | 8 +- src/main/webapp/src/pages/MemberList.jsx | 234 ++++++++++++++++++ src/main/webapp/src/pages/admin/AdminRoot.jsx | 4 +- .../webapp/src/pages/admin/MemberList.jsx | 60 ----- .../src/pages/admin/member/MemberPage.jsx | 2 +- src/main/webapp/src/pages/club/ClubRoot.jsx | 4 +- src/main/webapp/src/pages/club/MemberList.jsx | 60 ----- .../src/pages/club/member/MemberPage.jsx | 2 +- 15 files changed, 384 insertions(+), 156 deletions(-) create mode 100644 src/main/java/fr/titionfire/ffsaf/utils/PageResult.java create mode 100644 src/main/webapp/src/pages/MemberList.jsx delete mode 100644 src/main/webapp/src/pages/admin/MemberList.jsx delete mode 100644 src/main/webapp/src/pages/club/MemberList.jsx diff --git a/src/main/java/fr/titionfire/ffsaf/domain/service/LicenceService.java b/src/main/java/fr/titionfire/ffsaf/domain/service/LicenceService.java index 15c9291..6843358 100644 --- a/src/main/java/fr/titionfire/ffsaf/domain/service/LicenceService.java +++ b/src/main/java/fr/titionfire/ffsaf/domain/service/LicenceService.java @@ -13,6 +13,7 @@ import io.smallrye.mutiny.unchecked.Unchecked; import jakarta.enterprise.context.ApplicationScoped; import jakarta.inject.Inject; import jakarta.ws.rs.BadRequestException; +import org.eclipse.microprofile.jwt.JsonWebToken; import org.hibernate.reactive.mutiny.Mutiny; import java.util.List; @@ -32,6 +33,15 @@ public class LicenceService { return combRepository.findById(id).invoke(checkPerm).chain(combRepository -> Mutiny.fetch(combRepository.getLicences())); } + public Uni> getCurrentSaisonLicence(JsonWebToken idToken) { + if (idToken == null) + return repository.find("saison = ?1", Utils.getSaison()).list(); + + return combRepository.find("userId = ?1", idToken.getSubject()).firstResult().map(MembreModel::getClub) + .chain(clubModel -> combRepository.find("club = ?1", clubModel).list()) + .chain(membres -> repository.find("saison = ?1 AND membre IN ?2", Utils.getSaison(), membres).list()); + } + public Uni setLicence(long id, LicenceForm form) { if (form.getId() == -1) { return combRepository.findById(id).chain(combRepository -> { @@ -58,7 +68,7 @@ public class LicenceService { public Uni askLicence(long id, LicenceForm form, Consumer checkPerm) { return combRepository.findById(id).invoke(checkPerm).chain(membreModel -> { if (form.getId() == -1) { - return repository.find("saison = ?1", Utils.getSaison()).count().invoke(Unchecked.consumer(count -> { + return repository.find("saison = ?1 AND membre = ?2", Utils.getSaison(), membreModel).count().invoke(Unchecked.consumer(count -> { if (count > 0) throw new BadRequestException(); })).chain(__ -> combRepository.findById(id).chain(combRepository -> { 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 0ac75b3..d059892 100644 --- a/src/main/java/fr/titionfire/ffsaf/domain/service/MembreService.java +++ b/src/main/java/fr/titionfire/ffsaf/domain/service/MembreService.java @@ -6,24 +6,28 @@ 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.data.SimpleMembre; import fr.titionfire.ffsaf.rest.from.ClubMemberForm; import fr.titionfire.ffsaf.rest.from.FullMemberForm; import fr.titionfire.ffsaf.utils.GroupeUtils; +import fr.titionfire.ffsaf.utils.PageResult; import fr.titionfire.ffsaf.utils.Pair; import fr.titionfire.ffsaf.utils.RoleAsso; import io.quarkus.hibernate.reactive.panache.Panache; +import io.quarkus.hibernate.reactive.panache.PanacheQuery; import io.quarkus.hibernate.reactive.panache.common.WithSession; +import io.quarkus.panache.common.Page; import io.quarkus.panache.common.Sort; +import io.quarkus.security.identity.SecurityIdentity; import io.quarkus.vertx.VertxContextSupport; import io.smallrye.mutiny.Uni; import io.smallrye.mutiny.unchecked.Unchecked; import jakarta.enterprise.context.ApplicationScoped; import jakarta.inject.Inject; +import jakarta.ws.rs.BadRequestException; import jakarta.ws.rs.ForbiddenException; import org.eclipse.microprofile.jwt.JsonWebToken; -import java.util.List; - @WithSession @ApplicationScoped @@ -48,13 +52,48 @@ public class MembreService { return VertxContextSupport.subscribeAndAwait(() -> Panache.withTransaction(() -> repository.findById(id).map(SimpleCombModel::fromModel))); } - public Uni> getAll() { - return repository.listAll(Sort.ascending("fname", "lname")); + public Uni> searchAdmin(int limit, int page, String search, String club) { + if (search == null) + search = ""; + search = search + "%"; + + PanacheQuery query; + + if (club == null || club.isBlank()) + query = repository.find("(lname LIKE ?1 OR fname LIKE ?1)", + Sort.ascending("fname", "lname"), search).page(Page.ofSize(limit)); + else + query = repository.find("club.name LIKE ?2 AND (lname LIKE ?1 OR fname LIKE ?1)", + Sort.ascending("fname", "lname"), search, club + "%").page(Page.ofSize(limit)); + return getPageResult(query, limit, page); } - public Uni> getInClub(String subject) { + public Uni> search(int limit, int page, String search, String subject) { + if (search == null) + search = ""; + search = search + "%"; + String finalSearch = search; return repository.find("userId = ?1", subject).firstResult() - .chain(membreModel -> repository.find("club = ?1", membreModel.getClub()).list()); + .chain(membreModel -> { + PanacheQuery query = repository.find("club = ?1 AND (lname LIKE ?2 OR fname LIKE ?2)", + Sort.ascending("fname", "lname"), membreModel.getClub(), finalSearch).page(Page.ofSize(limit)); + return getPageResult(query, limit, page); + }); + } + + private Uni> getPageResult(PanacheQuery query, int limit, int page) { + return Uni.createFrom().item(new PageResult()) + .invoke(result -> result.setPage(page)) + .invoke(result -> result.setPage_size(limit)) + .call(result -> query.count().invoke(result::setResult_count)) + .call(result -> query.pageCount() + .invoke(Unchecked.consumer(pages -> { + if (page > pages) throw new BadRequestException(); + })) + .invoke(result::setPage_count)) + .call(result -> query.page(Page.of(page, limit)).list() + .map(membreModels -> membreModels.stream().map(SimpleMembre::fromModel).toList()) + .invoke(result::setResult)); } public Uni getById(long id) { @@ -89,7 +128,7 @@ public class MembreService { .map(__ -> "OK"); } - public Uni update(long id, ClubMemberForm membre, JsonWebToken idToken) { + public Uni update(long id, ClubMemberForm membre, JsonWebToken idToken, SecurityIdentity securityIdentity) { return repository.findById(id) .invoke(Unchecked.consumer(membreModel -> { if (!GroupeUtils.isInClubGroup(membreModel.getClub().getId(), idToken)) @@ -97,9 +136,9 @@ public class MembreService { })) .invoke(Unchecked.consumer(membreModel -> { RoleAsso source = RoleAsso.MEMBRE; - if (idToken.getGroups().contains("club_president")) source = RoleAsso.PRESIDENT; - else if (idToken.getGroups().contains("club_secretaire")) source = RoleAsso.SECRETAIRE; - else if (idToken.getGroups().contains("club_respo_intra")) source = RoleAsso.SECRETAIRE; + if (securityIdentity.getRoles().contains("club_president")) source = RoleAsso.PRESIDENT; + else if (securityIdentity.getRoles().contains("club_secretaire")) source = RoleAsso.SECRETAIRE; + else if (securityIdentity.getRoles().contains("club_respo_intra")) source = RoleAsso.SECRETAIRE; if (!membre.getRole().equals(membreModel.getRole()) && membre.getRole().level > source.level) throw new ForbiddenException(); })) diff --git a/src/main/java/fr/titionfire/ffsaf/rest/CombEndpoints.java b/src/main/java/fr/titionfire/ffsaf/rest/CombEndpoints.java index b27dd28..82a0209 100644 --- a/src/main/java/fr/titionfire/ffsaf/rest/CombEndpoints.java +++ b/src/main/java/fr/titionfire/ffsaf/rest/CombEndpoints.java @@ -6,9 +6,11 @@ import fr.titionfire.ffsaf.rest.data.SimpleMembre; import fr.titionfire.ffsaf.rest.from.ClubMemberForm; import fr.titionfire.ffsaf.rest.from.FullMemberForm; import fr.titionfire.ffsaf.utils.GroupeUtils; +import fr.titionfire.ffsaf.utils.PageResult; import fr.titionfire.ffsaf.utils.Pair; import io.quarkus.oidc.IdToken; import io.quarkus.security.Authenticated; +import io.quarkus.security.identity.SecurityIdentity; import io.smallrye.mutiny.Uni; import io.smallrye.mutiny.unchecked.Unchecked; import jakarta.annotation.security.RolesAllowed; @@ -26,7 +28,6 @@ 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; import java.util.concurrent.Future; import java.util.function.Consumer; @@ -45,25 +46,38 @@ public class CombEndpoints { @IdToken JsonWebToken idToken; + @Inject + SecurityIdentity securityIdentity; + Consumer checkPerm = Unchecked.consumer(membreModel -> { - if (!idToken.getGroups().contains("federation_admin") && !GroupeUtils.isInClubGroup(membreModel.getClub().getId(), idToken)) + if (!securityIdentity.getRoles().contains("federation_admin") && !GroupeUtils.isInClubGroup(membreModel.getClub().getId(), idToken)) throw new ForbiddenException(); }); @GET - @Path("/all") - @RolesAllowed("federation_admin") + @Path("/find/admin") + @RolesAllowed({"federation_admin"}) @Produces(MediaType.APPLICATION_JSON) - public Uni> getAll() { - return membreService.getAll().map(membreModels -> membreModels.stream().map(SimpleMembre::fromModel).toList()); + public Uni> getFindAdmin(@QueryParam("limit") Integer limit, @QueryParam("page") Integer page, + @QueryParam("search") String search, @QueryParam("club") String club) { + if (limit == null) + limit = 50; + if (page == null || page < 1) + page = 1; + return membreService.searchAdmin(limit, page - 1, search, club); } @GET - @Path("/club") + @Path("/find/club") @RolesAllowed({"club_president", "club_secretaire", "club_respo_intra"}) @Produces(MediaType.APPLICATION_JSON) - public Uni> getClub() { - return membreService.getInClub(idToken.getSubject()).map(membreModels -> membreModels.stream().map(SimpleMembre::fromModel).toList()); + public Uni> getFindClub(@QueryParam("limit") Integer limit, @QueryParam("page") Integer page, + @QueryParam("search") String search) { + if (limit == null) + limit = 50; + if (page == null || page < 1) + page = 1; + return membreService.search(limit, page - 1, search, idToken.getSubject()); } @GET @@ -99,7 +113,7 @@ public class CombEndpoints { @Produces(MediaType.TEXT_PLAIN) @Consumes(MediaType.MULTIPART_FORM_DATA) public Uni setMembre(@PathParam("id") long id, ClubMemberForm input) { - return membreService.update(id, input, idToken) + return membreService.update(id, input, idToken, securityIdentity) .invoke(Unchecked.consumer(out -> { if (!out.equals("OK")) throw new InternalError("Fail to update data: " + out); })).chain(() -> { diff --git a/src/main/java/fr/titionfire/ffsaf/rest/CompteEndpoints.java b/src/main/java/fr/titionfire/ffsaf/rest/CompteEndpoints.java index 33dc88a..d05c38d 100644 --- a/src/main/java/fr/titionfire/ffsaf/rest/CompteEndpoints.java +++ b/src/main/java/fr/titionfire/ffsaf/rest/CompteEndpoints.java @@ -4,7 +4,7 @@ import fr.titionfire.ffsaf.domain.service.KeycloakService; import fr.titionfire.ffsaf.rest.from.MemberPermForm; import fr.titionfire.ffsaf.utils.GroupeUtils; import fr.titionfire.ffsaf.utils.Pair; -import io.quarkus.oidc.IdToken; +import io.quarkus.security.identity.SecurityIdentity; import io.smallrye.mutiny.Uni; import io.vertx.mutiny.core.Vertx; import jakarta.annotation.security.RolesAllowed; @@ -26,8 +26,7 @@ public class CompteEndpoints { JsonWebToken accessToken; @Inject - @IdToken - JsonWebToken idToken; + SecurityIdentity securityIdentity; @Inject Vertx vertx; @@ -37,8 +36,8 @@ public class CompteEndpoints { @RolesAllowed({"federation_admin", "club_president", "club_secretaire", "club_respo_intra"}) public Uni getCompte(@PathParam("id") String id) { return service.fetchCompte(id).call(pair -> vertx.getOrCreateContext().executeBlocking(() -> { - if (!idToken.getGroups().contains("federation_admin") && !pair.getKey().groups().stream().map(GroupRepresentation::getPath) - .anyMatch(s -> s.startsWith("/club/") && GroupeUtils.contains(s, accessToken))) + if (!securityIdentity.getRoles().contains("federation_admin") && pair.getKey().groups().stream().map(GroupRepresentation::getPath) + .noneMatch(s -> s.startsWith("/club/") && GroupeUtils.contains(s, accessToken))) throw new ForbiddenException(); return pair; })).map(Pair::getValue); diff --git a/src/main/java/fr/titionfire/ffsaf/rest/LicenceEndpoints.java b/src/main/java/fr/titionfire/ffsaf/rest/LicenceEndpoints.java index 7583287..9e4d069 100644 --- a/src/main/java/fr/titionfire/ffsaf/rest/LicenceEndpoints.java +++ b/src/main/java/fr/titionfire/ffsaf/rest/LicenceEndpoints.java @@ -6,6 +6,7 @@ import fr.titionfire.ffsaf.rest.data.SimpleLicence; import fr.titionfire.ffsaf.rest.from.LicenceForm; import fr.titionfire.ffsaf.utils.GroupeUtils; import io.quarkus.oidc.IdToken; +import io.quarkus.security.identity.SecurityIdentity; import io.smallrye.mutiny.Uni; import io.smallrye.mutiny.unchecked.Unchecked; import jakarta.annotation.security.RolesAllowed; @@ -27,8 +28,11 @@ public class LicenceEndpoints { @IdToken JsonWebToken idToken; + @Inject + SecurityIdentity securityIdentity; + Consumer checkPerm = Unchecked.consumer(membreModel -> { - if (!idToken.getGroups().contains("federation_admin") && !GroupeUtils.isInClubGroup(membreModel.getClub().getId(), idToken)) + if (!securityIdentity.getRoles().contains("federation_admin") && !GroupeUtils.isInClubGroup(membreModel.getClub().getId(), idToken)) throw new ForbiddenException(); }); @@ -40,6 +44,22 @@ public class LicenceEndpoints { return licenceService.getLicence(id, checkPerm).map(licenceModels -> licenceModels.stream().map(SimpleLicence::fromModel).toList()); } + @GET + @Path("current/admin") + @RolesAllowed({"federation_admin"}) + @Produces(MediaType.APPLICATION_JSON) + public Uni> getCurrentSaisonLicenceAdmin() { + return licenceService.getCurrentSaisonLicence(null).map(licenceModels -> licenceModels.stream().map(SimpleLicence::fromModel).toList()); + } + + @GET + @Path("current/club") + @RolesAllowed({"club_president", "club_secretaire", "club_respo_intra"}) + @Produces(MediaType.APPLICATION_JSON) + public Uni> getCurrentSaisonLicenceClub() { + return licenceService.getCurrentSaisonLicence(idToken).map(licenceModels -> licenceModels.stream().map(SimpleLicence::fromModel).toList()); + } + @POST @Path("{id}") @RolesAllowed("federation_admin") diff --git a/src/main/java/fr/titionfire/ffsaf/utils/PageResult.java b/src/main/java/fr/titionfire/ffsaf/utils/PageResult.java new file mode 100644 index 0000000..e8d2629 --- /dev/null +++ b/src/main/java/fr/titionfire/ffsaf/utils/PageResult.java @@ -0,0 +1,17 @@ +package fr.titionfire.ffsaf.utils; + +import io.quarkus.runtime.annotations.RegisterForReflection; +import lombok.Data; + +import java.util.ArrayList; +import java.util.List; + +@Data +@RegisterForReflection +public class PageResult { + private int page; + private int page_size; + private int page_count; + private long result_count; + private List result = new ArrayList<>(); +} diff --git a/src/main/webapp/src/components/MemberCustomFiels.jsx b/src/main/webapp/src/components/MemberCustomFiels.jsx index 1b7eb64..6a00bce 100644 --- a/src/main/webapp/src/components/MemberCustomFiels.jsx +++ b/src/main/webapp/src/components/MemberCustomFiels.jsx @@ -77,4 +77,15 @@ export function CheckField({name, text, value, row = false}) {
    } -} \ No newline at end of file +} + +export const Checkbox = ({ label, value, onChange }) => { + const handleChange = () => { + onChange(!value); + }; + + return
    + + +
    +}; \ No newline at end of file diff --git a/src/main/webapp/src/hooks/useFetch.js b/src/main/webapp/src/hooks/useFetch.js index f85bddf..99379fc 100644 --- a/src/main/webapp/src/hooks/useFetch.js +++ b/src/main/webapp/src/hooks/useFetch.js @@ -19,12 +19,16 @@ export function useFetch(url, setLoading = null, loadingLevel = 1, config = {}) const [data, setData] = useState(null) const [error, setErrors] = useState(null) - useEffect(() => { + const refresh = (url) => { stdAction(apiAxios.get(url, config), setData, setErrors, setLoading, loadingLevel) + } + + useEffect(() => { + refresh(url) }, []); return { - data, error + data, error, refresh } } diff --git a/src/main/webapp/src/pages/MemberList.jsx b/src/main/webapp/src/pages/MemberList.jsx new file mode 100644 index 0000000..8fbd771 --- /dev/null +++ b/src/main/webapp/src/pages/MemberList.jsx @@ -0,0 +1,234 @@ +import {useLoadingSwitcher} from "../hooks/useLoading.jsx"; +import {useFetch} from "../hooks/useFetch.js"; +import {AxiosError} from "../components/AxiosError.jsx"; +import {ThreeDots} from "react-loader-spinner"; +import {useEffect, useState} from "react"; +import {Input} from "../components/Input.jsx"; +import {useLocation, useNavigate} from "react-router-dom"; +import {Checkbox} from "../components/MemberCustomFiels.jsx"; +import axios from "axios"; +import {apiAxios} from "../utils/Tools.js"; +import {toast} from "react-toastify"; + +const removeDiacritics = str => { + return str + .normalize('NFD') + .replace(/[\u0300-\u036f]/g, '') +} + +export function MemberList({source}) { + const {hash} = useLocation(); + const navigate = useNavigate(); + let page = Number(hash.substring(1)); + page = (page > 0) ? page : 1; + + const [memberData, setMemberData] = useState([]); + const [licenceData, setLicenceData] = useState([]); + const [showLicenceState, setShowLicenceState] = useState(false); + const [clubFilter, setClubFilter] = useState(""); + const [lastSearch, setLastSearch] = useState(""); + + const setLoading = useLoadingSwitcher() + const {data, error, refresh} = useFetch(`/member/find/${source}?page=${page}`, setLoading, 1) + + + useEffect(() => { + refresh(`/member/find/${source}?page=${page}&search=${lastSearch}&club=${clubFilter}`); + }, [hash, clubFilter]); + + useEffect(() => { + if (!data) + return; + const data2 = []; + for (const e of data.result) { + data2.push({ + id: e.id, + fname: e.fname, + lname: e.lname, + club: e.club, + licence_number: e.licence, + licence: showLicenceState ? licenceData.find(licence => licence.membre === e.id) : null + }) + } + setMemberData(data2); + }, [data, licenceData]); + + useEffect(() => { + if (!showLicenceState) + return; + + toast.promise( + apiAxios.get(`/licence/current/${source}`), + { + pending: "Chargement des licences...", + success: "Licences chargées", + error: "Impossible de charger les licences" + }) + .then(data => { + setLicenceData(data.data); + }); + }, [showLicenceState]); + + const search = (search) => { + if (search === lastSearch) + return; + setLastSearch(search); + refresh(`/member/find/${source}?page=${page}&search=${search}&club=${clubFilter}`); + } + + return <> +
    +
    +
    + + {data + ? + : error + ? + : + } +
    +
    +
    + +
    +
    +
    Filtre
    +
    + +
    +
    +
    +
    +
    + +} + +function SearchBar({search}) { + const [searchInput, setSearchInput] = useState(""); + + const handelChange = (e) => { + setSearchInput(e.target.value); + } + + const handleKeyDown = (event) => { + if (event.key === 'Enter') { + searchMember(); + } + } + + const searchMember = () => { + search(removeDiacritics(searchInput)); + } + + useEffect(() => { + const delayDebounceFn = setTimeout(() => { + searchMember(); + }, 750) + return () => clearTimeout(delayDebounceFn) + }, [searchInput]) + + return
    +
    + + +
    +
    +} + +function MakeCentralPanel({data, visibleMember, navigate, showLicenceState, page}) { + const pages = [] + for (let i = 1; i <= data.page_count; i++) { + pages.push(
  • + navigate("#" + i)}>{i} +
  • ); + } + + return <> +
    + Ligne {((page - 1) * data.page_size) + 1} à { + (page * data.page_size > data.result_count) ? data.result_count : (page * data.page_size)} (page {page} sur {data.page_count}) +
    + {visibleMember.map(member => ())} +
    +
    +
    + +
    + +} + +function MakeRow({member, showLicenceState, navigate}) { + const rowContent = <> +
    + {String(member.licence_number).padStart(5, '0')} +
    +
    {member.fname} {member.lname}
    +
    +
    + {member.club?.name || "Sans club"} + + + if (showLicenceState && member.licence != null) { + return
    navigate("" + member.id)}>{rowContent}
    + } else { + return
    navigate("" + member.id)}> + {rowContent} +
    + } +} + +let allClub = [] + +function FiltreBar({showLicenceState, setShowLicenceState, data, clubFilter, setClubFilter, source}) { + useEffect(() => { + if (!data) + return; + allClub.push(...data.result.map((e) => e.club?.name)) + allClub = allClub.filter((value, index, self) => self.indexOf(value) === index).filter(value => value != null).sort() + }, [data]); + + return
    +
    + +
    + {source !== "club" && +
    + +
    + } +
    +} + +function Def() { + return
    +
  • +
  • +
  • +
  • +
  • +
    +} \ No newline at end of file diff --git a/src/main/webapp/src/pages/admin/AdminRoot.jsx b/src/main/webapp/src/pages/admin/AdminRoot.jsx index 623032d..1ea1031 100644 --- a/src/main/webapp/src/pages/admin/AdminRoot.jsx +++ b/src/main/webapp/src/pages/admin/AdminRoot.jsx @@ -1,7 +1,7 @@ import {Outlet} from "react-router-dom"; import './AdminRoot.css' import {LoadingProvider} from "../../hooks/useLoading.jsx"; -import {MemberList} from "./MemberList.jsx"; +import {MemberList} from "../MemberList.jsx"; import {MemberPage} from "./member/MemberPage.jsx"; export function AdminRoot() { @@ -17,7 +17,7 @@ export function getAdminChildren () { return [ { path: 'member', - element: + element: }, { path: 'member/:id', diff --git a/src/main/webapp/src/pages/admin/MemberList.jsx b/src/main/webapp/src/pages/admin/MemberList.jsx deleted file mode 100644 index 5f4ed16..0000000 --- a/src/main/webapp/src/pages/admin/MemberList.jsx +++ /dev/null @@ -1,60 +0,0 @@ -import {useLoadingSwitcher} from "../../hooks/useLoading.jsx"; -import {useFetch} from "../../hooks/useFetch.js"; -import {AxiosError} from "../../components/AxiosError.jsx"; -import {ThreeDots} from "react-loader-spinner"; -import {useState} from "react"; -import {Input} from "../../components/Input.jsx"; -import {useNavigate} from "react-router-dom"; - -const removeDiacritics = str => { - return str - .normalize('NFD') - .replace(/[\u0300-\u036f]/g, '') -} - -export function MemberList() { - const setLoading = useLoadingSwitcher() - const {data, error} = useFetch(`/member/all`, setLoading, 1) - const [searchInput, setSearchInput] = useState(""); - const navigate = useNavigate(); - - const visibleMember = data ? data.filter(member => { - const lo = removeDiacritics(searchInput).toLowerCase() - return !searchInput - || (removeDiacritics(member.fname).toLowerCase().startsWith(lo) - || removeDiacritics(member.lname).toLowerCase().startsWith(lo)); - }) : []; - - return <> - - {data - ?
    - {visibleMember.map(member => ( - navigate("/admin/member/" + member.id)} - className="list-group-item list-group-item-action">{member.fname} {member.lname}))} -
    - : error - ? - : - } - -} - -function SearchBar({searchInput, onSearchInputChange}) { - return
    -
    - -
    -
    -} - -function Def() { - return
    -
  • -
  • -
  • -
  • -
  • -
    -} \ No newline at end of file diff --git a/src/main/webapp/src/pages/admin/member/MemberPage.jsx b/src/main/webapp/src/pages/admin/member/MemberPage.jsx index 77b85d3..badac6f 100644 --- a/src/main/webapp/src/pages/admin/member/MemberPage.jsx +++ b/src/main/webapp/src/pages/admin/member/MemberPage.jsx @@ -19,7 +19,7 @@ export function MemberPage() { return <>

    Page membre

    {data ?
    diff --git a/src/main/webapp/src/pages/club/ClubRoot.jsx b/src/main/webapp/src/pages/club/ClubRoot.jsx index 5c92a4a..5ddc95a 100644 --- a/src/main/webapp/src/pages/club/ClubRoot.jsx +++ b/src/main/webapp/src/pages/club/ClubRoot.jsx @@ -1,8 +1,8 @@ import {Outlet} from "react-router-dom"; import {LoadingProvider} from "../../hooks/useLoading.jsx"; -import {MemberList} from "./MemberList.jsx"; import {MemberPage} from "./member/MemberPage.jsx"; import {useAuth} from "../../hooks/useAuth.jsx"; +import {MemberList} from "../MemberList.jsx"; export function ClubRoot() { const {userinfo} = useAuth() @@ -29,7 +29,7 @@ export function getClubChildren() { return [ { path: 'member', - element: + element: }, { path: 'member/:id', diff --git a/src/main/webapp/src/pages/club/MemberList.jsx b/src/main/webapp/src/pages/club/MemberList.jsx deleted file mode 100644 index a486f6b..0000000 --- a/src/main/webapp/src/pages/club/MemberList.jsx +++ /dev/null @@ -1,60 +0,0 @@ -import {useLoadingSwitcher} from "../../hooks/useLoading.jsx"; -import {useFetch} from "../../hooks/useFetch.js"; -import {AxiosError} from "../../components/AxiosError.jsx"; -import {ThreeDots} from "react-loader-spinner"; -import {useState} from "react"; -import {Input} from "../../components/Input.jsx"; -import {useNavigate} from "react-router-dom"; - -const removeDiacritics = str => { - return str - .normalize('NFD') - .replace(/[\u0300-\u036f]/g, '') -} - -export function MemberList() { - const setLoading = useLoadingSwitcher() - const {data, error} = useFetch(`/member/club`, setLoading, 1) - const [searchInput, setSearchInput] = useState(""); - const navigate = useNavigate(); - - const visibleMember = data ? data.filter(member => { - const lo = removeDiacritics(searchInput).toLowerCase() - return !searchInput - || (removeDiacritics(member.fname).toLowerCase().startsWith(lo) - || removeDiacritics(member.lname).toLowerCase().startsWith(lo)); - }) : []; - - return <> - - {data - ?
    - {visibleMember.map(member => ( - navigate("/club/member/" + member.id)} - className="list-group-item list-group-item-action">{member.fname} {member.lname}))} -
    - : error - ? - : - } - -} - -function SearchBar({searchInput, onSearchInputChange}) { - return
    -
    - -
    -
    -} - -function Def() { - return
    -
  • -
  • -
  • -
  • -
  • -
    -} \ No newline at end of file diff --git a/src/main/webapp/src/pages/club/member/MemberPage.jsx b/src/main/webapp/src/pages/club/member/MemberPage.jsx index 3f34d2f..622fd5d 100644 --- a/src/main/webapp/src/pages/club/member/MemberPage.jsx +++ b/src/main/webapp/src/pages/club/member/MemberPage.jsx @@ -18,7 +18,7 @@ export function MemberPage() { return <>

    Page membre

    {data ?