Merge pull request #17 from TitiOnFire/dev

Dev
This commit is contained in:
TitiOnFire 2024-02-09 19:47:15 +01:00 committed by GitHub
commit b3d9b1df83
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
48 changed files with 2536 additions and 550 deletions

View File

@ -56,6 +56,10 @@
<groupId>io.quarkus</groupId> <groupId>io.quarkus</groupId>
<artifactId>quarkus-resteasy-reactive</artifactId> <artifactId>quarkus-resteasy-reactive</artifactId>
</dependency> </dependency>
<dependency>
<groupId>io.quarkus</groupId>
<artifactId>quarkus-rest-client-reactive-jackson</artifactId>
</dependency>
<dependency> <dependency>
<groupId>io.vertx</groupId> <groupId>io.vertx</groupId>

View File

@ -79,6 +79,11 @@ public class ExampleResource {
response.append("<li>scopes: ").append(this.accessToken.toString()).append("</li>"); response.append("<li>scopes: ").append(this.accessToken.toString()).append("</li>");
} }
if (scopes != null) {
response.append("<li>scopes: ").append(this.accessToken.getClaim("user_groups").toString()).append("</li>");
}
if (scopes != null) { if (scopes != null) {
response.append("<li>getRoles: ").append(this.securityIdentity.getRoles()).append("</li>"); response.append("<li>getRoles: ").append(this.securityIdentity.getRoles()).append("</li>");
} }

View File

@ -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;
}

View File

@ -5,6 +5,7 @@ import io.quarkus.runtime.annotations.RegisterForReflection;
import jakarta.persistence.*; import jakarta.persistence.*;
import lombok.*; import lombok.*;
import java.util.List;
import java.util.Map; import java.util.Map;
@Getter @Getter
@ -49,4 +50,7 @@ public class ClubModel {
String no_affiliation; String no_affiliation;
boolean international; boolean international;
@OneToMany(mappedBy = "club", fetch = FetchType.EAGER)
List<AffiliationModel> affiliations;
} }

View File

@ -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.LAZY)
@JoinColumn(name = "membre", referencedColumnName = "id")
MembreModel membre;
int saison;
boolean certificate;
boolean validate;
}

View File

@ -9,6 +9,7 @@ import jakarta.persistence.*;
import lombok.*; import lombok.*;
import java.util.Date; import java.util.Date;
import java.util.List;
@Getter @Getter
@Setter @Setter
@ -51,4 +52,7 @@ public class MembreModel {
GradeArbitrage grade_arbitrage; GradeArbitrage grade_arbitrage;
String url_photo; String url_photo;
@OneToMany(mappedBy = "membre", fetch = FetchType.LAZY)
List<LicenceModel> licences;
} }

View File

@ -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<LicenceModel, Long> {
}

View File

@ -49,10 +49,4 @@ public class ClubEntity {
.international(model.isInternational()) .international(model.isInternational())
.build(); .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);
}
} }

View File

@ -2,8 +2,7 @@ package fr.titionfire.ffsaf.domain.service;
import fr.titionfire.ffsaf.data.model.ClubModel; import fr.titionfire.ffsaf.data.model.ClubModel;
import fr.titionfire.ffsaf.data.model.MembreModel; import fr.titionfire.ffsaf.data.model.MembreModel;
import fr.titionfire.ffsaf.utils.KeycloakException; import fr.titionfire.ffsaf.utils.*;
import fr.titionfire.ffsaf.utils.RequiredAction;
import io.quarkus.runtime.annotations.RegisterForReflection; import io.quarkus.runtime.annotations.RegisterForReflection;
import io.smallrye.mutiny.Uni; import io.smallrye.mutiny.Uni;
import io.smallrye.mutiny.unchecked.Unchecked; import io.smallrye.mutiny.unchecked.Unchecked;
@ -21,6 +20,7 @@ import org.keycloak.representations.idm.RoleRepresentation;
import org.keycloak.representations.idm.UserRepresentation; import org.keycloak.representations.idm.UserRepresentation;
import java.text.Normalizer; import java.text.Normalizer;
import java.util.ArrayList;
import java.util.List; import java.util.List;
import java.util.Optional; import java.util.Optional;
@ -86,13 +86,42 @@ public class KeycloakService {
}))); })));
} }
public Uni<UserCompteState> 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<String> toRemove = new ArrayList<>(List.of("club_president", "club_tresorier", "club_secretaire", "asseseur", "arbitre"));
List<String> 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<Pair<UserResource, UserCompteState>> fetchCompte(String id) {
return vertx.getOrCreateContext().executeBlocking(() -> { return vertx.getOrCreateContext().executeBlocking(() -> {
UserResource user = keycloak.realm(realm).users().get(id); UserResource user = keycloak.realm(realm).users().get(id);
UserRepresentation user2 = user.toRepresentation(); UserRepresentation user2 = user.toRepresentation();
return new UserCompteState(user2.isEnabled(), user2.getUsername(), user2.isEmailVerified(), return new Pair<>(user, new UserCompteState(user2.isEnabled(), user2.getUsername(), user2.isEmailVerified())) ;
user.roles().realmLevel().listEffective().stream().map(RoleRepresentation::getName).toList(),
user.groups().stream().map(GroupRepresentation::getName).toList());
}); });
} }
@ -127,9 +156,18 @@ public class KeycloakService {
} }
private Uni<UserRepresentation> creatUser(MembreModel membreModel) { private Uni<UserRepresentation> creatUser(MembreModel membreModel) {
String login = makeLogin(membreModel);
LOGGER.infof("Creation of user %s...", login);
return vertx.getOrCreateContext().executeBlocking(() -> { 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(); UserRepresentation user = new UserRepresentation();
user.setUsername(login); user.setUsername(login);
user.setFirstName(membreModel.getFname()); user.setFirstName(membreModel.getFname());
@ -145,13 +183,18 @@ public class KeycloakService {
throw new KeycloakException("Fail to creat user %s (reason=%s)".formatted(login, response.getStatusInfo().getReasonPhrase())); 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())) .invoke(user -> membreModel.setUserId(user.getId()))
.call(user -> membreService.setUserId(membreModel.getId(), user.getId())) .call(user -> membreService.setUserId(membreModel.getId(), user.getId()))
.call(user -> setClubGroupMembre(membreModel, membreModel.getClub())); .call(user -> setClubGroupMembre(membreModel, membreModel.getClub()));
} }
public Uni<?> setId(long id, String nid) {
return membreService.setUserId(id, nid).map(__ -> "OK");
}
private Optional<UserRepresentation> getUser(String username) { private Optional<UserRepresentation> getUser(String username) {
List<UserRepresentation> users = keycloak.realm(realm).users().searchByUsername(username, true); List<UserRepresentation> users = keycloak.realm(realm).users().searchByUsername(username, true);
@ -168,7 +211,6 @@ public class KeycloakService {
} }
@RegisterForReflection @RegisterForReflection
public record UserCompteState(Boolean enabled, String login, Boolean emailVerified, List<String> realmRoles, public record UserCompteState(Boolean enabled, String login, Boolean emailVerified) {
List<String> groups) {
} }
} }

View File

