Merge pull request #18

dev
This commit is contained in:
TitiOnFire 2024-03-08 16:31:34 +01:00 committed by GitHub
commit d7882b5bff
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
22 changed files with 845 additions and 75 deletions

View File

@ -104,6 +104,11 @@
<artifactId>jodd-util</artifactId>
<version>6.2.1</version>
</dependency>
<dependency>
<groupId>io.quarkus</groupId>
<artifactId>quarkus-websockets</artifactId>
</dependency>
</dependencies>
<build>
<plugins>

View File

@ -0,0 +1,43 @@
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_request")
public class AffiliationRequestModel {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
Long id;
String name;
String siren;
String RNA;
String address;
String president_lname;
String president_fname;
String president_email;
int president_lincence;
String tresorier_lname;
String tresorier_fname;
String tresorier_email;
int tresorier_lincence;
String secretaire_lname;
String secretaire_fname;
String secretaire_email;
int secretaire_lincence;
int saison;
}

View File

@ -0,0 +1,9 @@
package fr.titionfire.ffsaf.data.repository;
import fr.titionfire.ffsaf.data.model.AffiliationRequestModel;
import io.quarkus.hibernate.reactive.panache.PanacheRepositoryBase;
import jakarta.enterprise.context.ApplicationScoped;
@ApplicationScoped
public class AffiliationRequestRepository implements PanacheRepositoryBase<AffiliationRequestModel, Long> {
}

View File

@ -0,0 +1,60 @@
package fr.titionfire.ffsaf.domain.service;
import fr.titionfire.ffsaf.data.model.AffiliationRequestModel;
import fr.titionfire.ffsaf.data.repository.AffiliationRequestRepository;
import fr.titionfire.ffsaf.data.repository.CombRepository;
import fr.titionfire.ffsaf.rest.from.AffiliationRequestForm;
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 jakarta.enterprise.context.ApplicationScoped;
import jakarta.inject.Inject;
import org.eclipse.microprofile.config.inject.ConfigProperty;
@WithSession
@ApplicationScoped
public class AffiliationService {
@Inject
CombRepository combRepository;
@Inject
AffiliationRequestRepository repository;
@ConfigProperty(name = "upload_dir")
String media;
public Uni<String> save(AffiliationRequestForm form) {
AffiliationRequestModel affModel = form.toModel();
affModel.setSaison(Utils.getSaison());
return Uni.createFrom().item(affModel)
.call(model -> ((model.getPresident_lincence() != 0) ? combRepository.find("licence",
model.getPresident_lincence()).count().invoke(count -> {
if (count == 0) {
throw new IllegalArgumentException("Licence président inconnue");
}
}) : Uni.createFrom().nullItem())
)
.call(model -> ((model.getTresorier_lincence() != 0) ? combRepository.find("licence",
model.getTresorier_lincence()).count().invoke(count -> {
if (count == 0) {
throw new IllegalArgumentException("Licence trésorier inconnue");
}
}) : Uni.createFrom().nullItem())
)
.call(model -> ((model.getSecretaire_lincence() != 0) ? combRepository.find("licence",
model.getSecretaire_lincence()).count().invoke(count -> {
if (count == 0) {
throw new IllegalArgumentException("Licence secrétaire inconnue");
}
}) : Uni.createFrom().nullItem())
).chain(model -> Panache.withTransaction(() -> repository.persist(model)))
.call(model -> Uni.createFrom().future(Utils.replacePhoto(model.getId(), form.getLogo(), media,
"aff_request/logo")))
.call(model -> Uni.createFrom().future(Utils.replacePhoto(model.getId(), form.getStatus(), media,
"aff_request/status")))
.map(__ -> "Ok");
}
}

View File

