feat: add membre list filter
This commit is contained in:
parent
40427b8cfb
commit
2fd6ef3c2e
@ -13,6 +13,7 @@ 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 jakarta.ws.rs.BadRequestException;
|
||||||
|
import org.eclipse.microprofile.jwt.JsonWebToken;
|
||||||
import org.hibernate.reactive.mutiny.Mutiny;
|
import org.hibernate.reactive.mutiny.Mutiny;
|
||||||
|
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
@ -32,6 +33,15 @@ public class LicenceService {
|
|||||||
return combRepository.findById(id).invoke(checkPerm).chain(combRepository -> Mutiny.fetch(combRepository.getLicences()));
|
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) {
|
public Uni<LicenceModel> setLicence(long id, LicenceForm form) {
|
||||||
if (form.getId() == -1) {
|
if (form.getId() == -1) {
|
||||||
return combRepository.findById(id).chain(combRepository -> {
|
return combRepository.findById(id).chain(combRepository -> {
|
||||||
@ -58,7 +68,7 @@ public class LicenceService {
|
|||||||
public Uni<LicenceModel> askLicence(long id, LicenceForm form, Consumer<MembreModel> checkPerm) {
|
public Uni<LicenceModel> askLicence(long id, LicenceForm form, Consumer<MembreModel> checkPerm) {
|
||||||
return combRepository.findById(id).invoke(checkPerm).chain(membreModel -> {
|
return combRepository.findById(id).invoke(checkPerm).chain(membreModel -> {
|
||||||
if (form.getId() == -1) {
|
if (form.getId() == -1) {
|
||||||
return repository.find("saison = ?1", Utils.getSaison()).count().invoke(Unchecked.consumer(count -> {
|
return repository.find("saison = ?1 AND membre = ?2", Utils.getSaison(), membreModel).count().invoke(Unchecked.consumer(count -> {
|
||||||
if (count > 0)
|
if (count > 0)
|
||||||
throw new BadRequestException();
|
throw new BadRequestException();
|
||||||
})).chain(__ -> combRepository.findById(id).chain(combRepository -> {
|
})).chain(__ -> combRepository.findById(id).chain(combRepository -> {
|
||||||
|
|||||||
@ -6,24 +6,28 @@ 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.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.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 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 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 jakarta.ws.rs.ForbiddenException;
|
import jakarta.ws.rs.ForbiddenException;
|
||||||
import org.eclipse.microprofile.jwt.JsonWebToken;
|
import org.eclipse.microprofile.jwt.JsonWebToken;
|
||||||
|
|
||||||
import java.util.List;
|
|
||||||
|
|
||||||
|
|
||||||
@WithSession
|
@WithSession
|
||||||
@ApplicationScoped
|
@ApplicationScoped
|
||||||
@ -48,13 +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<List<MembreModel>> getInClub(String subject) {
|
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()
|
return repository.find("userId = ?1", subject).firstResult()
|
||||||
.chain(membreModel -> repository.find("club = ?1", membreModel.getClub()).list());
|
.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) {
|
||||||
@ -89,7 +128,7 @@ public class MembreService {
|
|||||||
.map(__ -> "OK");
|
.map(__ -> "OK");
|
||||||
}
|
}
|
||||||
|
|
||||||
public Uni<String> update(long id, ClubMemberForm membre, JsonWebToken idToken) {
|
public Uni<String> update(long id, ClubMemberForm membre, JsonWebToken idToken, SecurityIdentity securityIdentity) {
|
||||||
return repository.findById(id)
|
return repository.findById(id)
|
||||||
.invoke(Unchecked.consumer(membreModel -> {
|
.invoke(Unchecked.consumer(membreModel -> {
|
||||||
if (!GroupeUtils.isInClubGroup(membreModel.getClub().getId(), idToken))
|
if (!GroupeUtils.isInClubGroup(membreModel.getClub().getId(), idToken))
|
||||||
@ -97,9 +136,9 @@ public class MembreService {
|
|||||||
}))
|
}))
|
||||||
.invoke(Unchecked.consumer(membreModel -> {
|
.invoke(Unchecked.consumer(membreModel -> {
|
||||||
RoleAsso source = RoleAsso.MEMBRE;
|
RoleAsso source = RoleAsso.MEMBRE;
|
||||||
if (idToken.getGroups().contains("club_president")) source = RoleAsso.PRESIDENT;
|
if (securityIdentity.getRoles().contains("club_president")) source = RoleAsso.PRESIDENT;
|
||||||
else if (idToken.getGroups().contains("club_secretaire")) source = RoleAsso.SECRETAIRE;
|
else if (securityIdentity.getRoles().contains("club_secretaire")) source = RoleAsso.SECRETAIRE;
|
||||||
else if (idToken.getGroups().contains("club_respo_intra")) 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)
|
if (!membre.getRole().equals(membreModel.getRole()) && membre.getRole().level > source.level)
|
||||||
throw new ForbiddenException();
|
throw new ForbiddenException();
|
||||||
}))
|
}))
|
||||||
|
|||||||
@ -6,9 +6,11 @@ import fr.titionfire.ffsaf.rest.data.SimpleMembre;
|
|||||||
import fr.titionfire.ffsaf.rest.from.ClubMemberForm;
|
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.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.oidc.IdToken;
|
||||||
import io.quarkus.security.Authenticated;
|
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;
|
||||||
@ -26,7 +28,6 @@ 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;
|
import java.util.function.Consumer;
|
||||||
@ -45,25 +46,38 @@ public class CombEndpoints {
|
|||||||
@IdToken
|
@IdToken
|
||||||
JsonWebToken idToken;
|
JsonWebToken idToken;
|
||||||
|
|
||||||
|
@Inject
|
||||||
|
SecurityIdentity securityIdentity;
|
||||||
|
|
||||||
Consumer<MembreModel> checkPerm = Unchecked.consumer(membreModel -> {
|
Consumer<MembreModel> checkPerm = Unchecked.consumer(membreModel -> {
|
||||||
if (!idToken.getGroups().contains("federation_admin") && !GroupeUtils.isInClubGroup(membreModel.getClub().getId(), idToken))
|
if (!securityIdentity.getRoles().contains("federation_admin") && !GroupeUtils.isInClubGroup(membreModel.getClub().getId(), idToken))
|
||||||
throw new ForbiddenException();
|
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
|
@GET
|
||||||
@Path("/club")
|
@Path("/find/club")
|
||||||
@RolesAllowed({"club_president", "club_secretaire", "club_respo_intra"})
|
@RolesAllowed({"club_president", "club_secretaire", "club_respo_intra"})
|
||||||
@Produces(MediaType.APPLICATION_JSON)
|
@Produces(MediaType.APPLICATION_JSON)
|
||||||
public Uni<List<SimpleMembre>> getClub() {
|
public Uni<PageResult<SimpleMembre>> getFindClub(@QueryParam("limit") Integer limit, @QueryParam("page") Integer page,
|
||||||
return membreService.getInClub(idToken.getSubject()).map(membreModels -> membreModels.stream().map(SimpleMembre::fromModel).toList());
|
@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
|
||||||
@ -99,7 +113,7 @@ public class CombEndpoints {
|
|||||||
@Produces(MediaType.TEXT_PLAIN)
|
@Produces(MediaType.TEXT_PLAIN)
|
||||||
@Consumes(MediaType.MULTIPART_FORM_DATA)
|
@Consumes(MediaType.MULTIPART_FORM_DATA)
|
||||||
public Uni<String> setMembre(@PathParam("id") long id, ClubMemberForm input) {
|
public Uni<String> setMembre(@PathParam("id") long id, ClubMemberForm input) {
|
||||||
return membreService.update(id, input, idToken)
|
return membreService.update(id, input, idToken, securityIdentity)
|
||||||
.invoke(Unchecked.consumer(out -> {
|
.invoke(Unchecked.consumer(out -> {
|
||||||
if (!out.equals("OK")) throw new InternalError("Fail to update data: " + out);
|
if (!out.equals("OK")) throw new InternalError("Fail to update data: " + out);
|
||||||
})).chain(() -> {
|
})).chain(() -> {
|
||||||
|
|||||||
@ -4,7 +4,7 @@ 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.GroupeUtils;
|
||||||
import fr.titionfire.ffsaf.utils.Pair;
|
import fr.titionfire.ffsaf.utils.Pair;
|
||||||
import io.quarkus.oidc.IdToken;
|
import io.quarkus.security.identity.SecurityIdentity;
|
||||||
import io.smallrye.mutiny.Uni;
|
import io.smallrye.mutiny.Uni;
|
||||||
import io.vertx.mutiny.core.Vertx;
|
import io.vertx.mutiny.core.Vertx;
|
||||||
import jakarta.annotation.security.RolesAllowed;
|
import jakarta.annotation.security.RolesAllowed;
|
||||||
@ -26,8 +26,7 @@ public class CompteEndpoints {
|
|||||||
JsonWebToken accessToken;
|
JsonWebToken accessToken;
|
||||||
|
|
||||||
@Inject
|
@Inject
|
||||||
@IdToken
|
SecurityIdentity securityIdentity;
|
||||||
JsonWebToken idToken;
|
|
||||||
|
|
||||||
@Inject
|
@Inject
|
||||||
Vertx vertx;
|
Vertx vertx;
|
||||||
@ -37,8 +36,8 @@ public class CompteEndpoints {
|
|||||||
@RolesAllowed({"federation_admin", "club_president", "club_secretaire", "club_respo_intra"})
|
@RolesAllowed({"federation_admin", "club_president", "club_secretaire", "club_respo_intra"})
|
||||||
public Uni<KeycloakService.UserCompteState> getCompte(@PathParam("id") String id) {
|
public Uni<KeycloakService.UserCompteState> getCompte(@PathParam("id") String id) {
|
||||||
return service.fetchCompte(id).call(pair -> vertx.getOrCreateContext().executeBlocking(() -> {
|
return service.fetchCompte(id).call(pair -> vertx.getOrCreateContext().executeBlocking(() -> {
|
||||||
if (!idToken.getGroups().contains("federation_admin") && !pair.getKey().groups().stream().map(GroupRepresentation::getPath)
|
if (!securityIdentity.getRoles().contains("federation_admin") && pair.getKey().groups().stream().map(GroupRepresentation::getPath)
|
||||||
.anyMatch(s -> s.startsWith("/club/") && GroupeUtils.contains(s, accessToken)))
|
.noneMatch(s -> s.startsWith("/club/") && GroupeUtils.contains(s, accessToken)))
|
||||||
throw new ForbiddenException();
|
throw new ForbiddenException();
|
||||||
return pair;
|
return pair;
|
||||||
})).map(Pair::getValue);
|
})).map(Pair::getValue);
|
||||||
|
|||||||
@ -6,6 +6,7 @@ import fr.titionfire.ffsaf.rest.data.SimpleLicence;
|
|||||||
import fr.titionfire.ffsaf.rest.from.LicenceForm;
|
import fr.titionfire.ffsaf.rest.from.LicenceForm;
|
||||||
import fr.titionfire.ffsaf.utils.GroupeUtils;
|
import fr.titionfire.ffsaf.utils.GroupeUtils;
|
||||||
import io.quarkus.oidc.IdToken;
|
import io.quarkus.oidc.IdToken;
|
||||||
|
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;
|
||||||
@ -27,8 +28,11 @@ public class LicenceEndpoints {
|
|||||||
@IdToken
|
@IdToken
|
||||||
JsonWebToken idToken;
|
JsonWebToken idToken;
|
||||||
|
|
||||||
|
@Inject
|
||||||
|
SecurityIdentity securityIdentity;
|
||||||
|
|
||||||
Consumer<MembreModel> checkPerm = Unchecked.consumer(membreModel -> {
|
Consumer<MembreModel> checkPerm = Unchecked.consumer(membreModel -> {
|
||||||
if (!idToken.getGroups().contains("federation_admin") && !GroupeUtils.isInClubGroup(membreModel.getClub().getId(), idToken))
|
if (!securityIdentity.getRoles().contains("federation_admin") && !GroupeUtils.isInClubGroup(membreModel.getClub().getId(), idToken))
|
||||||
throw new ForbiddenException();
|
throw new ForbiddenException();
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -40,6 +44,22 @@ public class LicenceEndpoints {
|
|||||||
return licenceService.getLicence(id, checkPerm).map(licenceModels -> licenceModels.stream().map(SimpleLicence::fromModel).toList());
|
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
|
@POST
|
||||||
@Path("{id}")
|
@Path("{id}")
|
||||||
@RolesAllowed("federation_admin")
|
@RolesAllowed("federation_admin")
|
||||||
|
|||||||
17
src/main/java/fr/titionfire/ffsaf/utils/PageResult.java
Normal file
17
src/main/java/fr/titionfire/ffsaf/utils/PageResult.java
Normal 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<>();
|
||||||
|
}
|
||||||
@ -77,4 +77,15 @@ export function CheckField({name, text, value, row = false}) {
|
|||||||
</div>
|
</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>
|
||||||
|
};
|
||||||
@ -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
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
234
src/main/webapp/src/pages/MemberList.jsx
Normal file
234
src/main/webapp/src/pages/MemberList.jsx
Normal 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))}>«</span></li>
|
||||||
|
{pages}
|
||||||
|
<li className={"page-item" + ((page >= data.page_count) ? " disabled" : "")}>
|
||||||
|
<span className="page-link" onClick={() => navigate("#" + (page + 1))}>»</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>
|
||||||
|
}
|
||||||
@ -1,7 +1,7 @@
|
|||||||
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 "./member/MemberPage.jsx";
|
import {MemberPage} from "./member/MemberPage.jsx";
|
||||||
|
|
||||||
export function AdminRoot() {
|
export function AdminRoot() {
|
||||||
@ -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',
|
||||||
|
|||||||
@ -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>
|
|
||||||
}
|
|
||||||
@ -19,7 +19,7 @@ export function MemberPage() {
|
|||||||
return <>
|
return <>
|
||||||
<h2>Page membre</h2>
|
<h2>Page membre</h2>
|
||||||
<button type="button" className="btn btn-link" onClick={() => navigate("/admin/member")}>
|
<button type="button" className="btn btn-link" onClick={() => navigate("/admin/member")}>
|
||||||
<< retour
|
« retour
|
||||||
</button>
|
</button>
|
||||||
{data
|
{data
|
||||||
? <div>
|
? <div>
|
||||||
|
|||||||
@ -1,8 +1,8 @@
|
|||||||
import {Outlet} from "react-router-dom";
|
import {Outlet} from "react-router-dom";
|
||||||
import {LoadingProvider} from "../../hooks/useLoading.jsx";
|
import {LoadingProvider} from "../../hooks/useLoading.jsx";
|
||||||
import {MemberList} from "./MemberList.jsx";
|
|
||||||
import {MemberPage} from "./member/MemberPage.jsx";
|
import {MemberPage} from "./member/MemberPage.jsx";
|
||||||
import {useAuth} from "../../hooks/useAuth.jsx";
|
import {useAuth} from "../../hooks/useAuth.jsx";
|
||||||
|
import {MemberList} from "../MemberList.jsx";
|
||||||
|
|
||||||
export function ClubRoot() {
|
export function ClubRoot() {
|
||||||
const {userinfo} = useAuth()
|
const {userinfo} = useAuth()
|
||||||
@ -29,7 +29,7 @@ export function getClubChildren() {
|
|||||||
return [
|
return [
|
||||||
{
|
{
|
||||||
path: 'member',
|
path: 'member',
|
||||||
element: <MemberList/>
|
element: <MemberList source="club"/>
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: 'member/:id',
|
path: 'member/:id',
|
||||||
|
|||||||
@ -1,60 +0,0 @@
|
|||||||
import {useLoadingSwitcher} from "../../hooks/useLoading.jsx";
|
|
||||||
import {useFetch} from "../../hooks/useFetch.js";
|
|
||||||
import {AxiosError} from "../../components/AxiosError.jsx";
|
|
||||||
import {ThreeDots} from "react-loader-spinner";
|
|
||||||
import {useState} from "react";
|
|
||||||
import {Input} from "../../components/Input.jsx";
|
|
||||||
import {useNavigate} from "react-router-dom";
|
|
||||||
|
|
||||||
const removeDiacritics = str => {
|
|
||||||
return str
|
|
||||||
.normalize('NFD')
|
|
||||||
.replace(/[\u0300-\u036f]/g, '')
|
|
||||||
}
|
|
||||||
|
|
||||||
export function MemberList() {
|
|
||||||
const setLoading = useLoadingSwitcher()
|
|
||||||
const {data, error} = useFetch(`/member/club`, setLoading, 1)
|
|
||||||
const [searchInput, setSearchInput] = useState("");
|
|
||||||
const navigate = useNavigate();
|
|
||||||
|
|
||||||
const visibleMember = data ? data.filter(member => {
|
|
||||||
const lo = removeDiacritics(searchInput).toLowerCase()
|
|
||||||
return !searchInput
|
|
||||||
|| (removeDiacritics(member.fname).toLowerCase().startsWith(lo)
|
|
||||||
|| removeDiacritics(member.lname).toLowerCase().startsWith(lo));
|
|
||||||
}) : [];
|
|
||||||
|
|
||||||
return <>
|
|
||||||
<SearchBar searchInput={searchInput} onSearchInputChange={setSearchInput}/>
|
|
||||||
{data
|
|
||||||
? <div className="list-group">
|
|
||||||
{visibleMember.map(member => (
|
|
||||||
<span key={member.id}
|
|
||||||
onClick={() => navigate("/club/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>
|
|
||||||
}
|
|
||||||
@ -18,7 +18,7 @@ export function MemberPage() {
|
|||||||
return <>
|
return <>
|
||||||
<h2>Page membre</h2>
|
<h2>Page membre</h2>
|
||||||
<button type="button" className="btn btn-link" onClick={() => navigate("/club/member")}>
|
<button type="button" className="btn btn-link" onClick={() => navigate("/club/member")}>
|
||||||
<< retour
|
« retour
|
||||||
</button>
|
</button>
|
||||||
{data
|
{data
|
||||||
? <div>
|
? <div>
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user