@ -0,0 +1,96 @@
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.eclipse.microprofile.jwt.JsonWebToken;
import org.hibernate.reactive.mutiny.Mutiny;
import java.util.List;
import java.util.function.Consumer;
@WithSession
@ApplicationScoped
public class LicenceService {
@Inject
LicenceRepository repository;
@Inject
CombRepository combRepository;
public Uni<List<LicenceModel>> getLicence(long id, Consumer<MembreModel> checkPerm) {
return combRepository.findById(id).invoke(checkPerm).chain(combRepository -> Mutiny.fetch(combRepository.getLicences()));
}
public Uni<List<LicenceModel>> 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<LicenceModel> 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));
}
public Uni<LicenceModel> askLicence(long id, LicenceForm form, Consumer<MembreModel> checkPerm) {
return combRepository.findById(id).invoke(checkPerm).chain(membreModel -> {
if (form.getId() == -1) {
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 -> {
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<MembreModel> checkPerm) {
return repository.findById(id)
.call(licenceModel -> Mutiny.fetch(licenceModel.getMembre()).invoke(checkPerm))
.chain(__ -> Panache.withTransaction(() -> repository.deleteById(id)));
}
}

View File

@ -6,17 +6,27 @@ import fr.titionfire.ffsaf.data.repository.CombRepository;
import fr.titionfire.ffsaf.net2.ServerCustom; import fr.titionfire.ffsaf.net2.ServerCustom;
import fr.titionfire.ffsaf.net2.data.SimpleCombModel; import fr.titionfire.ffsaf.net2.data.SimpleCombModel;
import fr.titionfire.ffsaf.net2.request.SReqComb; 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.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.Pair;
import fr.titionfire.ffsaf.utils.RoleAsso;
import io.quarkus.hibernate.reactive.panache.Panache; 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.hibernate.reactive.panache.common.WithSession;
import io.quarkus.panache.common.Page;
import io.quarkus.panache.common.Sort; import io.quarkus.panache.common.Sort;
import io.quarkus.security.identity.SecurityIdentity;
import io.quarkus.vertx.VertxContextSupport; import io.quarkus.vertx.VertxContextSupport;
import io.smallrye.mutiny.Uni; import io.smallrye.mutiny.Uni;
import io.smallrye.mutiny.unchecked.Unchecked;
import jakarta.enterprise.context.ApplicationScoped; import jakarta.enterprise.context.ApplicationScoped;
import jakarta.inject.Inject; import jakarta.inject.Inject;
import jakarta.ws.rs.BadRequestException;
import java.util.List; import jakarta.ws.rs.ForbiddenException;
import org.eclipse.microprofile.jwt.JsonWebToken;
@WithSession @WithSession
@ -42,8 +52,48 @@ public class MembreService {
return VertxContextSupport.subscribeAndAwait(() -> Panache.withTransaction(() -> repository.findById(id).map(SimpleCombModel::fromModel))); return VertxContextSupport.subscribeAndAwait(() -> Panache.withTransaction(() -> repository.findById(id).map(SimpleCombModel::fromModel)));
} }
public Uni<List<MembreModel>> getAll() { public Uni<PageResult<SimpleMembre>> searchAdmin(int limit, int page, String search, String club) {
return repository.listAll(Sort.ascending("fname", "lname")); if (search == null)
search = "";
search = search + "%";
PanacheQuery<MembreModel> 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<PageResult<SimpleMembre>> 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 -> {
PanacheQuery<MembreModel> 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<PageResult<SimpleMembre>> getPageResult(PanacheQuery<MembreModel> query, int limit, int page) {
return Uni.createFrom().item(new PageResult<SimpleMembre>())
.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<MembreModel> getById(long id) { public Uni<MembreModel> getById(long id) {
@ -70,6 +120,46 @@ public class MembreService {
.invoke(membreModel -> SReqComb.sendIfNeed(serverCustom.clients, SimpleCombModel.fromModel(membreModel))) .invoke(membreModel -> SReqComb.sendIfNeed(serverCustom.clients, SimpleCombModel.fromModel(membreModel)))
.call(membreModel -> (membreModel.getUserId() != null) ? .call(membreModel -> (membreModel.getUserId() != null) ?
keycloakService.setClubGroupMembre(membreModel, membreModel.getClub()) : Uni.createFrom().nullItem()) 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<String> update(long id, ClubMemberForm membre, JsonWebToken idToken, SecurityIdentity securityIdentity) {
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 (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();
}))
.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"); .map(__ -> "OK");
} }

View File

@ -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<UniteLegaleRoot> 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;
});
}
}

View File

@ -2,8 +2,8 @@ package fr.titionfire.ffsaf.rest;
import fr.titionfire.ffsaf.domain.service.ClubService; import fr.titionfire.ffsaf.domain.service.ClubService;
import fr.titionfire.ffsaf.net2.data.SimpleClubModel; import fr.titionfire.ffsaf.net2.data.SimpleClubModel;
import io.quarkus.security.Authenticated;
import io.smallrye.mutiny.Uni; import io.smallrye.mutiny.Uni;
import jakarta.annotation.security.RolesAllowed;
import jakarta.inject.Inject; import jakarta.inject.Inject;
import jakarta.ws.rs.GET; import jakarta.ws.rs.GET;
import jakarta.ws.rs.Path; import jakarta.ws.rs.Path;
@ -20,7 +20,7 @@ public class ClubEndpoints {
@GET @GET
@Path("/no_detail") @Path("/no_detail")
@RolesAllowed("federation_admin") @Authenticated
@Produces(MediaType.APPLICATION_JSON) @Produces(MediaType.APPLICATION_JSON)
public Uni<List<SimpleClubModel>> getAll() { public Uni<List<SimpleClubModel>> getAll() {
return clubService.getAll().map(clubModels -> clubModels.stream().map(SimpleClubModel::fromModel).toList()); return clubService.getAll().map(clubModels -> clubModels.stream().map(SimpleClubModel::fromModel).toList());

View File

@ -1,9 +1,16 @@
package fr.titionfire.ffsaf.rest; package fr.titionfire.ffsaf.rest;
import fr.titionfire.ffsaf.data.model.MembreModel;
import fr.titionfire.ffsaf.domain.service.MembreService; import fr.titionfire.ffsaf.domain.service.MembreService;
import fr.titionfire.ffsaf.rest.data.SimpleMembre; 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.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.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.Uni;
import io.smallrye.mutiny.unchecked.Unchecked; import io.smallrye.mutiny.unchecked.Unchecked;
import jakarta.annotation.security.RolesAllowed; import jakarta.annotation.security.RolesAllowed;
@ -14,16 +21,18 @@ import jakarta.ws.rs.core.MediaType;
import jakarta.ws.rs.core.Response; import jakarta.ws.rs.core.Response;
import jodd.net.MimeTypes; import jodd.net.MimeTypes;
import org.eclipse.microprofile.config.inject.ConfigProperty; import org.eclipse.microprofile.config.inject.ConfigProperty;
import org.eclipse.microprofile.jwt.JsonWebToken;
import java.io.*; import java.io.*;
import java.net.URI; import java.net.URI;
import java.net.URISyntaxException; import java.net.URISyntaxException;
import java.net.URLConnection; import java.net.URLConnection;
import java.nio.file.Files; import java.nio.file.Files;
import java.util.List;
import java.util.concurrent.CompletableFuture; import java.util.concurrent.CompletableFuture;
import java.util.concurrent.Future; import java.util.concurrent.Future;
import java.util.function.Consumer;
@Authenticated
@Path("api/member") @Path("api/member")
public class CombEndpoints { public class CombEndpoints {
@ -33,30 +42,93 @@ public class CombEndpoints {
@ConfigProperty(name = "upload_dir") @ConfigProperty(name = "upload_dir")
String media; String media;
@Inject
@IdToken
JsonWebToken idToken;
@Inject
SecurityIdentity securityIdentity;
Consumer<MembreModel> checkPerm = Unchecked.consumer(membreModel -> {
if (!securityIdentity.getRoles().contains("federation_admin") && !GroupeUtils.isInClubGroup(membreModel.getClub().getId(), idToken))
throw new ForbiddenException();
});
@GET @GET
@Path("/all") @Path("/find/admin")
@RolesAllowed("federation_admin") @RolesAllowed({"federation_admin"})
@Produces(MediaType.APPLICATION_JSON) @Produces(MediaType.APPLICATION_JSON)
public Uni<List<SimpleMembre>> getAll() { public Uni<PageResult<SimpleMembre>> getFindAdmin(@QueryParam("limit") Integer limit, @QueryParam("page") Integer page,
return membreService.getAll().map(membreModels -> membreModels.stream().map(SimpleMembre::fromModel).toList()); @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("/find/club")
@RolesAllowed({"club_president", "club_secretaire", "club_respo_intra"})
@Produces(MediaType.APPLICATION_JSON)
public Uni<PageResult<SimpleMembre>> 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 @GET
@Path("{id}") @Path("{id}")
@RolesAllowed("federation_admin") @RolesAllowed({"federation_admin", "club_president", "club_secretaire", "club_respo_intra"})
@Produces(MediaType.APPLICATION_JSON) @Produces(MediaType.APPLICATION_JSON)
public Uni<SimpleMembre> getById(@PathParam("id") long id) { public Uni<SimpleMembre> getById(@PathParam("id") long id) {
return membreService.getById(id).map(SimpleMembre::fromModel); return membreService.getById(id).onItem().invoke(checkPerm).map(SimpleMembre::fromModel);
} }
@POST @POST
@Path("{id}") @Path("{id}")
@RolesAllowed("federation_admin") @RolesAllowed({"federation_admin"})
@Produces(MediaType.TEXT_PLAIN) @Produces(MediaType.TEXT_PLAIN)
@Consumes(MediaType.MULTIPART_FORM_DATA) @Consumes(MediaType.MULTIPART_FORM_DATA)
public Uni<String> setAdminMembre(@PathParam("id") long id, FullMemberForm input) { public Uni<String> setAdminMembre(@PathParam("id") long id, FullMemberForm input) {
Future<String> future = CompletableFuture.supplyAsync(() -> { return membreService.update(id, input)
try (InputStream is = new BufferedInputStream(new ByteArrayInputStream(input.getPhoto_data()))) { .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<String> setMembre(@PathParam("id") long id, ClubMemberForm input) {
return membreService.update(id, input, idToken, securityIdentity)
.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<String> replacePhoto(long id, byte[] input) {
return CompletableFuture.supplyAsync(() -> {
try (InputStream is = new BufferedInputStream(new ByteArrayInputStream(input))) {
String mimeType = URLConnection.guessContentTypeFromStream(is); String mimeType = URLConnection.guessContentTypeFromStream(is);
String[] detectedExtensions = MimeTypes.findExtensionsByMimeTypes(mimeType, false); String[] detectedExtensions = MimeTypes.findExtensionsByMimeTypes(mimeType, false);
if (detectedExtensions.length == 0) if (detectedExtensions.length == 0)
@ -72,33 +144,17 @@ public class CombEndpoints {
} }
String extension = "." + detectedExtensions[0]; 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"; return "OK";
} catch (IOException e) { } catch (IOException e) {
return e.getMessage(); 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 @GET
@Path("{id}/photo") @Path("{id}/photo")
@RolesAllowed("federation_admin") @RolesAllowed({"federation_admin", "club_president", "club_secretaire", "club_respo_intra"})
public Uni<Response> getPhoto(@PathParam("id") long id) throws URISyntaxException { public Uni<Response> getPhoto(@PathParam("id") long id) throws URISyntaxException {
Future<Pair<File, byte[]>> future = CompletableFuture.supplyAsync(() -> { Future<Pair<File, byte[]>> future = CompletableFuture.supplyAsync(() -> {
FilenameFilter filter = (directory, filename) -> filename.startsWith(String.valueOf(id)); FilenameFilter filter = (directory, filename) -> filename.startsWith(String.valueOf(id));
@ -116,7 +172,7 @@ public class CombEndpoints {
URI uri = new URI("https://mdbcdn.b-cdn.net/img/Photos/new-templates/bootstrap-chat/ava2.webp"); 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 -> { .map(filePair -> {
if (filePair == null) if (filePair == null)
return Response.temporaryRedirect(uri).build(); return Response.temporaryRedirect(uri).build();
@ -130,7 +186,7 @@ public class CombEndpoints {
resp.header(HttpHeaders.CONTENT_DISPOSITION, "inline; "); resp.header(HttpHeaders.CONTENT_DISPOSITION, "inline; ");
return resp.build(); return resp.build();
}); }));
} }
} }

View File

@ -2,13 +2,16 @@ package fr.titionfire.ffsaf.rest;
import fr.titionfire.ffsaf.domain.service.KeycloakService; import fr.titionfire.ffsaf.domain.service.KeycloakService;
import fr.titionfire.ffsaf.rest.from.MemberPermForm; import fr.titionfire.ffsaf.rest.from.MemberPermForm;
import fr.titionfire.ffsaf.utils.GroupeUtils;
import fr.titionfire.ffsaf.utils.Pair;
import io.quarkus.security.identity.SecurityIdentity;
import io.smallrye.mutiny.Uni; import io.smallrye.mutiny.Uni;
import io.vertx.mutiny.core.Vertx;
import jakarta.annotation.security.RolesAllowed; import jakarta.annotation.security.RolesAllowed;
import jakarta.inject.Inject; import jakarta.inject.Inject;
import jakarta.ws.rs.GET; import jakarta.ws.rs.*;
import jakarta.ws.rs.PUT; import org.eclipse.microprofile.jwt.JsonWebToken;
import jakarta.ws.rs.Path; import org.keycloak.representations.idm.GroupRepresentation;
import jakarta.ws.rs.PathParam;
import java.util.ArrayList; import java.util.ArrayList;
import java.util.List; import java.util.List;
@ -19,11 +22,25 @@ public class CompteEndpoints {
@Inject @Inject
KeycloakService service; KeycloakService service;
@Inject
JsonWebToken accessToken;
@Inject
SecurityIdentity securityIdentity;
@Inject
Vertx vertx;
@GET @GET
@Path("{id}") @Path("{id}")
@RolesAllowed("federation_admin") @RolesAllowed({"federation_admin", "club_president", "club_secretaire", "club_respo_intra"})
public Uni<?> getCompte(@PathParam("id") String id) { public Uni<KeycloakService.UserCompteState> getCompte(@PathParam("id") String id) {
return service.fetchCompte(id); return service.fetchCompte(id).call(pair -> vertx.getOrCreateContext().executeBlocking(() -> {
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);
} }
@PUT @PUT
@ -33,6 +50,13 @@ public class CompteEndpoints {
return service.initCompte(id); 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 @GET
@Path("{id}/roles") @Path("{id}/roles")
@RolesAllowed("federation_admin") @RolesAllowed("federation_admin")

View File

@ -0,0 +1,96 @@
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.quarkus.security.identity.SecurityIdentity;
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 {
@Inject
LicenceService licenceService;
@Inject
@IdToken
JsonWebToken idToken;
@Inject
SecurityIdentity securityIdentity;
Consumer<MembreModel> checkPerm = Unchecked.consumer(membreModel -> {
if (!securityIdentity.getRoles().contains("federation_admin") && !GroupeUtils.isInClubGroup(membreModel.getClub().getId(), idToken))
throw new ForbiddenException();
});
@GET
@Path("{id}")
@RolesAllowed({"federation_admin", "club_president", "club_secretaire", "club_respo_intra"})
@Produces(MediaType.APPLICATION_JSON)
public Uni<List<SimpleLicence>> getLicence(@PathParam("id") long id) {
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<List<SimpleLicence>> 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<List<SimpleLicence>> getCurrentSaisonLicenceClub() {
return licenceService.getCurrentSaisonLicence(idToken).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<SimpleLicence> 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);
}
@POST
@Path("club/{id}")
@RolesAllowed({"club_president", "club_secretaire", "club_respo_intra"})
@Produces(MediaType.APPLICATION_JSON)
@Consumes(MediaType.MULTIPART_FORM_DATA)
public Uni<SimpleLicence> 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);
}
}

View File

@ -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<UniteLegaleRoot> get_unite(@PathParam("SIREN") String siren);
}

View File

@ -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();
}
}

View File

@ -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<Etablissement> 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;
}
}

View File

@ -6,6 +6,8 @@ import lombok.Builder;
import lombok.Data; import lombok.Data;
import org.eclipse.microprofile.jwt.JsonWebToken; import org.eclipse.microprofile.jwt.JsonWebToken;
import java.util.ArrayList;
import java.util.List;
import java.util.Set; import java.util.Set;
@Data @Data
@ -19,7 +21,7 @@ public class UserInfo {
String email; String email;
boolean emailVerified; boolean emailVerified;
long expiration; long expiration;
Set<String> groups; List<String> groups;
Set<String> roles; Set<String> roles;
public static UserInfo makeUserInfo(JsonWebToken accessToken, SecurityIdentity securityIdentity) { public static UserInfo makeUserInfo(JsonWebToken accessToken, SecurityIdentity securityIdentity) {
@ -31,7 +33,13 @@ public class UserInfo {
builder.email(accessToken.getClaim("email")); builder.email(accessToken.getClaim("email"));
builder.emailVerified(accessToken.getClaim("email_verified")); builder.emailVerified(accessToken.getClaim("email_verified"));
builder.expiration(accessToken.getExpirationTime()); builder.expiration(accessToken.getExpirationTime());
builder.groups(accessToken.getGroups()); List<String> 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()); builder.roles(securityIdentity.getRoles());
return builder.build(); return builder.build();
} }

View File

@ -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];
}

View File

@ -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 +
'}';
}
}

View File

@ -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;
}

View File

@ -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;
}
}

View File

@ -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<T> {
private int page;
private int page_size;
private int page_count;
private long result_count;
private List<T> result = new ArrayList<>();
}

View File

@ -4,19 +4,17 @@ import io.quarkus.runtime.annotations.RegisterForReflection;
@RegisterForReflection @RegisterForReflection
public enum RoleAsso { public enum RoleAsso {
MEMBRE("Membre"), MEMBRE("Membre", 0),
PRESIDENT("Président"), PRESIDENT("Président", 3),
TRESORIER("Trésorier"), TRESORIER("Trésorier", 1),
SECRETAIRE("Secrétaire"); SECRETAIRE("Secrétaire", 2);
public String name; public final String name;
public final int level;
RoleAsso(String name) { RoleAsso(String name, int level) {
this.name = name;
}
public void setName(String name) {
this.name = name; this.name = name;
this.level = level;
} }
@Override @Override

View File

@ -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;
}
}
}

View File

@ -34,13 +34,16 @@ database.port=3306
database.user=root database.user=root
database.pass= database.pass=
siren-api.key=siren-ap
quarkus.rest-client."fr.titionfire.ffsaf.rest.client.SirenService".url=https://data.siren-api.fr/
#Login #Login
quarkus.oidc.token-state-manager.split-tokens=true quarkus.oidc.token-state-manager.split-tokens=true
quarkus.oidc.token.refresh-expired=true quarkus.oidc.token.refresh-expired=true
quarkus.oidc.authentication.redirect-path=/api/auth/login quarkus.oidc.authentication.redirect-path=/api/auth/login
quarkus.oidc.logout.path=/api/logout 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: # Only the authenticated users can initiate a logout:
quarkus.http.auth.permission.authenticated.paths=api/logout,api/auth/login quarkus.http.auth.permission.authenticated.paths=api/logout,api/auth/login

View File

@ -10,6 +10,8 @@ import {ToastContainer} from "react-toastify";
import './App.css' import './App.css'
import 'react-toastify/dist/ReactToastify.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([ const router = createBrowserRouter([
{ {
@ -25,6 +27,24 @@ const router = createBrowserRouter([
path: 'admin', path: 'admin',
element: <AdminRoot/>, element: <AdminRoot/>,
children: getAdminChildren() children: getAdminChildren()
},
{
path: 'club',
element: <ClubRoot/>,
children: getClubChildren()
},
{
path: 'affiliation',
children: [
{
path: '',
element: <DemandeAff/>
},
{
path: 'ok',
element: <DemandeAffOk/>
}
]
} }
] ]
}, },

View File

@ -14,3 +14,15 @@ export const ColoredCircle = ({color, boolean}) => {
<span className="colored-circle" style={styles}/> <span className="colored-circle" style={styles}/>
</Fragment> </Fragment>
}; };
export const ColoredText = ({boolean, text={true: "Oui", false: "Non"}}) => {
const styles = {color: '#F00'};
if (boolean !== undefined) {
styles.color = (boolean) ? '#00c700' : '#e50000';
}
return <Fragment>
<span className="font-weight-bold" style={styles}>{text[boolean]}</span>
</Fragment>
};

View File

@ -0,0 +1,91 @@
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 <>
<div className="input-group mb-3">
<span className="input-group-text" id="birth_date">Date de naissance</span>
<input type="date" className="form-control" placeholder="jj/mm/aaaa" aria-label="birth_date"
name="birth_date" aria-describedby="birth_date" defaultValue={date} required
onChange={(e) => setDate(e.target.value)}/>
</div>
<div className="row">
<div className="input-group mb-3">
<span className="input-group-text" id="category">Catégorie</span>
<input type="text" className="form-control" placeholder="" name="category"
aria-label="category" value={category} aria-describedby="category"
disabled/>
{canUpdate && <button className="btn btn-outline-secondary" type="button" id="button-addon1"
onClick={updateCat}>Mettre à jours</button>}
</div>
</div>
</>
}
export function OptionField({name, text, values, value, disabled=false}) {
return <div className="row">
<div className="input-group mb-3">
<label className="input-group-text" id={name}>{text}</label>
<select className="form-select" id={name} name={name} defaultValue={value} required disabled={disabled}>
{Object.keys(values).map((key, _) => {
return (<option key={key} value={key}>{values[key]}</option>)
})}
</select>
</div>
</div>
}
export function TextField({name, text, value, placeholder, type = "text"}) {
return <div className="row">
<div className="input-group mb-3">
<span className="input-group-text" id={name}>{text}</span>
<input type={type} className="form-control" placeholder={placeholder ? placeholder : text} aria-label={name}
name={name} aria-describedby={name} defaultValue={value} required/>
</div>
</div>
}
export function CheckField({name, text, value, row = false}) {
return <>{
row ?
<div className="row">
<div className="input-group mb-3">
<div className="form-check">
<input className="form-check-input" type="checkbox" id={name} name={name}
defaultChecked={value}/>
<label className="form-check-label" htmlFor={name}>{text}</label>
</div>
</div>
</div>
: <div className="form-check">
<input className="form-check-input" type="checkbox" id={name} name={name} defaultChecked={value}/>
<label className="form-check-label" htmlFor={name}>{text}</label>
</div>
}
</>
}
export const Checkbox = ({ label, value, onChange }) => {
const handleChange = () => {
onChange(!value);
};
return <div className="form-check">
<input className="form-check-input" type="checkbox" id="checkbox1" checked={value} onChange={handleChange}/>
<label className="form-check-label" htmlFor="checkbox1">{label}</label>
</div>
};

View File

@ -22,7 +22,9 @@ export function Nav() {
<div className="collapse-item"> <div className="collapse-item">
<ul className="navbar-nav"> <ul className="navbar-nav">
<li className="nav-item"><NavLink className="nav-link" to="/">Accueil</NavLink></li> <li className="nav-item"><NavLink className="nav-link" to="/">Accueil</NavLink></li>
<ClubMenu/>
<AdminMenu/> <AdminMenu/>
<AffiliationMenu/>
<LoginMenu/> <LoginMenu/>
</ul> </ul>
</div> </div>
@ -31,6 +33,33 @@ export function Nav() {
</nav> </nav>
} }
function AffiliationMenu() {
const {is_authenticated} = useAuth()
if (is_authenticated)
return <></>
return <li className="nav-item"><NavLink className="nav-link" to="/affiliation">Demande d'affiliation</NavLink></li>
}
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 <li className="nav-item dropdown">
<div className="nav-link dropdown-toggle" role="button" data-bs-toggle="dropdown" aria-expanded="false">
Club
</div>
<ul className="dropdown-menu">
<li className="nav-item"><NavLink className="nav-link" to="/club/member">Member</NavLink></li>
<li className="nav-item"><NavLink className="nav-link" to="/club/b">B</NavLink></li>
</ul>
</li>
}
function AdminMenu() { function AdminMenu() {
const {is_authenticated, userinfo} = useAuth() const {is_authenticated, userinfo} = useAuth()

View File

@ -19,12 +19,16 @@ export function useFetch(url, setLoading = null, loadingLevel = 1, config = {})
const [data, setData] = useState(null) const [data, setData] = useState(null)
const [error, setErrors] = useState(null) const [error, setErrors] = useState(null)
useEffect(() => { const refresh = (url) => {
stdAction(apiAxios.get(url, config), setData, setErrors, setLoading, loadingLevel) stdAction(apiAxios.get(url, config), setData, setErrors, setLoading, loadingLevel)
}
useEffect(() => {
refresh(url)
}, []); }, []);
return { return {
data, error data, error, refresh
} }
} }

View File

@ -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 <div>
<h1>Demande d'affiliation</h1>
<p>L'affiliation est annuelle et valable pour une saison sportive : du 1er septembre au 31 août de lannée suivante.</p>
Pour saffilier, une association sportive doit réunir les conditions suivantes :
<ul>
<li>Avoir son siège social en France ou Principauté de Monaco</li>
<li>Être constituée conformément au chapitre 1er du titre II du livre 1er du Code du Sport</li>
<li>Poursuivre un objet social entrant dans la définition de larticle 1 des statuts de la Fédération</li>
<li>Disposer de statuts compatibles avec les principes dorganisation et de fonctionnement de la Fédération</li>
<li>Assurer en son sein la liberté dopinion et le respect des droits de la défense, et sinterdire toute discrimination</li>
<li>Respecter les règles dencadrement, dhygiène et de sécurité établies par les règlements de la Fédération</li>
</ul>
<div className="card mb-4">
<form onSubmit={submit}>
<div className="card-body">
<h4>L'association</h4>
<AssoInfo/>
<h4>Le président</h4>
<MembreInfo role="president"/>
<h4>Le trésorier</h4>
<MembreInfo role="tresorier"/>
<h4>Le secrétaire</h4>
<MembreInfo role="secretaire"/>
<div className="mb-3">
<p>Après validation de votre demande, vous recevrez un login et mot de passe provisoire pour accéder à votre espace FFSAF</p>
Notez que pour finaliser votre affiliation, il vous faudra :
<ul>
<li>Disposer dau moins trois membres licenciés, dont le président, le trésorier et le secrétaire</li>
<li>S'être acquitté des cotisations prévues par les règlements fédéraux</li>
</ul>
</div>
<div className="row">
<div className="d-grid gap-2 d-md-flex justify-content-md-center">
<button type="submit" className="btn btn-primary">Confirmer ma demande d'affiliation</button>
</div>
</div>
</div>
</form>
</div>
</div>
}
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 <>
<div className="input-group mb-3">
<span className="input-group-text" id="basic-addon1">Nom de l'association*</span>
<input type="text" className="form-control" placeholder="Nom de l'association" name="name" aria-label="Nom de l'association"
aria-describedby="basic-addon1" required/>
</div>
<div className="input-group mb-3">
<span className="input-group-text">N° SIREN*</span>
<input type="number" className="form-control" placeholder="siren" name="siren" required value={siren}
onChange={e => setSiren(e.target.value)}/>
<button className="btn btn-outline-secondary" type="button" id="button-addon2" onClick={fetchSiren}>Rechercher</button>
</div>
<div className="input-group mb-3">
<span className="input-group-text" id="basic-addon1">Dénomination</span>
<input type="text" className="form-control" placeholder="Appuyer sur rechercher pour compléter" aria-label="Dénomination"
aria-describedby="basic-addon1" disabled value={denomination} readOnly/>
</div>
<div className="input-group mb-3">
<span className="input-group-text" id="basic-addon1">RNA</span>
<input type="text" className="form-control" placeholder="RNA" aria-label="RNA" aria-describedby="basic-addon1"
disabled={!rnaEnable} name="rna" value={rna} onChange={e => setRna(e.target.value)}/>
</div>
<div className="input-group mb-3">
<span className="input-group-text" id="basic-addon1">Adresse*</span>
<input type="text" className="form-control" placeholder="Adresse" aria-label="Adresse" aria-describedby="basic-addon1"
required value={adresse} name="adresse" onChange={e => setAdresse(e.target.value)}/>
</div>
<div className="input-group mb-3">
<label className="input-group-text" htmlFor="status">Status*</label>
<input type="file" className="form-control" id="status" name="status" accept=".pdf,.txt" required/>
</div>
<div className="input-group mb-3">
<label className="input-group-text" htmlFor="logo">Logo*</label>
<input type="file" className="form-control" id="logo" name="logo" accept=".jpg,.jpeg,.gif,.png,.svg" required/>
</div>
</>;
}
function MembreInfo({role}) {
return <div className="row g-3 mb-3">
<div className="col-sm-3">
<div className="form-floating">
<input type="text" className="form-control" id="floatingInput" placeholder="Nom" name={role + "-nom"}/>
<label htmlFor="floatingInput">Nom*</label>
</div>
</div>
<div className="col-sm-3">
<div className="form-floating">
<input type="text" className="form-control" id="floatingInput" placeholder="Prénom" name={role + "-prenom"}/>
<label htmlFor="floatingInput">Prénom*</label>
</div>
</div>
<div className="col-sm-5">
<div className="form-floating">
<input type="email" className="form-control" id="floatingInput" placeholder="name@example.com" name={role + "-mail"}/>
<label htmlFor="floatingInput">Email*</label>
</div>
</div>
</div>
}
export function DemandeAffOk() {
return (
<div>
<h1 className="text-green-800 text-4xl">Demande d'affiliation envoyée avec succès</h1>
<p>Une fois votre demande validée, vous recevrez un login et mot de passe provisoire pour accéder à votre espace FFSAF</p>
</div>
);
}

View File

@ -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 <>
<div>
<div className="row">
<div className="col-lg-9">
<SearchBar search={search}/>
{data
? <MakeCentralPanel data={data} visibleMember={memberData} navigate={navigate} showLicenceState={showLicenceState}
page={page}/>
: error
? <AxiosError error={error}/>
: <Def/>
}
</div>
<div className="col-lg-3">
<div className="mb-4">
<button className="btn btn-primary" onClick={() => navigate("new")}>Ajouter un membre</button>
</div>
<div className="card mb-4">
<div className="card-header">Filtre</div>
<div className="card-body">
<FiltreBar showLicenceState={showLicenceState} setShowLicenceState={setShowLicenceState} data={data}
clubFilter={clubFilter} setClubFilter={setClubFilter} source={source}/>
</div>
</div>
</div>
</div>
</div>
</>
}
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 <div className="mb-3">
<div className="input-group mb-3">
<input type="text" className="form-control" placeholder="Rechercher..." aria-label="Rechercher..."
aria-describedby="button-addon2" value={searchInput} onChange={handelChange} onKeyDown={handleKeyDown}/>
<button className="btn btn-outline-secondary" type="button" id="button-addon2"
onClick={searchMember}>Rechercher
</button>
</div>
</div>
}
function MakeCentralPanel({data, visibleMember, navigate, showLicenceState, page}) {
const pages = []
for (let i = 1; i <= data.page_count; i++) {
pages.push(<li key={i} className={"page-item " + ((page === i) ? "active" : "")}>
<span className="page-link" onClick={() => navigate("#" + i)}>{i}</span>
</li>);
}
return <>
<div className="mb-4">
<small>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})</small>
<div className="list-group">
{visibleMember.map(member => (<MakeRow key={member.id} member={member} navigate={navigate} showLicenceState={showLicenceState}/>))}
</div>
</div>
<div className="mb-4">
<nav aria-label="Page navigation">
<ul className="pagination justify-content-center">
<li className={"page-item" + ((page <= 1) ? " disabled" : "")}>
<span className="page-link" onClick={() => navigate("#" + (page - 1))}>&laquo;</span></li>
{pages}
<li className={"page-item" + ((page >= data.page_count) ? " disabled" : "")}>
<span className="page-link" onClick={() => navigate("#" + (page + 1))}>&raquo;</span></li>
</ul>
</nav>
</div>
</>
}
function MakeRow({member, showLicenceState, navigate}) {
const rowContent = <>
<div className="row">
<span className="col-auto">{String(member.licence_number).padStart(5, '0')}</span>
<div className="ms-2 col-auto">
<div className="fw-bold">{member.fname} {member.lname}</div>
</div>
</div>
<small>{member.club?.name || "Sans club"}</small>
</>
if (showLicenceState && member.licence != null) {
return <div
className={"list-group-item d-flex justify-content-between align-items-start list-group-item-action list-group-item-"
+ (member.licence.validate ? "success" : (member.licence.certificate ? "warning" : "danger"))}
onClick={() => navigate("" + member.id)}>{rowContent}</div>
} else {
return <div className="list-group-item d-flex justify-content-between align-items-start list-group-item-action"
onClick={() => navigate("" + member.id)}>
{rowContent}
</div>
}
}
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 <div>
<div className="mb-3">
<Checkbox value={showLicenceState} onChange={setShowLicenceState} label="Afficher l'état des licences"/>
</div>
{source !== "club" &&
<div className="mb-3">
<select className="form-select" value={clubFilter} onChange={event => setClubFilter(event.target.value)}>
<option value="">--- tout les clubs ---</option>
{allClub && allClub.map((value, index) => {
return <option key={index} value={value}>{value}</option>
})
}
</select>
</div>
}
</div>
}
function Def() {
return <div className="list-group">
<li className="list-group-item"><ThreeDots/></li>
<li className="list-group-item"><ThreeDots/></li>
<li className="list-group-item"><ThreeDots/></li>
<li className="list-group-item"><ThreeDots/></li>
<li className="list-group-item"><ThreeDots/></li>
</div>
}