@ -121,7 +121,7 @@ public class KeycloakService {
return vertx.getOrCreateContext().executeBlocking(() -> {
UserResource user = keycloak.realm(realm).users().get(id);
UserRepresentation user2 = user.toRepresentation();
return new Pair<>(user, new UserCompteState(user2.isEnabled(), user2.getUsername(), user2.isEmailVerified())) ;
return new Pair<>(user, new UserCompteState(user2.isEnabled(), user2.getUsername(), user2.isEmailVerified()));
});
}
@ -195,6 +195,17 @@ public class KeycloakService {
return membreService.setUserId(id, nid).map(__ -> "OK");
}
public Uni<?> removeAccount(String userId) {
return vertx.getOrCreateContext().executeBlocking(() -> {
try (Response response = keycloak.realm(realm).users().delete(userId)) {
System.out.println(response.getStatusInfo());
if (!response.getStatusInfo().equals(Response.Status.NO_CONTENT))
throw new KeycloakException("Fail to delete user %s (reason=%s)".formatted(userId, response.getStatusInfo().getReasonPhrase()));
}
return null;
});
}
private Optional<UserRepresentation> getUser(String username) {
List<UserRepresentation> users = keycloak.realm(realm).users().searchByUsername(username, true);

View File

@ -1,18 +1,17 @@
package fr.titionfire.ffsaf.domain.service;
import fr.titionfire.ffsaf.data.model.ClubModel;
import fr.titionfire.ffsaf.data.model.MembreModel;
import fr.titionfire.ffsaf.data.repository.ClubRepository;
import fr.titionfire.ffsaf.data.repository.CombRepository;
import fr.titionfire.ffsaf.data.repository.LicenceRepository;
import fr.titionfire.ffsaf.net2.ServerCustom;
import fr.titionfire.ffsaf.net2.data.SimpleCombModel;
import fr.titionfire.ffsaf.net2.request.SReqComb;
import fr.titionfire.ffsaf.rest.data.SimpleMembre;
import fr.titionfire.ffsaf.rest.from.ClubMemberForm;
import fr.titionfire.ffsaf.rest.from.FullMemberForm;
import fr.titionfire.ffsaf.utils.GroupeUtils;
import fr.titionfire.ffsaf.utils.PageResult;
import fr.titionfire.ffsaf.utils.Pair;
import fr.titionfire.ffsaf.utils.RoleAsso;
import fr.titionfire.ffsaf.utils.*;
import io.quarkus.hibernate.reactive.panache.Panache;
import io.quarkus.hibernate.reactive.panache.PanacheQuery;
import io.quarkus.hibernate.reactive.panache.common.WithSession;
@ -37,6 +36,9 @@ public class MembreService {
CombRepository repository;
@Inject
ClubRepository clubRepository;
@Inject
LicenceRepository licenceRepository;
@Inject
ServerCustom serverCustom;
@Inject
@ -163,10 +165,74 @@ public class MembreService {
.map(__ -> "OK");
}
public Uni<Long> add(FullMemberForm input) {
return clubRepository.findById(input.getClub())
.chain(clubModel -> {
MembreModel model = getMembreModel(input, clubModel);
return Panache.withTransaction(() -> repository.persist(model));
})
.invoke(membreModel -> SReqComb.sendIfNeedAdd(serverCustom.clients, SimpleCombModel.fromModel(membreModel)))
.map(MembreModel::getId);
}
public Uni<Long> add(FullMemberForm input, String subject) {
return repository.find("userId = ?1", subject).firstResult()
.chain(membreModel -> {
MembreModel model = getMembreModel(input, membreModel.getClub());
model.setRole(RoleAsso.MEMBRE);
model.setGrade_arbitrage(GradeArbitrage.NA);
return Panache.withTransaction(() -> repository.persist(model));
})
.invoke(membreModel -> SReqComb.sendIfNeedAdd(serverCustom.clients, SimpleCombModel.fromModel(membreModel)))
.map(MembreModel::getId);
}
public Uni<String> delete(long id) {
return repository.findById(id)
.call(membreModel -> (membreModel.getUserId() != null) ?
keycloakService.removeAccount(membreModel.getUserId()) : Uni.createFrom().nullItem())
.call(membreModel -> Panache.withTransaction(() -> repository.delete(membreModel)))
.invoke(membreModel -> SReqComb.sendRm(serverCustom.clients, id))
.map(__ -> "Ok");
}
public Uni<String> delete(long id, JsonWebToken idToken) {
return repository.findById(id)
.invoke(Unchecked.consumer(membreModel -> {
if (!GroupeUtils.isInClubGroup(membreModel.getClub().getId(), idToken))
throw new ForbiddenException();
}))
.call(membreModel -> licenceRepository.find("membre = ?1", membreModel).count()
.invoke(Unchecked.consumer(l -> {
if (l > 0)
throw new BadRequestException();
})))
.call(membreModel -> (membreModel.getUserId() != null) ?
keycloakService.removeAccount(membreModel.getUserId()) : Uni.createFrom().nullItem())
.call(membreModel -> Panache.withTransaction(() -> repository.delete(membreModel)))
.invoke(membreModel -> SReqComb.sendRm(serverCustom.clients, id))
.map(__ -> "Ok");
}
public Uni<?> setUserId(Long id, String id1) {
return repository.findById(id).chain(membreModel -> {
membreModel.setUserId(id1);
return Panache.withTransaction(() -> repository.persist(membreModel));
});
}
private static MembreModel getMembreModel(FullMemberForm input, ClubModel clubModel) {
MembreModel model = new MembreModel();
model.setFname(input.getFname());
model.setLname(input.getLname());
model.setEmail(input.getEmail());
model.setGenre(input.getGenre());
model.setCountry(input.getCountry());
model.setBirth_date(input.getBirth_date());
model.setCategorie(input.getCategorie());
model.setClub(clubModel);
model.setRole(input.getRole());
model.setGrade_arbitrage(input.getGrade_arbitrage());
return model;
}
}

View File

@ -0,0 +1,29 @@
package fr.titionfire.ffsaf.rest;
import fr.titionfire.ffsaf.rest.from.AffiliationRequestForm;
import io.smallrye.mutiny.Uni;
import jakarta.ws.rs.*;
import jakarta.ws.rs.core.MediaType;
@Path("api/affiliation")
public class AffiliationEndpoints {
@POST
@Path("save")
@Produces(MediaType.TEXT_PLAIN)
@Consumes(MediaType.MULTIPART_FORM_DATA)
public Uni<String> saveAffRequest(AffiliationRequestForm form) {
System.out.println(form);
return Uni.createFrom().item("OK");
}
/*@POST
@Path("affiliation")
@Produces(MediaType.TEXT_PLAIN)
@Consumes(MediaType.MULTIPART_FORM_DATA)
public Uni<String> saveAffRequest(AffiliationRequestForm form) {
System.out.println(form);
return service.save(form);
}*/
}

View File

@ -1,28 +1,53 @@
package fr.titionfire.ffsaf.rest;
import fr.titionfire.ffsaf.domain.service.AffiliationService;
import fr.titionfire.ffsaf.rest.client.SirenService;
import fr.titionfire.ffsaf.rest.data.UniteLegaleRoot;
import fr.titionfire.ffsaf.rest.from.AffiliationRequestForm;
import io.smallrye.mutiny.Uni;
import jakarta.inject.Inject;
import jakarta.ws.rs.*;
import jakarta.ws.rs.core.MediaType;
import jodd.net.MimeTypes;
import org.eclipse.microprofile.config.inject.ConfigProperty;
import org.eclipse.microprofile.rest.client.inject.RestClient;
import java.io.*;
import java.net.URLConnection;
import java.nio.file.Files;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.Future;
@Path("api/asso")
public class AssoEndpoints {
@RestClient
SirenService sirenService;
@Inject
AffiliationService service;
@ConfigProperty(name = "upload_dir")
String media;
@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 (throwable instanceof WebApplicationException exception) {
if (exception.getResponse().getStatus() == 400)
return new BadRequestException("Not found");
}
return throwable;
});
}
@POST
@Path("affiliation")
@Produces(MediaType.TEXT_PLAIN)
@Consumes(MediaType.MULTIPART_FORM_DATA)
public Uni<String> saveAffRequest(AffiliationRequestForm form) {
return service.save(form);
}
}