View File

@ -1,8 +1,8 @@
import {Outlet} from "react-router-dom"; import {Outlet} from "react-router-dom";
import './AdminRoot.css' import './AdminRoot.css'
import {LoadingProvider} from "../../hooks/useLoading.jsx"; import {LoadingProvider} from "../../hooks/useLoading.jsx";
import {MemberList} from "./MemberList.jsx"; import {MemberList} from "../MemberList.jsx";
import {MemberPage} from "./MemberPage.jsx"; import {MemberPage} from "./member/MemberPage.jsx";
export function AdminRoot() { export function AdminRoot() {
return <> return <>
@ -17,7 +17,7 @@ export function getAdminChildren () {
return [ return [
{ {
path: 'member', path: 'member',
element: <MemberList/> element: <MemberList source="admin"/>
}, },
{ {
path: 'member/:id', path: 'member/:id',

View File

@ -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 <>
<SearchBar searchInput={searchInput} onSearchInputChange={setSearchInput}/>
{data
? <div className="list-group">
{visibleMember.map(member => (
<span key={member.id}
onClick={() => navigate("/admin/member/" + member.id)}
className="list-group-item list-group-item-action">{member.fname} {member.lname}</span>))}
</div>
: error
? <AxiosError error={error}/>
: <Def/>
}
</>
}
function SearchBar({searchInput, onSearchInputChange}) {
return <div>
<div className="mb-3">
<Input value={searchInput} onChange={onSearchInputChange} placeholder="Rechercher..."/>
</div>
</div>
}
function Def() {
return <div className="list-group">
<li className="list-group-item"><ThreeDots/></li>
<li className="list-group-item"><ThreeDots/></li>
<li className="list-group-item"><ThreeDots/></li>
<li className="list-group-item"><ThreeDots/></li>
<li className="list-group-item"><ThreeDots/></li>
</div>
}

View File

@ -1,411 +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 <>
<h2>Page membre</h2>
<button type="button" className="btn btn-link" onClick={() => navigate("/admin/member")}>
&lt;&lt; retour
</button>
{data
? <div>
<div className="row">
<div className="col-lg-4">
<PhotoCard data={data}/>
<LoadingProvider><CompteInfo userData={data}/></LoadingProvider>
</div>
<div className="col-lg-8">
<InformationForm data={data}/>
<LoadingProvider><PremForm userData={data}/></LoadingProvider>
<div className="row">
<LicenceCard/>
<SelectCard/>
</div>
</div>
</div>
</div>
: error && <AxiosError error={error}/>
}
</>
}
function PhotoCard({data}) {
return <div className="card mb-4">
<div className="card-header">Licence n°{data.licence}</div>
<div className="card-body text-center">
<div className="input-group mb-3">
<img
src={`${vite_url}/api/member/${data.id}/photo`}
alt="avatar"
className="rounded-circle img-fluid" style={{object_fit: 'contain'}}/>
</div>
</div>
</div>;
}
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 <form onSubmit={handleSubmit}>
<div className="card mb-4">
<div className="card-header">Information</div>
<div className="card-body">
<TextField name="lname" text="Nom" value={data.lname}/>
<TextField name="fname" text="Prénom" value={data.fname}/>
<TextField name="email" text="Email" value={data.email} placeholder="name@example.com"
type="email"/>
<OptionField name="genre" text="Genre" value={data.genre}
values={{NA: 'N/A', H: 'H', F: 'F'}}/>
<OptionField name="country" text="Pays" value={data.country}
values={{NA: 'Sélectionner...', fr: 'FR', es: 'ES', be: 'BE'}}/>
<BirthDayField inti_date={data.birth_date ? data.birth_date.split('T')[0] : ''}
inti_category={data.categorie}/>
<div className="row">
<ClubSelect defaultValue={data?.club?.id} name="club"/>
</div>
<OptionField name="role" text="Rôle" value={data.role}
values={{
MEMBRE: 'Membre',
PRESIDENT: 'Président',
TRESORIER: 'Trésorier',
SECRETAIRE: 'Secrétaire'
}}/>
<OptionField name="grade_arbitrage" text="Grade d'arbitrage" value={data.grade_arbitrage}
values={{NA: 'N/A', ASSESSEUR: 'Assesseur', ARBITRE: 'Arbitre'}}/>
<div className="row">
<div className="input-group mb-3">
<label className="input-group-text" htmlFor="url_photo">Photos
(optionnelle)</label>
<input type="file" className="form-control" id="url_photo" name="url_photo"
accept=".jpg,.jpeg,.gif,.png,.svg"/>
</div>
</div>
<div className="row">
<div className="col-md-12 text-right">
<button type="submit" className="btn btn-primary">Enregistrer</button>
</div>
</div>
</div>
</div>
</form>;
}
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 <form onSubmit={handleSubmitPerm}>
<div className="card mb-4">
<div className="card-header">Permission</div>
<div className="card-body">
<div className="row g-3">
{userData.userId
? <PremFormContent userData={userData}/>
: <div className="col">
<div className="input-group mb-3">
<div>Ce membre ne dispose pas de compte...</div>
</div>
</div>
}
</div>
<div className="row">
<div className="col-md-12 text-right">
{userData.userId && <button type="submit" className="btn btn-primary">Enregistrer</button>}
</div>
</div>
</div>
</div>
</form>
}
function PremFormContent({userData}) {
const setLoading = useLoadingSwitcher()
const {data, error} = useFetch(`/compte/${userData.userId}/roles`, setLoading, 1)
return <>
<div className="col">
<h5>FFSAF intra</h5>
{data
? <>
<CheckField name="federation_admin" text="Accès à l'intra"
value={data.includes("federation_admin")}/>
</>
: error && <AxiosError error={error}/>}
</div>
<div className="col">
<h5>SAFCA</h5>
{data
? <>
<CheckField name="safca_user" text="Accès à l'application" value={data.includes("safca_user")}/>
<CheckField name="safca_create_compet" text="Créer des compétion"
value={data.includes("safca_create_compet")}/>
<CheckField name="safca_super_admin" text="Super administrateur"
value={data.includes("safca_super_admin")}/>
</>
: error && <AxiosError error={error}/>}
</div>
</>
}
function LicenceCard() {
return <div className="col-md-6">
<div className="card mb-4 mb-md-0">
<div className="card-header">Licence</div>
<div className="card-body">
<p className="mb-1">Web Design</p>
</div>
</div>
</div>;
}
function SelectCard() {
return <div className="col-md-6">
<div className="card mb-4 mb-md-0">
<div className="card-header">Sélection en équipe de France</div>
<div className="card-body">
<p className="mb-1">Web Design</p>
</div>
</div>
</div>;
}
function CompteInfo({userData}) {
const creatAccount = () => {
let err = {};
toast.promise(
apiAxios.put(`/compte/${userData.id}/init`).catch(e => {
err = e
}),
{
pending: 'Création du compte en cours',
success: 'Compte créé avec succès 🎉',
error: 'Échec de la création du compte 😕 (code: ' + err.response.status + ')'
}
)
}
return <div className="card mb-4">
<div className="card-header">Compte</div>
<div className="card-body text-center">
{userData.userId
? <CompteInfoContent userData={userData}/>
:
<>
<div className="row">
<div className="input-group mb-3">
<div>Ce membre ne dispose pas de compte...</div>
</div>
</div>
<div className="row">
<div className="input-group mb-3">
<button className="btn btn-primary" onClick={creatAccount}>Initialiser le compte</button>
</div>
</div>
</>
}
</div>
</div>
}
function CompteInfoContent({
userData
}) {
const setLoading = useLoadingSwitcher()
const {data, error} = useFetch(`/compte/${userData.userId}`, setLoading, 1)
return <>
{data
? <>
<div className="row">
<div className="input-group mb-3">
<div>Identifiant: {data.login}</div>
</div>
</div>
<div className="row">
<div className="input-group mb-3">
<div>Activer: <ColoredCircle boolean={data.enabled}/></div>
</div>
</div>
<div className="row">
<div className="input-group mb-3">
<div>Email vérifié: <ColoredCircle boolean={data.emailVerified}/></div>
</div>
</div>
</>
: error && <AxiosError error={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 <>
<div className="input-group mb-3">
<span className="input-group-text" id="birth_date">Date de naissance</span>
<input type="date" className="form-control" placeholder="jj/mm/aaaa" aria-label="birth_date"
name="birth_date" aria-describedby="birth_date" defaultValue={date} required
onChange={(e) => setDate(e.target.value)}/>
</div>
<div className="row">
<div className="input-group mb-3">
<span className="input-group-text" id="category">Catégorie</span>
<input type="text" className="form-control" placeholder="" name="category"
aria-label="category" value={category} aria-describedby="category"
disabled/>
{canUpdate && <button className="btn btn-outline-secondary" type="button" id="button-addon1"
onClick={updateCat}>Mettre à jours</button>}
</div>
</div>
</>
}
function OptionField({name, text, values, value}) {
return <div className="row">
<div className="input-group mb-3">
<label className="input-group-text" id={name}>{text}</label>
<select className="form-select" id={name} name={name} defaultValue={value} required>
{Object.keys(values).map((key, _) => {
return (<option key={key} value={key}>{values[key]}</option>)
})}
</select>
</div>
</div>
}
function TextField({name, text, value, placeholder, type = "text"}) {
return <div className="row">
<div className="input-group mb-3">
<span className="input-group-text" id={name}>{text}</span>
<input type={type} className="form-control" placeholder={placeholder ? placeholder : text} aria-label={name}
name={name} aria-describedby={name} defaultValue={value} required/>
</div>
</div>
}
function CheckField({name, text, value, row = false}) {
return <>{
row ?
<div className="row">
<div className="input-group mb-3">
<div className="form-check">
<input className="form-check-input" type="checkbox" id={name} name={name}
defaultChecked={value}/>
<label className="form-check-label" htmlFor={name}>{text}</label>
</div>
</div>
</div>
: <div className="form-check">
<input className="form-check-input" type="checkbox" id={name} name={name} defaultChecked={value}/>
<label className="form-check-label" htmlFor={name}>{text}</label>
</div>
}
</>
}

View File

@ -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 <div className="card mb-4">
<div className="card-header">
<div className="btn-group dropend">
<div className="dropdown-toggle" data-bs-toggle="dropdown" aria-expanded="false">
Compte
</div>
<ul className="dropdown-menu">
<li>
<button type="button" className="btn btn-primary" data-bs-toggle="modal"
data-bs-target="#comptIdModal">Définir l'id du compte
</button>
</li>
</ul>
</div>
</div>
<div className="card-body text-center">
{userData.userId
? <CompteInfoContent userData={userData}/>
:
<>
<div className="row">
<div className="input-group mb-3">
<div>Ce membre ne dispose pas de compte...</div>
</div>
</div>
<div className="row">
<div className="input-group mb-3">
<button className="btn btn-primary" onClick={creatAccount}>Initialiser le compte</button>
</div>
</div>
</>
}
</div>
<div className="modal fade" id="comptIdModal" tabIndex="-1" aria-labelledby="comptIdModalLabel"
aria-hidden="true">
<div className="modal-dialog">
<div className="modal-content">
<form onSubmit={sendId}>
<div className="modal-header">
<h1 className="modal-title fs-5" id="comptIdModalLabel">Entré l'UUID du compte</h1>
<button type="button" className="btn-close" data-bs-dismiss="modal"
aria-label="Close"></button>
</div>
<div className="modal-body">
<h5>Attention ne changée l'id d'un membre que si vous êtes sûr de ce que vos faites...</h5>
<input type="text" className="form-control" placeholder="uuid" name="uuid"
defaultValue={userData.userId}/>
</div>
<div className="modal-footer">
<button type="submit" className="btn btn-primary" data-bs-dismiss="modal">Appliquer</button>
<button type="reset" className="btn btn-secondary" data-bs-dismiss="modal">Fermer</button>
</div>
</form>
</div>
</div>
</div>
</div>
}
function CompteInfoContent({userData}) {
const setLoading = useLoadingSwitcher()
const {data, error} = useFetch(`/compte/${userData.userId}`, setLoading, 1)
return <>
{data
? <>
<div className="row">
<div className="input-group mb-3">
<div>Identifiant: {data.login}</div>
</div>
</div>
<div className="row">
<div className="input-group mb-3">
<div>Activer: <ColoredCircle boolean={data.enabled}/></div>
</div>
</div>
<div className="row">
<div className="input-group mb-3">
<div>Email vérifié: <ColoredCircle boolean={data.emailVerified}/></div>
</div>
</div>
</>
: error && <AxiosError error={error}/>
} </>
}

View File

@ -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 "../../../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("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 <form onSubmit={handleSubmit}>
<div className="card mb-4">
<div className="card-header">Information</div>
<div className="card-body">
<TextField name="lname" text="Nom" value={data.lname}/>
<TextField name="fname" text="Prénom" value={data.fname}/>
<TextField name="email" text="Email" value={data.email} placeholder="name@example.com"
type="email"/>
<OptionField name="genre" text="Genre" value={data.genre}
values={{NA: 'N/A', H: 'H', F: 'F'}}/>
<OptionField name="country" text="Pays" value={data.country}
values={{NA: 'Sélectionner...', fr: 'FR', es: 'ES', be: 'BE'}}/>
<BirthDayField inti_date={data.birth_date ? data.birth_date.split('T')[0] : ''}
inti_category={data.categorie}/>
<div className="row">
<ClubSelect defaultValue={data?.club?.id} name="club"/>
</div>
<OptionField name="role" text="Rôle" value={data.role}
values={{
MEMBRE: 'Membre',
PRESIDENT: 'Président',
TRESORIER: 'Trésorier',
SECRETAIRE: 'Secrétaire'
}}/>
<OptionField name="grade_arbitrage" text="Grade d'arbitrage" value={data.grade_arbitrage}
values={{NA: 'N/A', ASSESSEUR: 'Assesseur', ARBITRE: 'Arbitre'}}/>
<div className="row">
<div className="input-group mb-3">
<label className="input-group-text" htmlFor="url_photo">Photos
(optionnelle)</label>
<input type="file" className="form-control" id="url_photo" name="url_photo"
accept=".jpg,.jpeg,.gif,.png,.svg"/>
</div>
</div>
<div className="row">
<div className="d-grid gap-2 d-md-flex justify-content-md-end">
<button type="submit" className="btn btn-primary">Enregistrer</button>
</div>
</div>
</div>
</div>
</form>;
}

View File

@ -0,0 +1,195 @@
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 "../../../components/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 <div className="card mb-4 mb-md-0">
<div className="card-header container-fluid">
<div className="row">
<div className="col">Licence</div>
<div className="col" style={{textAlign: 'right'}}>
<button className="btn btn-primary btn-sm" data-bs-toggle="modal" data-bs-target="#LicenceModal"
onClick={_ => setModal({id: -1, membre: userData.id})}>Ajouter
</button>
</div>
</div>
</div>
<div className="card-body">
<ul className="list-group">
{licences.map((licence, index) => {
return <div key={index}
className={"list-group-item d-flex justify-content-between align-items-start list-group-item-" +
(licence.validate ? "success" : (licence.certificate ? "warning" : "danger"))}>
<div className="me-auto">{licence?.saison}-{licence?.saison + 1}</div>
<button className="badge btn btn-primary rounded-pill" data-bs-toggle="modal"
data-bs-target="#LicenceModal" onClick={_ => setModal(licence)}>
<FontAwesomeIcon icon={faPen}/></button>
</div>
})}
{error && <AxiosError error={error}/>}
</ul>
</div>
<div className="modal fade" id="LicenceModal" tabIndex="-1" aria-labelledby="LicenceModalLabel"
aria-hidden="true">
<div className="modal-dialog">
<div className="modal-content">
<ModalContent licence={modalLicence} dispatch={dispatch}/>
</div>
</div>
</div>
</div>;
}
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(_ => {
dispatch({type: 'REMOVE', payload: 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 <form onSubmit={e => sendLicence(e, dispatch)}>
<input name="id" value={licence.id} readOnly hidden/>
<input name="membre" value={licence.membre} readOnly hidden/>
<div className="modal-header">
<h1 className="modal-title fs-5" id="LicenceModalLabel">Edition de la licence</h1>
<button type="button" className="btn-close" data-bs-dismiss="modal"
aria-label="Close"></button>
</div>
<div className="modal-body">
<div className="input-group mb-3 justify-content-md-center">
{isNew
? <input type="number" className="form-control" placeholder="Saison" name="saison"
aria-label="Saison" aria-describedby="basic-addon2" value={saison} onChange={setSeason}/>
: <><span className="input-group-text" id="basic-addon2">{saison}</span>
<input name="saison" value={saison} readOnly hidden/></>}
<span className="input-group-text" id="basic-addon2">-</span>
<span className="input-group-text" id="basic-addon2">{saison + 1}</span>
</div>
<RadioGroupeOnOff name="certificate" text="Certificat médical valide" value={certificate}
onChange={handleCertificateChange}/>
<RadioGroupeOnOff name="validate" text="Validation de la licence" value={validate}
onChange={handleValidateChange}/>
</div>
<div className="modal-footer">
<button type="submit" className="btn btn-primary" data-bs-dismiss="modal">Enregistrer</button>
<button type="reset" className="btn btn-secondary" data-bs-dismiss="modal">Annuler</button>
{isNew || <button type="button" className="btn btn-danger" data-bs-dismiss="modal"
onClick={() => removeLicence(licence.id, dispatch)}>Supprimer</button>}
</div>
</form>
}
function RadioGroupeOnOff({value, onChange, name, text}) {
return <div className="btn-group input-group mb-3 justify-content-md-center" role="group"
aria-label="Basic radio toggle button group">
<span className="input-group-text">{text}</span>
<input type="radio" className="btn-check" id={"btnradio1" + name} autoComplete="off"
value="false" checked={value === false} onChange={onChange}/>
<label className="btn btn-outline-primary" htmlFor={"btnradio1" + name}>Non</label>
<input type="radio" className="btn-check" name={name} id={"btnradio2" + name} autoComplete="off"
value="true" checked={value === true} onChange={onChange}/>
<label className="btn btn-outline-primary" htmlFor={"btnradio2" + name}>Oui</label>
</div>;
}

View File

@ -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 <>
<h2>Page membre</h2>
<button type="button" className="btn btn-link" onClick={() => navigate("/admin/member")}>
&laquo; retour
</button>
{data
? <div>
<div className="row">
<div className="col-lg-4">
<PhotoCard data={data}/>
<LoadingProvider><CompteInfo userData={data}/></LoadingProvider>
</div>
<div className="col-lg-8">
<InformationForm data={data}/>
<LoadingProvider><PremForm userData={data}/></LoadingProvider>
<div className="row">
<div className="col-md-6">
<LoadingProvider><LicenceCard userData={data}/></LoadingProvider>
</div>
<div className="col-md-6">
<LoadingProvider><SelectCard/></LoadingProvider>
</div>
</div>
</div>
</div>
</div>
: error && <AxiosError error={error}/>
}
</>
}
function PhotoCard({data}) {
return <div className="card mb-4">
<div className="card-header">Licence n°{data.licence}</div>
<div className="card-body text-center">
<div className="input-group mb-3">
<img
src={`${vite_url}/api/member/${data.id}/photo`}
alt="avatar"
className="rounded-circle img-fluid" style={{object_fit: 'contain'}}/>
</div>
</div>
</div>;
}
function SelectCard() {
return <div className="card mb-4 mb-md-0">
<div className="card-header">Sélection en équipe de France</div>
<div className="card-body">
<p className="mb-1">Web Design</p>
</div>
</div>;
}

View File

@ -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 "../../../components/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 <form onSubmit={handleSubmitPerm}>
<div className="card mb-4">
<div className="card-header">Permission</div>
<div className="card-body">
<div className="row g-3">
{userData.userId
? <PremFormContent userData={userData}/>
: <div className="col">
<div className="input-group mb-3">
<div>Ce membre ne dispose pas de compte...</div>
</div>
</div>
}
</div>
<div className="row">
<div className="d-grid gap-2 d-md-flex justify-content-md-end">
{userData.userId && <button type="submit" className="btn btn-primary">Enregistrer</button>}
</div>
</div>
</div>
</div>
</form>
}
function PremFormContent({userData}) {
const setLoading = useLoadingSwitcher()
const {data, error} = useFetch(`/compte/${userData.userId}/roles`, setLoading, 1)
return <>
<div className="col">
<h5>FFSAF intra</h5>
{data
? <>
<CheckField name="federation_admin" text="Administrateur de la fédération"
value={data.includes("federation_admin")}/>
</>
: error && <AxiosError error={error}/>}
</div>
<div className="col">
<h5>SAFCA</h5>
{data
? <>
<CheckField name="safca_user" text="Accès à l'application" value={data.includes("safca_user")}/>
<CheckField name="safca_create_compet" text="Créer des compétion"
value={data.includes("safca_create_compet")}/>
<CheckField name="safca_super_admin" text="Super administrateur"
value={data.includes("safca_super_admin")}/>
</>
: error && <AxiosError error={error}/>}
</div>
</>
}

View File

@ -0,0 +1,43 @@
import {Outlet} from "react-router-dom";
import {LoadingProvider} from "../../hooks/useLoading.jsx";
import {MemberPage} from "./member/MemberPage.jsx";
import {useAuth} from "../../hooks/useAuth.jsx";
import {MemberList} from "../MemberList.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 <>
<div style={{display: 'flex', flexDirection: 'row', alignItems: 'center', flexWrap: 'wrap'}}>
<h1>Espace club</h1><h3 style={{marginLeft: '0.75em'}}>{club}</h3></div>
<LoadingProvider>
<Outlet/>
</LoadingProvider>
</>
}
export function getClubChildren() {
return [
{
path: 'member',
element: <MemberList source="club"/>
},
{
path: 'member/:id',
element: <MemberPage/>
},
{
path: 'b',
element: <div>Club B</div>
}
]
}

View File

@ -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 <div className="card mb-4">
<div className="card-header">Compte</div>
<div className="card-body text-center">
{userData.userId
? <CompteInfoContent userData={userData}/>
:
<>
<div className="row">
<div className="input-group mb-3">
<div>Ce membre ne dispose pas de compte... <br/>
Un compte sera créé par la fédération lors de la validation de sa première licence
</div>
</div>
</div>
</>
}
</div>
</div>
}
function CompteInfoContent({userData}) {
const setLoading = useLoadingSwitcher()
const {data, error} = useFetch(`/compte/${userData.userId}`, setLoading, 1)
return <>
{data
? <>
<div className="row">
<div className="input-group mb-3">
<div>Identifiant: {data.login}</div>
</div>
</div>
<div className="row">
<div className="input-group mb-3">
<div>Activer: <ColoredCircle boolean={data.enabled}/></div>
</div>
</div>
<div className="row">
<div className="input-group mb-3">
<div>Email vérifié: <ColoredCircle boolean={data.emailVerified}/></div>
</div>
</div>
</>
: error && <AxiosError error={error}/>
} </>
}

View File

@ -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 <form onSubmit={handleSubmit}>
<div className="card mb-4">
<div className="card-header">Information</div>
<div className="card-body">
<TextField name="lname" text="Nom" value={data.lname}/>
<TextField name="fname" text="Prénom" value={data.fname}/>
<TextField name="email" text="Email" value={data.email} placeholder="name@example.com"
type="email"/>
<OptionField name="genre" text="Genre" value={data.genre}
values={{NA: 'N/A', H: 'H', F: 'F'}}/>
<OptionField name="country" text="Pays" value={data.country}
values={{NA: 'Sélectionner...', fr: 'FR', es: 'ES', be: 'BE'}}/>
<BirthDayField inti_date={data.birth_date ? data.birth_date.split('T')[0] : ''}
inti_category={data.categorie}/>
<OptionField name="role" text="Rôle" value={data.role}
values={{
MEMBRE: 'Membre',
PRESIDENT: 'Président',
TRESORIER: 'Trésorier',
SECRETAIRE: 'Secrétaire'
}}/>
<OptionField name="grade_arbitrage" text="Grade d'arbitrage" value={data.grade_arbitrage}
values={{NA: 'N/A', ASSESSEUR: 'Assesseur', ARBITRE: 'Arbitre'}} disabled={true}/>
<div className="row">
<div className="input-group mb-3">
<label className="input-group-text" htmlFor="url_photo">Photos
(optionnelle)</label>
<input type="file" className="form-control" id="url_photo" name="url_photo"
accept=".jpg,.jpeg,.gif,.png,.svg"/>
</div>
</div>
<div className="row">
<div className="d-grid gap-2 d-md-flex justify-content-md-end">
<button type="submit" className="btn btn-primary">Enregistrer</button>
</div>
</div>
</div>
</div>
</form>;
}

View File

@ -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 <div className="card mb-4 mb-md-0">
<div className="card-header container-fluid">
<div className="row">
<div className="col">Licence</div>
<div className="col" style={{textAlign: 'right'}}>
<button className="btn btn-primary btn-sm" data-bs-toggle="modal" data-bs-target="#LicenceModal"
onClick={() => setModal(defaultLicence)}
disabled={licences.some(licence => licence.saison === getSaison())}>Demander
</button>
</div>
</div>
</div>
<div className="card-body">
<ul className="list-group">
{licences.map((licence, index) => {
return <div key={index}
className={"list-group-item d-flex justify-content-between align-items-start list-group-item-" +
(licence.validate ? "success" : (licence.certificate ? "warning" : "danger"))}>
<div className="me-auto">{licence?.saison}-{licence?.saison + 1}</div>
<button className="badge btn btn-primary rounded-pill" data-bs-toggle="modal"
data-bs-target="#LicenceModal" onClick={_ => setModal(licence)}>
{licence.saison === getSaison() ? <FontAwesomeIcon icon={faPen}/> :
<FontAwesomeIcon icon={faInfo}/>}</button>
</div>
})}
{error && <AxiosError error={error}/>}
</ul>
</div>
<div className="modal fade" id="LicenceModal" tabIndex="-1" aria-labelledby="LicenceModalLabel"
aria-hidden="true">
<div className="modal-dialog">
<div className="modal-content">
<ModalContent licence={modalLicence} dispatch={dispatch}/>
</div>
</div>
</div>
</div>;
}
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 <form onSubmit={e => sendLicence(e, dispatch)}>
<input name="id" value={licence.id} readOnly hidden/>
<input name="membre" value={licence.membre} readOnly hidden/>
<div className="modal-header">
<h1 className="modal-title fs-5" id="LicenceModalLabel">
{isNew ? "Demande de licence " : "Edition de la demande "}
(saison {licence.saison}-{licence.saison + 1})</h1>
<button type="button" className="btn-close" data-bs-dismiss="modal"
aria-label="Close"></button>
</div>
<div className="modal-body">
<div className="btn-group input-group mb-3 justify-content-md-center" role="group"
aria-label="Basic radio toggle button group">
<span className="input-group-text">Certificat médical</span>
<input type="radio" className="btn-check" id="btnradio1" autoComplete="off" value="false"
checked={certificate === false} onChange={handleCertificateChange} disabled={!currentSaison}/>
<label className="btn btn-outline-primary" htmlFor="btnradio1">Non</label>
<input type="radio" className="btn-check" name="certificate" id="btnradio2" autoComplete="off"
value="true" checked={certificate === true} onChange={handleCertificateChange}
disabled={!currentSaison}/>
<label className="btn btn-outline-primary" htmlFor="btnradio2">Oui</label>
</div>
<div className="input-group mb-3 justify-content-md-center">
<div>Validation de la licence: <ColoredText boolean={licence.validate}/></div>
</div>
</div>
<div className="modal-footer">
{currentSaison &&
<button type="submit" className="btn btn-primary" data-bs-dismiss="modal">Enregistrer</button>}
<button type="button" className="btn btn-secondary" data-bs-dismiss="modal">Fermer</button>
{currentSaison && licence.validate === false &&
<button type="button" className="btn btn-danger" data-bs-dismiss="modal"
onClick={() => removeLicence(licence.id, dispatch)}>Annuler</button>}
</div>
</form>
}