View File

@ -8,6 +8,7 @@ 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.Utils;
import io.quarkus.oidc.IdToken;
import io.quarkus.security.Authenticated;
import io.quarkus.security.identity.SecurityIdentity;
@ -58,8 +59,10 @@ public class CombEndpoints {
@Path("/find/admin")
@RolesAllowed({"federation_admin"})
@Produces(MediaType.APPLICATION_JSON)
public Uni<PageResult<SimpleMembre>> getFindAdmin(@QueryParam("limit") Integer limit, @QueryParam("page") Integer page,
@QueryParam("search") String search, @QueryParam("club") String club) {
public Uni<PageResult<SimpleMembre>> getFindAdmin(@QueryParam("limit") Integer limit,
@QueryParam("page") Integer page,
@QueryParam("search") String search,
@QueryParam("club") String club) {
if (limit == null)
limit = 50;
if (page == null || page < 1)
@ -71,7 +74,8 @@ public class CombEndpoints {
@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,
public Uni<PageResult<SimpleMembre>> getFindClub(@QueryParam("limit") Integer limit,
@QueryParam("page") Integer page,
@QueryParam("search") String search) {
if (limit == null)
limit = 50;
@ -88,7 +92,7 @@ public class CombEndpoints {
return membreService.getById(id).onItem().invoke(checkPerm).map(SimpleMembre::fromModel);
}
@POST
@PUT
@Path("{id}")
@RolesAllowed({"federation_admin"})
@Produces(MediaType.TEXT_PLAIN)
@ -99,7 +103,8 @@ public class CombEndpoints {
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 -> {
return Uni.createFrom().future(Utils.replacePhoto(id, input.getPhoto_data(), media, "ppMembre"
)).invoke(Unchecked.consumer(out -> {
if (!out.equals("OK")) throw new InternalError("Fail to get MimeType " + out);
}));
else
@ -108,6 +113,31 @@ public class CombEndpoints {
}
@POST
@RolesAllowed({"federation_admin"})
@Produces(MediaType.TEXT_PLAIN)
@Consumes(MediaType.MULTIPART_FORM_DATA)
public Uni<Long> addAdminMembre(FullMemberForm input) {
return membreService.add(input)
.invoke(Unchecked.consumer(id -> {
if (id == null) throw new InternalError("Fail to creat member data");
})).call(id -> {
if (input.getPhoto_data().length > 0)
return Uni.createFrom().future(Utils.replacePhoto(id, input.getPhoto_data(), media, "ppMembre"
));
else
return Uni.createFrom().nullItem();
});
}
@DELETE
@Path("{id}")
@RolesAllowed({"federation_admin"})
@Produces(MediaType.TEXT_PLAIN)
public Uni<String> deleteAdminMembre(@PathParam("id") long id) {
return membreService.delete(id);
}
@PUT
@Path("club/{id}")
@RolesAllowed({"club_president", "club_secretaire", "club_respo_intra"})
@Produces(MediaType.TEXT_PLAIN)
@ -118,7 +148,8 @@ public class CombEndpoints {
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 -> {
return Uni.createFrom().future(Utils.replacePhoto(id, input.getPhoto_data(), media, "ppMembre"
)).invoke(Unchecked.consumer(out -> {
if (!out.equals("OK")) throw new InternalError("Fail to get MimeType " + out);
}));
else
@ -126,6 +157,32 @@ public class CombEndpoints {
});
}
@POST
@Path("club")
@RolesAllowed({"club_president", "club_secretaire", "club_respo_intra"})
@Produces(MediaType.TEXT_PLAIN)
@Consumes(MediaType.MULTIPART_FORM_DATA)
public Uni<Long> addMembre(FullMemberForm input) {
return membreService.add(input, idToken.getSubject())
.invoke(Unchecked.consumer(id -> {
if (id == null) throw new InternalError("Fail to creat member data");
})).call(id -> {
if (input.getPhoto_data().length > 0)
return Uni.createFrom().future(Utils.replacePhoto(id, input.getPhoto_data(), media, "ppMembre"
));
else
return Uni.createFrom().nullItem();
});
}
@DELETE
@Path("club/{id}")
@RolesAllowed({"club_president", "club_secretaire", "club_respo_intra"})
@Produces(MediaType.TEXT_PLAIN)
public Uni<String> deleteMembre(@PathParam("id") long id) {
return membreService.delete(id, idToken);
}
private Future<String> replacePhoto(long id, byte[] input) {
return CompletableFuture.supplyAsync(() -> {
try (InputStream is = new BufferedInputStream(new ByteArrayInputStream(input))) {

View File

@ -1,5 +1,6 @@
package fr.titionfire.ffsaf.rest.from;
import fr.titionfire.ffsaf.data.model.AffiliationRequestModel;
import jakarta.ws.rs.FormParam;
import jakarta.ws.rs.core.MediaType;
import lombok.Getter;
@ -28,4 +29,59 @@ public class AffiliationRequestForm {
@FormParam("logo")
@PartType(MediaType.APPLICATION_OCTET_STREAM)
private byte[] logo = new byte[0];
@FormParam("president-nom")
private String president_lname = null;
@FormParam("president-prenom")
private String president_fname = null;
@FormParam("president-mail")
private String president_email = null;
@FormParam("president-licence")
private String president_lincence = null;
@FormParam("tresorier-nom")
private String tresorier_lname = null;
@FormParam("tresorier-prenom")
private String tresorier_fname = null;
@FormParam("tresorier-mail")
private String tresorier_email = null;
@FormParam("tresorier-licence")
private String tresorier_lincence = null;
@FormParam("secretaire-nom")
private String secretaire_lname = null;
@FormParam("secretaire-prenom")
private String secretaire_fname = null;
@FormParam("secretaire-mail")
private String secretaire_email = null;
@FormParam("secretaire-licence")
private String secretaire_lincence = null;
public AffiliationRequestModel toModel() {
AffiliationRequestModel model = new AffiliationRequestModel();
model.setName(this.getName());
model.setSiren(this.getSiren());
model.setRNA(this.getRna());
model.setAddress(this.getAdresse());
model.setPresident_lname(this.getPresident_lname());
model.setPresident_fname(this.getPresident_fname());
model.setPresident_email(this.getPresident_email());
model.setPresident_lincence((this.getPresident_lincence() == null || this.getPresident_lincence().isBlank())
? 0 : Integer.parseInt(this.getPresident_lincence()));
model.setTresorier_lname(this.getTresorier_lname());
model.setTresorier_fname(this.getTresorier_fname());
model.setTresorier_email(this.getTresorier_email());
model.setTresorier_lincence((this.getPresident_lincence() == null || this.getPresident_lincence().isBlank())
? 0 : Integer.parseInt(this.getTresorier_lincence()));
model.setSecretaire_lname(this.getSecretaire_lname());
model.setSecretaire_fname(this.getSecretaire_fname());
model.setSecretaire_email(this.getSecretaire_email());
model.setSecretaire_lincence((this.getPresident_lincence() == null || this.getPresident_lincence().isBlank())
? 0 : Integer.parseInt(this.getSecretaire_lincence()));
return model;
}
}

View File

@ -1,7 +1,14 @@
package fr.titionfire.ffsaf.utils;
import jodd.net.MimeTypes;
import java.io.*;
import java.net.URLConnection;
import java.nio.file.Files;
import java.util.Calendar;
import java.util.Date;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.Future;
public class Utils {
@ -19,4 +26,35 @@ public class Utils {
return calendar.get(Calendar.YEAR) - 1;
}
}
public static Future<String> replacePhoto(long id, byte[] input, String media, String dir) {
return CompletableFuture.supplyAsync(() -> {
try (InputStream is = new BufferedInputStream(new ByteArrayInputStream(input))) {
String mimeType = URLConnection.guessContentTypeFromStream(is);
String[] detectedExtensions = MimeTypes.findExtensionsByMimeTypes(mimeType, false);
if (detectedExtensions.length == 0)
throw new IOException("Fail to detect file extension for MIME type " + mimeType);
File dirFile = new File(media, dir);
if (!dirFile.exists())
if (dirFile.mkdirs())
throw new IOException("Fail to create directory " + dir);
FilenameFilter filter = (directory, filename) -> filename.startsWith(String.valueOf(id));
File[] files = dirFile.listFiles(filter);
if (files != null) {
for (File file : files) {
//noinspection ResultOfMethodCallIgnored
file.delete();
}
}
String extension = "." + detectedExtensions[0];
Files.write(new File(dirFile, id + extension).toPath(), input);
return "OK";
} catch (IOException e) {
return e.getMessage();
}
});
}
}

View File

@ -0,0 +1,84 @@
package fr.titionfire.ffsaf.ws;
import io.quarkus.runtime.annotations.RegisterForReflection;
import jakarta.enterprise.context.ApplicationScoped;
import jakarta.websocket.*;
import jakarta.websocket.server.PathParam;
import jakarta.websocket.server.ServerEndpoint;
import lombok.AllArgsConstructor;
import java.io.File;
import java.io.FileOutputStream;
import java.io.IOException;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
@ServerEndpoint("/api/ws/file/{id}")
@ApplicationScoped
public class FileSocket {
Map<String, FileRecv> sessions = new ConcurrentHashMap<>();
@OnOpen
public void onOpen(Session session, @PathParam("id") String id) {
try {
File file = File.createTempFile("safca-", ".tmp");
FileRecv fileRecv = new FileRecv(file, new FileOutputStream(file, true), System.currentTimeMillis(),
session);
System.out.println("File created: " + file.getAbsolutePath());
sessions.put(id, fileRecv);
} catch (IOException e) {
e.printStackTrace();
}
}
@OnClose
public void onClose(Session session, @PathParam("id") String id) {
if (sessions.containsKey(id)) {
FileRecv fileRecv = sessions.get(id);
if (fileRecv.fos != null) {
try {
fileRecv.fos.close();
} catch (IOException e) {
e.printStackTrace();
}
}
}
sessions.remove(id);
}
@OnError
public void onError(Session session, @PathParam("id") String id, Throwable throwable) {
if (sessions.containsKey(id)) {
FileRecv fileRecv = sessions.get(id);
if (fileRecv.fos != null) {
try {
fileRecv.fos.close();
} catch (IOException e) {
e.printStackTrace();
}
}
}
sessions.remove(id);
}
@OnMessage
public void onMessage(String message, @PathParam("id") String id) {
if (sessions.containsKey(id)) {
FileRecv fileRecv = sessions.get(id);
try {
fileRecv.fos.write(message.getBytes());
} catch (IOException e) {
e.printStackTrace();
}
}
}
@AllArgsConstructor
@RegisterForReflection
class FileRecv {
File file;
FileOutputStream fos;
long time;
Session session;
}
}

View File

@ -0,0 +1,19 @@
export function ConfirmDialog({title, message, onConfirm = () => {}, onCancel = () => {}, id = "confirm-delete"}) {
return <div className="modal fade" id={id} tabIndex="-1" role="dialog" aria-labelledby="myModalLabel" aria-hidden="true">
<div className="modal-dialog">
<div className="modal-content">
<div className="modal-header">
{title && <h4 className="modal-title" id="myModalLabel">{title}</h4>}
</div>
<div className="modal-body">
{message}
</div>
<div className="modal-footer">
<button type="button" className="btn btn-default" data-dismiss="modal" data-bs-dismiss="modal" onClick={onCancel}>Annuler</button>
<a className="btn btn-danger btn-ok" data-bs-dismiss="modal" onClick={onConfirm}>Confirmer</a>
</div>
</div>
</div>
</div>
}

View File

@ -31,28 +31,35 @@ export function DemandeAff() {
event.preventDefault()
const formData = new FormData(event.target)
toast.promise(
apiAxios.post(`asso/affiliation`, formData),
apiAxios.post(`asso/affiliation`, formData, { headers: {'Accept': '*/*'}}),
{
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")
// 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>
<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>
<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">
@ -68,16 +75,20 @@ export function DemandeAff() {
<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>
<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>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>
<button type="submit" className="btn btn-primary">Confirmer ma demande d'affiliation
</button>
</div>
</div>
</div>
@ -113,7 +124,8 @@ function AssoInfo() {
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"
<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>
@ -121,24 +133,29 @@ function AssoInfo() {
<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>
<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"
<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"
<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"
<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>
@ -149,7 +166,8 @@ function AssoInfo() {
<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/>
<input type="file" className="form-control" id="logo" name="logo" accept=".jpg,.jpeg,.gif,.png,.svg"
required/>
</div>
</>;
}
@ -159,19 +177,29 @@ function MembreInfo({role}) {
<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>
<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>
<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>
<input type="email" className="form-control" id="floatingInput" placeholder="name@example.com"
name={role + "-mail"}/>
<label htmlFor="floatingInput">Email</label>
</div>
</div>
<div className="col-sm-3">
<div>OU</div>
<div className="form-floating">
<input type="number" className="form-control" id="floatingInput" placeholder="N° Licence"
name={role + "-licence"}/>
<label htmlFor="floatingInput">N° Licence</label>
</div>
</div>
</div>
@ -181,7 +209,8 @@ 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>
<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

@ -3,6 +3,7 @@ import './AdminRoot.css'
import {LoadingProvider} from "../../hooks/useLoading.jsx";
import {MemberList} from "../MemberList.jsx";
import {MemberPage} from "./member/MemberPage.jsx";
import {NewMemberPage} from "./member/NewMemberPage.jsx";
export function AdminRoot() {
return <>
@ -13,7 +14,7 @@ export function AdminRoot() {
</>
}
export function getAdminChildren () {
export function getAdminChildren() {
return [
{
path: 'member',
@ -23,6 +24,10 @@ export function getAdminChildren () {
path: 'member/:id',
element: <MemberPage/>
},
{
path: 'member/new',
element: <NewMemberPage/>
},
{
path: 'b',
element: <div>Admin B</div>

View File

@ -5,6 +5,27 @@ import imageCompression from "browser-image-compression";
import {BirthDayField, OptionField, TextField} from "../../../components/MemberCustomFiels.jsx";
import {ClubSelect} from "../../../components/ClubSelect.jsx";
export function addPhoto(event, formData, send) {
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)
}
}
export function InformationForm({data}) {
const setLoading = useLoadingSwitcher()
const handleSubmit = (event) => {
@ -25,7 +46,7 @@ export function InformationForm({data}) {
formData.append("grade_arbitrage", event.target.grade_arbitrage?.value);
const send = (formData_) => {
apiAxios.post(`/member/${data.id}`, formData_, {
apiAxios.put(`/member/${data.id}`, formData_, {
headers: {
'Accept': '*/*',
'Content-Type': 'multipart/form-data',
@ -40,25 +61,7 @@ export function InformationForm({data}) {
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)
}
addPhoto(event, formData, send);
}
return <form onSubmit={handleSubmit}>

View File

@ -6,6 +6,9 @@ import {CompteInfo} from "./CompteInfo.jsx";
import {PremForm} from "./PremForm.jsx";
import {InformationForm} from "./InformationForm.jsx";
import {LicenceCard} from "./LicenceCard.jsx";
import {toast} from "react-toastify";
import {apiAxios} from "../../../utils/Tools.js";
import {ConfirmDialog} from "../../../components/ConfirmDialog.jsx";
const vite_url = import.meta.env.VITE_URL;
@ -16,6 +19,19 @@ export function MemberPage() {
const setLoading = useLoadingSwitcher()
const {data, error} = useFetch(`/member/${id}`, setLoading, 1)
const handleRm = () => {
toast.promise(
apiAxios.delete(`/member/${id}`),
{
pending: "Suppression du compte en cours...",
success: "Compte supprimé avec succès 🎉",
error: "Échec de la suppression du compte 😕"
}
).then(_ => {
navigate("/admin/member")
})
}
return <>
<h2>Page membre</h2>
<button type="button" className="btn btn-link" onClick={() => navigate("/admin/member")}>
@ -39,6 +55,12 @@ export function MemberPage() {
<LoadingProvider><SelectCard/></LoadingProvider>
</div>
</div>
<div className="col" style={{textAlign: 'right', marginTop: '1em'}}>
<button className="btn btn-danger btn-sm" data-bs-toggle="modal" data-bs-target="#confirm-delete">Supprimer le compte
</button>
</div>
<ConfirmDialog title="Supprimer le compte" message="Êtes-vous sûr de vouloir supprimer ce compte ?"
onConfirm={handleRm}/>
</div>
</div>
</div>

View File

@ -0,0 +1,107 @@
import {useNavigate} from "react-router-dom";
import {useLoadingSwitcher} from "../../../hooks/useLoading.jsx";
import {apiAxios} from "../../../utils/Tools.js";
import {toast} from "react-toastify";
import {BirthDayField, OptionField, TextField} from "../../../components/MemberCustomFiels.jsx";
import {ClubSelect} from "../../../components/ClubSelect.jsx";
import {addPhoto} from "./InformationForm.jsx";
export function NewMemberPage() {
const navigate = useNavigate();
return <>
<h2>Page membre</h2>
<button type="button" className="btn btn-link" onClick={() => navigate("/admin/member")}>
&laquo; retour
</button>
<div>
<div className="row">
<Form/>
</div>
</div>
</>
}
function Form() {
const navigate = useNavigate();
const setLoading = useLoadingSwitcher()
const handleSubmit = (event) => {
event.preventDefault();
setLoading(1)
const formData = new FormData();
formData.append("id", -1);
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`, formData_, {
headers: {
'Accept': '*/*',
'Content-Type': 'multipart/form-data',
}
}).then(data => {
toast.success('Profile crée avec succès 🎉');
navigate(`/admin/member/${data.data}`)
}).catch(e => {
console.log(e.response)
toast.error('Échec de la création du profile 😕 (code: ' + e.response.status + ')');
}).finally(() => {
if (setLoading)
setLoading(0)
})
}
addPhoto(event, formData, send);
}
return <form onSubmit={handleSubmit}>
<div className="card mb-4">
<div className="card-header">Nouveau membre</div>
<div className="card-body">
<TextField name="lname" text="Nom"/>
<TextField name="fname" text="Prénom"/>
<TextField name="email" text="Email" placeholder="name@example.com"
type="email"/>
<OptionField name="genre" text="Genre" values={{NA: 'N/A', H: 'H', F: 'F'}}/>
<OptionField name="country" text="Pays" value={'fr'}
values={{NA: 'Sélectionner...', fr: 'FR', es: 'ES', be: 'BE'}}/>
<BirthDayField/>
<div className="row">
<ClubSelect name="club"/>
</div>
<OptionField name="role" text="Rôle" value={'MEMBRE'}
values={{
MEMBRE: 'Membre',
PRESIDENT: 'Président',
TRESORIER: 'Trésorier',
SECRETAIRE: 'Secrétaire'
}}/>
<OptionField name="grade_arbitrage" text="Grade d'arbitrage" value={'NA'}
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">Créer</button>
</div>
</div>
</div>
</div>
</form>;
}

View File

@ -3,6 +3,7 @@ import {LoadingProvider} from "../../hooks/useLoading.jsx";
import {MemberPage} from "./member/MemberPage.jsx";
import {useAuth} from "../../hooks/useAuth.jsx";
import {MemberList} from "../MemberList.jsx";
import {NewMemberPage} from "./member/NewMemberPage.jsx";
export function ClubRoot() {
const {userinfo} = useAuth()
@ -35,6 +36,10 @@ export function getClubChildren() {
path: 'member/:id',
element: <MemberPage/>
},
{
path: 'member/new',
element: <NewMemberPage/>
},
{
path: 'b',
element: <div>Club B</div>

View File

@ -3,9 +3,8 @@
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";
import {addPhoto} from "../../admin/member/InformationForm.jsx";
export function InformationForm({data}) {
const setLoading = useLoadingSwitcher()
@ -25,7 +24,7 @@ export function InformationForm({data}) {
formData.append("role", event.target.role?.value);
const send = (formData_) => {
apiAxios.post(`/member/club/${data.id}`, formData_, {
apiAxios.put(`/member/club/${data.id}`, formData_, {
headers: {
'Accept': '*/*',
'Content-Type': 'multipart/form-data',
@ -40,23 +39,7 @@ export function InformationForm({data}) {
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)
}
addPhoto(event, formData, send);
}
return <form onSubmit={handleSubmit}>
@ -79,7 +62,7 @@ export function InformationForm({data}) {
PRESIDENT: 'Président',
TRESORIER: 'Trésorier',
SECRETAIRE: 'Secrétaire'
}}/>
}} disabled={true}/>
<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">

View File

@ -5,6 +5,9 @@ import {AxiosError} from "../../../components/AxiosError.jsx";
import {CompteInfo} from "./CompteInfo.jsx";
import {InformationForm} from "./InformationForm.jsx";
import {LicenceCard} from "./LicenceCard.jsx";
import {ConfirmDialog} from "../../../components/ConfirmDialog.jsx";
import {apiAxios} from "../../../utils/Tools.js";
import {toast} from "react-toastify";
const vite_url = import.meta.env.VITE_URL;
@ -15,6 +18,19 @@ export function MemberPage() {
const setLoading = useLoadingSwitcher()
const {data, error} = useFetch(`/member/${id}`, setLoading, 1)
const handleRm = () => {
toast.promise(
apiAxios.delete(`/member/club/${id}`),
{
pending: "Suppression du compte en cours...",
success: "Compte supprimé avec succès 🎉",
error: "Échec de la suppression du compte 😕"
}
).then(_ => {
navigate("/club/member")
})
}
return <>
<h2>Page membre</h2>
<button type="button" className="btn btn-link" onClick={() => navigate("/club/member")}>
@ -37,6 +53,12 @@ export function MemberPage() {
<LoadingProvider><SelectCard/></LoadingProvider>
</div>
</div>
<div className="col" style={{textAlign: 'right', marginTop: '1em'}}>
<button className="btn btn-danger btn-sm" data-bs-toggle="modal" data-bs-target="#confirm-delete">Supprimer le compte
</button>
</div>
<ConfirmDialog title="Supprimer le compte" message="Êtes-vous sûr de vouloir supprimer ce compte ?"
onConfirm={handleRm}/>
</div>
</div>
</div>

View File

@ -0,0 +1,92 @@
import {useNavigate} from "react-router-dom";
import {useLoadingSwitcher} from "../../../hooks/useLoading.jsx";
import {apiAxios} from "../../../utils/Tools.js";
import {toast} from "react-toastify";
import {BirthDayField, OptionField, TextField} from "../../../components/MemberCustomFiels.jsx";
import {ClubSelect} from "../../../components/ClubSelect.jsx";
import {addPhoto} from "../../admin/member/InformationForm.jsx";
export function NewMemberPage() {
const navigate = useNavigate();
return <>
<h2>Page membre</h2>
<button type="button" className="btn btn-link" onClick={() => navigate("/club/member")}>
&laquo; retour
</button>
<div>
<div className="row">
<Form/>
</div>
</div>
</>
}
function Form() {
const navigate = useNavigate();
const setLoading = useLoadingSwitcher()
const handleSubmit = (event) => {
event.preventDefault();
setLoading(1)
const formData = new FormData();
formData.append("id", -1);
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);
const send = (formData_) => {
apiAxios.post(`/member/club`, formData_, {
headers: {
'Accept': '*/*',
'Content-Type': 'multipart/form-data',
}
}).then(data => {
toast.success('Profile crée avec succès 🎉');
navigate(`/club/member/${data.data}`)
}).catch(e => {
console.log(e.response)
toast.error('Échec de la création du profile 😕 (code: ' + e.response.status + ')');
}).finally(() => {
if (setLoading)
setLoading(0)
})
}
addPhoto(event, formData, send);
}
return <form onSubmit={handleSubmit}>
<div className="card mb-4">
<div className="card-header">Nouveau membre</div>
<div className="card-body">
<TextField name="lname" text="Nom"/>
<TextField name="fname" text="Prénom"/>
<TextField name="email" text="Email" placeholder="name@example.com"
type="email"/>
<OptionField name="genre" text="Genre" values={{NA: 'N/A', H: 'H', F: 'F'}}/>
<OptionField name="country" text="Pays" value={'fr'}
values={{NA: 'Sélectionner...', fr: 'FR', es: 'ES', be: 'BE'}}/>
<BirthDayField/>
<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">Créer</button>
</div>
</div>
</div>
</div>
</form>;
}