View File

@ -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 <>
<h2>Page membre</h2>
<button type="button" className="btn btn-link" onClick={() => navigate("/club/member")}>
&laquo; retour
</button>
{data
? <div>
<div className="row">
<div className="col-lg-4">
<PhotoCard data={data}/>
<LoadingProvider><CompteInfo userData={data}/></LoadingProvider>
</div>
<div className="col-lg-8">
<InformationForm data={data}/>
<div className="row">
<div className="col-md-6">
<LoadingProvider><LicenceCard userData={data}/></LoadingProvider>
</div>
<div className="col-md-6">
<LoadingProvider><SelectCard/></LoadingProvider>
</div>
</div>
</div>
</div>
</div>
: error && <AxiosError error={error}/>
}
</>
}
function PhotoCard({data}) {
return <div className="card mb-4">
<div className="card-header">Licence n°{data.licence}</div>
<div className="card-body text-center">
<div className="input-group mb-3">
<img
src={`${vite_url}/api/member/${data.id}/photo`}
alt="avatar"
className="rounded-circle img-fluid" style={{object_fit: 'contain'}}/>
</div>
</div>
</div>;
}
function SelectCard() {
return <div className="card mb-4 mb-md-0">
<div className="card-header">Sélection en équipe de France</div>
<div className="card-body">
<p className="mb-1">Soon</p>
</div>
</div>;
}