commit
72b78d7f4a
13
pom.xml
13
pom.xml
@ -72,6 +72,7 @@
|
|||||||
<scope>test</scope>
|
<scope>test</scope>
|
||||||
</dependency>
|
</dependency>
|
||||||
|
|
||||||
|
|
||||||
<dependency>
|
<dependency>
|
||||||
<groupId>io.quarkus</groupId>
|
<groupId>io.quarkus</groupId>
|
||||||
<artifactId>quarkus-oidc</artifactId>
|
<artifactId>quarkus-oidc</artifactId>
|
||||||
@ -81,6 +82,12 @@
|
|||||||
<artifactId>quarkus-keycloak-authorization</artifactId>
|
<artifactId>quarkus-keycloak-authorization</artifactId>
|
||||||
</dependency>
|
</dependency>
|
||||||
|
|
||||||
|
<dependency>
|
||||||
|
<groupId>io.quarkus</groupId>
|
||||||
|
<artifactId>quarkus-keycloak-admin-client-reactive</artifactId>
|
||||||
|
</dependency>
|
||||||
|
|
||||||
|
|
||||||
<dependency>
|
<dependency>
|
||||||
<groupId>org.projectlombok</groupId>
|
<groupId>org.projectlombok</groupId>
|
||||||
<artifactId>lombok</artifactId>
|
<artifactId>lombok</artifactId>
|
||||||
@ -89,9 +96,9 @@
|
|||||||
</dependency>
|
</dependency>
|
||||||
|
|
||||||
<dependency>
|
<dependency>
|
||||||
<groupId>org.apache.tika</groupId>
|
<groupId>org.jodd</groupId>
|
||||||
<artifactId>tika-core</artifactId>
|
<artifactId>jodd-util</artifactId>
|
||||||
<version>3.0.0-BETA</version>
|
<version>6.2.1</version>
|
||||||
</dependency>
|
</dependency>
|
||||||
</dependencies>
|
</dependencies>
|
||||||
<build>
|
<build>
|
||||||
|
|||||||
@ -21,6 +21,7 @@ RUN chown 1001 /work \
|
|||||||
&& chown 1001:root /work
|
&& chown 1001:root /work
|
||||||
COPY --chown=1001:root ffsaf/target/*-runner /work/application
|
COPY --chown=1001:root ffsaf/target/*-runner /work/application
|
||||||
COPY --chown=1001:root ffsaf/src/main/resources/cacerts /work/cacerts
|
COPY --chown=1001:root ffsaf/src/main/resources/cacerts /work/cacerts
|
||||||
|
RUN mkdir /work/media && chown -R 1001:root /work/media
|
||||||
|
|
||||||
EXPOSE 8080
|
EXPOSE 8080
|
||||||
USER 1001
|
USER 1001
|
||||||
|
|||||||
@ -21,6 +21,8 @@ public class ClubModel {
|
|||||||
@GeneratedValue(strategy = GenerationType.IDENTITY)
|
@GeneratedValue(strategy = GenerationType.IDENTITY)
|
||||||
Long id;
|
Long id;
|
||||||
|
|
||||||
|
String clubId;
|
||||||
|
|
||||||
String name;
|
String name;
|
||||||
|
|
||||||
String country;
|
String country;
|
||||||
|
|||||||
@ -25,6 +25,8 @@ public class MembreModel {
|
|||||||
@GeneratedValue(strategy = GenerationType.IDENTITY)
|
@GeneratedValue(strategy = GenerationType.IDENTITY)
|
||||||
Long id;
|
Long id;
|
||||||
|
|
||||||
|
String userId;
|
||||||
|
|
||||||
String lname;
|
String lname;
|
||||||
String fname;
|
String fname;
|
||||||
|
|
||||||
|
|||||||
@ -16,6 +16,7 @@ import java.util.Map;
|
|||||||
public class ClubEntity {
|
public class ClubEntity {
|
||||||
private long id;
|
private long id;
|
||||||
private String name;
|
private String name;
|
||||||
|
private String clubId;
|
||||||
private String country;
|
private String country;
|
||||||
private String shieldURL;
|
private String shieldURL;
|
||||||
private Map<Contact, String> contact;
|
private Map<Contact, String> contact;
|
||||||
@ -35,6 +36,7 @@ public class ClubEntity {
|
|||||||
return ClubEntity.builder()
|
return ClubEntity.builder()
|
||||||
.id(model.getId())
|
.id(model.getId())
|
||||||
.name(model.getName())
|
.name(model.getName())
|
||||||
|
.clubId(model.getClubId())
|
||||||
.country(model.getCountry())
|
.country(model.getCountry())
|
||||||
.shieldURL(model.getShieldURL())
|
.shieldURL(model.getShieldURL())
|
||||||
.contact(model.getContact())
|
.contact(model.getContact())
|
||||||
@ -49,7 +51,7 @@ public class ClubEntity {
|
|||||||
}
|
}
|
||||||
|
|
||||||
public ClubModel toModel () {
|
public ClubModel toModel () {
|
||||||
return new ClubModel(this.id, this.name, this.country, this.shieldURL, this.contact, this.training_location,
|
return new ClubModel(this.id, this.clubId, this.name, this.country, this.shieldURL, this.contact, this.training_location,
|
||||||
this.training_day_time, this.contact_intern, this.RNA, this.SIRET, this.no_affiliation,
|
this.training_day_time, this.contact_intern, this.RNA, this.SIRET, this.no_affiliation,
|
||||||
this.international);
|
this.international);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -32,4 +32,11 @@ public class ClubService {
|
|||||||
public Uni<List<ClubModel>> getAll() {
|
public Uni<List<ClubModel>> getAll() {
|
||||||
return repository.listAll();
|
return repository.listAll();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public Uni<?> setClubId(Long id, String id1) {
|
||||||
|
return repository.findById(id).chain(clubModel -> {
|
||||||
|
clubModel.setClubId(id1);
|
||||||
|
return Panache.withTransaction(() -> repository.persist(clubModel));
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -0,0 +1,172 @@
|
|||||||
|
package fr.titionfire.ffsaf.domain.service;
|
||||||
|
|
||||||
|
import fr.titionfire.ffsaf.data.model.ClubModel;
|
||||||
|
import fr.titionfire.ffsaf.data.model.MembreModel;
|
||||||
|
import fr.titionfire.ffsaf.utils.KeycloakException;
|
||||||
|
import fr.titionfire.ffsaf.utils.RequiredAction;
|
||||||
|
import io.smallrye.mutiny.Uni;
|
||||||
|
import io.smallrye.mutiny.unchecked.Unchecked;
|
||||||
|
import io.vertx.mutiny.core.Vertx;
|
||||||
|
import jakarta.enterprise.context.ApplicationScoped;
|
||||||
|
import jakarta.inject.Inject;
|
||||||
|
import jakarta.ws.rs.core.Response;
|
||||||
|
import org.eclipse.microprofile.config.inject.ConfigProperty;
|
||||||
|
import org.jboss.logging.Logger;
|
||||||
|
import org.keycloak.admin.client.Keycloak;
|
||||||
|
import org.keycloak.admin.client.resource.RoleScopeResource;
|
||||||
|
import org.keycloak.admin.client.resource.UserResource;
|
||||||
|
import org.keycloak.representations.idm.GroupRepresentation;
|
||||||
|
import org.keycloak.representations.idm.RoleRepresentation;
|
||||||
|
import org.keycloak.representations.idm.UserRepresentation;
|
||||||
|
|
||||||
|
import java.text.Normalizer;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Optional;
|
||||||
|
|
||||||
|
@ApplicationScoped
|
||||||
|
public class KeycloakService {
|
||||||
|
private static final Logger LOGGER = Logger.getLogger(KeycloakService.class);
|
||||||
|
|
||||||
|
@Inject
|
||||||
|
Keycloak keycloak;
|
||||||
|
|
||||||
|
@Inject
|
||||||
|
ClubService clubService;
|
||||||
|
|
||||||
|
@Inject
|
||||||
|
MembreService membreService;
|
||||||
|
|
||||||
|
@ConfigProperty(name = "keycloak.realm")
|
||||||
|
String realm;
|
||||||
|
|
||||||
|
@Inject
|
||||||
|
Vertx vertx;
|
||||||
|
|
||||||
|
public Uni<String> getGroupFromClub(ClubModel club) {
|
||||||
|
if (club.getClubId() == null) {
|
||||||
|
LOGGER.infof("Creation of club group %d-%s...", club.getId(), club.getName());
|
||||||
|
return vertx.getOrCreateContext().executeBlocking(() -> {
|
||||||
|
GroupRepresentation clubGroup = keycloak.realm(realm).groups().groups().stream().filter(g -> g.getName().equals("club"))
|
||||||
|
.findAny().orElseThrow(() -> new KeycloakException("Fail to fetch group %s".formatted("club")));
|
||||||
|
|
||||||
|
GroupRepresentation groupRepresentation = new GroupRepresentation();
|
||||||
|
groupRepresentation.setName(club.getId() + "-" + club.getName());
|
||||||
|
|
||||||
|
try (Response response = keycloak.realm(realm).groups().group(clubGroup.getId()).subGroup(groupRepresentation)) {
|
||||||
|
if (!response.getStatusInfo().equals(Response.Status.CREATED) && !response.getStatusInfo().equals(Response.Status.CONFLICT))
|
||||||
|
throw new KeycloakException("Fail to set group parent for club: %s (reason=%s)".formatted(club.getName(),
|
||||||
|
response.getStatusInfo().getReasonPhrase()));
|
||||||
|
}
|
||||||
|
|
||||||
|
return keycloak.realm(realm).groups().group(clubGroup.getId()).getSubGroups(0, 1000, true).stream()
|
||||||
|
.filter(g -> g.getName().startsWith(club.getId() + "-")).findAny().map(GroupRepresentation::getId)
|
||||||
|
.orElseThrow(() -> new KeycloakException("Fail to fetch group %s*".formatted(club.getId() + "-")));
|
||||||
|
}
|
||||||
|
).call(id -> clubService.setClubId(club.getId(), id));
|
||||||
|
}
|
||||||
|
return Uni.createFrom().item(club::getClubId);
|
||||||
|
}
|
||||||
|
|
||||||
|
public Uni<String> getUserFromMember(MembreModel membreModel) {
|
||||||
|
if (membreModel.getUserId() == null) {
|
||||||
|
return Uni.createFrom().failure(new NullPointerException("No keycloak user linked to the user id=" + membreModel.getId()));
|
||||||
|
}
|
||||||
|
return Uni.createFrom().item(membreModel::getUserId);
|
||||||
|
}
|
||||||
|
|
||||||
|
public Uni<String> setClubGroupMembre(MembreModel membreModel, ClubModel club) {
|
||||||
|
return getGroupFromClub(club).chain(
|
||||||
|
clubId -> getUserFromMember(membreModel).chain(userId -> vertx.getOrCreateContext().executeBlocking(() -> {
|
||||||
|
UserResource user = keycloak.realm(realm).users().get(userId);
|
||||||
|
user.groups().stream().filter(g -> g.getPath().startsWith("/club")).forEach(g -> user.leaveGroup(g.getId()));
|
||||||
|
user.joinGroup(clubId);
|
||||||
|
LOGGER.infof("Set club \"%s\" to user %s (%s)", club.getName(), userId, user.toRepresentation().getUsername());
|
||||||
|
return "OK";
|
||||||
|
})));
|
||||||
|
}
|
||||||
|
|
||||||
|
public Uni<UserCompteState> fetchCompte(String id) {
|
||||||
|
return vertx.getOrCreateContext().executeBlocking(() -> {
|
||||||
|
UserResource user = keycloak.realm(realm).users().get(id);
|
||||||
|
UserRepresentation user2 = user.toRepresentation();
|
||||||
|
return new UserCompteState(user2.isEnabled(), user2.getUsername(), user2.isEmailVerified(),
|
||||||
|
user.roles().realmLevel().listEffective().stream().map(RoleRepresentation::getName).toList(),
|
||||||
|
user.groups().stream().map(GroupRepresentation::getName).toList());
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public Uni<List<String>> fetchRole(String id) {
|
||||||
|
return vertx.getOrCreateContext().executeBlocking(() ->
|
||||||
|
keycloak.realm(realm).users().get(id).roles().realmLevel().listEffective().stream().map(RoleRepresentation::getName).toList());
|
||||||
|
}
|
||||||
|
|
||||||
|
public Uni<?> updateRole(String id, List<String> toAdd, List<String> toRemove) {
|
||||||
|
return vertx.getOrCreateContext().executeBlocking(() -> {
|
||||||
|
RoleScopeResource resource = keycloak.realm(realm).users().get(id).roles().realmLevel();
|
||||||
|
List<RoleRepresentation> roles = keycloak.realm(realm) .roles().list();
|
||||||
|
resource.add(roles.stream().filter(r -> toAdd.contains(r.getName())).toList());
|
||||||
|
resource.remove(roles.stream().filter(r -> toRemove.contains(r.getName())).toList());
|
||||||
|
return "OK";
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public Uni<String> initCompte(long id) {
|
||||||
|
return membreService.getById(id).invoke(Unchecked.consumer(membreModel -> {
|
||||||
|
if (membreModel.getUserId() != null)
|
||||||
|
throw new KeycloakException("User already linked to the user id=" + id);
|
||||||
|
if (membreModel.getEmail() == null)
|
||||||
|
throw new KeycloakException("User email is null");
|
||||||
|
if (membreModel.getFname() == null || membreModel.getLname() == null)
|
||||||
|
throw new KeycloakException("User name is null");
|
||||||
|
})).chain(membreModel -> creatUser(membreModel).chain(user -> {
|
||||||
|
LOGGER.infof("Set user id %s to membre %s", user.getId(), membreModel.getId());
|
||||||
|
return membreService.setUserId(membreModel.getId(), user.getId());
|
||||||
|
}))
|
||||||
|
.map(__ -> "OK");
|
||||||
|
}
|
||||||
|
|
||||||
|
private Uni<UserRepresentation> creatUser(MembreModel membreModel) {
|
||||||
|
String login = makeLogin(membreModel);
|
||||||
|
LOGGER.infof("Creation of user %s...", login);
|
||||||
|
return vertx.getOrCreateContext().executeBlocking(() -> {
|
||||||
|
UserRepresentation user = new UserRepresentation();
|
||||||
|
user.setUsername(login);
|
||||||
|
user.setFirstName(membreModel.getFname());
|
||||||
|
user.setLastName(membreModel.getLname());
|
||||||
|
user.setEmail(membreModel.getEmail());
|
||||||
|
user.setEnabled(true);
|
||||||
|
|
||||||
|
user.setRequiredActions(List.of(RequiredAction.VERIFY_EMAIL.name(),
|
||||||
|
RequiredAction.UPDATE_PASSWORD.name()));
|
||||||
|
|
||||||
|
try (Response response = keycloak.realm(realm).users().create(user)) {
|
||||||
|
if (!response.getStatusInfo().equals(Response.Status.CREATED) && !response.getStatusInfo().equals(Response.Status.CONFLICT))
|
||||||
|
throw new KeycloakException("Fail to creat user %s (reason=%s)".formatted(login, response.getStatusInfo().getReasonPhrase()));
|
||||||
|
}
|
||||||
|
|
||||||
|
return getUser(login).orElseThrow(() -> new KeycloakException("Fail to fetch user %s".formatted(login)));
|
||||||
|
})
|
||||||
|
.invoke(user -> membreModel.setUserId(user.getId()))
|
||||||
|
.call(user -> membreService.setUserId(membreModel.getId(), user.getId()))
|
||||||
|
.call(user -> setClubGroupMembre(membreModel, membreModel.getClub()));
|
||||||
|
}
|
||||||
|
|
||||||
|
private Optional<UserRepresentation> getUser(String username) {
|
||||||
|
List<UserRepresentation> users = keycloak.realm(realm).users().searchByUsername(username, true);
|
||||||
|
|
||||||
|
if (users.isEmpty())
|
||||||
|
return Optional.empty();
|
||||||
|
else
|
||||||
|
return Optional.of(users.get(0));
|
||||||
|
}
|
||||||
|
|
||||||
|
private String makeLogin(MembreModel model) {
|
||||||
|
return Normalizer.normalize((model.getFname().toLowerCase() + "." + model.getLname().toLowerCase()).replace(' ', '_'), Normalizer.Form.NFD)
|
||||||
|
.replaceAll("\\p{M}", "");
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
public record UserCompteState(Boolean enabled, String login, Boolean emailVerified, List<String> realmRoles,
|
||||||
|
List<String> groups) {
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -3,7 +3,9 @@ package fr.titionfire.ffsaf.domain.service;
|
|||||||
import fr.titionfire.ffsaf.data.model.MembreModel;
|
import fr.titionfire.ffsaf.data.model.MembreModel;
|
||||||
import fr.titionfire.ffsaf.data.repository.ClubRepository;
|
import fr.titionfire.ffsaf.data.repository.ClubRepository;
|
||||||
import fr.titionfire.ffsaf.data.repository.CombRepository;
|
import fr.titionfire.ffsaf.data.repository.CombRepository;
|
||||||
|
import fr.titionfire.ffsaf.net2.ServerCustom;
|
||||||
import fr.titionfire.ffsaf.net2.data.SimpleCombModel;
|
import fr.titionfire.ffsaf.net2.data.SimpleCombModel;
|
||||||
|
import fr.titionfire.ffsaf.net2.request.SReqComb;
|
||||||
import fr.titionfire.ffsaf.rest.from.FullMemberForm;
|
import fr.titionfire.ffsaf.rest.from.FullMemberForm;
|
||||||
import fr.titionfire.ffsaf.utils.Pair;
|
import fr.titionfire.ffsaf.utils.Pair;
|
||||||
import io.quarkus.hibernate.reactive.panache.Panache;
|
import io.quarkus.hibernate.reactive.panache.Panache;
|
||||||
@ -25,6 +27,10 @@ public class MembreService {
|
|||||||
CombRepository repository;
|
CombRepository repository;
|
||||||
@Inject
|
@Inject
|
||||||
ClubRepository clubRepository;
|
ClubRepository clubRepository;
|
||||||
|
@Inject
|
||||||
|
ServerCustom serverCustom;
|
||||||
|
@Inject
|
||||||
|
KeycloakService keycloakService;
|
||||||
|
|
||||||
public SimpleCombModel find(int licence, String np) throws Throwable {
|
public SimpleCombModel find(int licence, String np) throws Throwable {
|
||||||
return VertxContextSupport.subscribeAndAwait(() -> Panache.withTransaction(() ->
|
return VertxContextSupport.subscribeAndAwait(() -> Panache.withTransaction(() ->
|
||||||
@ -59,6 +65,17 @@ public class MembreService {
|
|||||||
m.setGrade_arbitrage(membre.getGrade_arbitrage());
|
m.setGrade_arbitrage(membre.getGrade_arbitrage());
|
||||||
m.setEmail(membre.getEmail());
|
m.setEmail(membre.getEmail());
|
||||||
return Panache.withTransaction(() -> repository.persist(m));
|
return Panache.withTransaction(() -> repository.persist(m));
|
||||||
}).map(__ -> "OK");
|
})
|
||||||
|
.invoke(membreModel -> SReqComb.sendIfNeed(serverCustom.clients, SimpleCombModel.fromModel(membreModel)))
|
||||||
|
.call(membreModel -> (membreModel.getUserId() != null) ?
|
||||||
|
keycloakService.setClubGroupMembre(membreModel, membreModel.getClub()) : Uni.createFrom().nullItem())
|
||||||
|
.map(__ -> "OK");
|
||||||
|
}
|
||||||
|
|
||||||
|
public Uni<?> setUserId(Long id, String id1) {
|
||||||
|
return repository.findById(id).chain(membreModel -> {
|
||||||
|
membreModel.setUserId(id1);
|
||||||
|
return Panache.withTransaction(() -> repository.persist(membreModel));
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
24
src/main/java/fr/titionfire/ffsaf/net2/request/SReqClub.java
Normal file
24
src/main/java/fr/titionfire/ffsaf/net2/request/SReqClub.java
Normal file
@ -0,0 +1,24 @@
|
|||||||
|
package fr.titionfire.ffsaf.net2.request;
|
||||||
|
|
||||||
|
import fr.titionfire.ffsaf.net2.Client_Thread;
|
||||||
|
import fr.titionfire.ffsaf.net2.data.SimpleClubModel;
|
||||||
|
|
||||||
|
import java.util.ArrayList;
|
||||||
|
|
||||||
|
public class SReqClub {
|
||||||
|
public static void sendIfNeed(ArrayList<Client_Thread> client_Thread, SimpleClubModel club) {
|
||||||
|
for (Client_Thread client : client_Thread) {
|
||||||
|
client.sendNotify(club, "sendClub");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
public static void sendAddIfNeed(ArrayList<Client_Thread> client_Thread, SimpleClubModel club) {
|
||||||
|
for (Client_Thread client : client_Thread) {
|
||||||
|
client.sendNotify(club, "sendAddClub");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
public static void sendRmIfNeed(ArrayList<Client_Thread> client_Thread, long club) {
|
||||||
|
for (Client_Thread client : client_Thread) {
|
||||||
|
client.sendNotify(club, "sendRmClub");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
27
src/main/java/fr/titionfire/ffsaf/net2/request/SReqComb.java
Normal file
27
src/main/java/fr/titionfire/ffsaf/net2/request/SReqComb.java
Normal file
@ -0,0 +1,27 @@
|
|||||||
|
package fr.titionfire.ffsaf.net2.request;
|
||||||
|
|
||||||
|
import fr.titionfire.ffsaf.net2.Client_Thread;
|
||||||
|
import fr.titionfire.ffsaf.net2.data.SimpleCombModel;
|
||||||
|
|
||||||
|
import java.util.ArrayList;
|
||||||
|
|
||||||
|
public class SReqComb {
|
||||||
|
|
||||||
|
public static void sendIfNeed(ArrayList<Client_Thread> client_Thread, SimpleCombModel comb) {
|
||||||
|
for (Client_Thread client : client_Thread) {
|
||||||
|
client.sendNotify(comb, "sendComb");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public static void sendIfNeedAdd(ArrayList<Client_Thread> client_Thread, SimpleCombModel comb) {
|
||||||
|
for (Client_Thread client : client_Thread) {
|
||||||
|
client.sendNotify(comb, "sendAddComb");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public static void sendRm(ArrayList<Client_Thread> client_Thread, long id) {
|
||||||
|
for (Client_Thread client : client_Thread) {
|
||||||
|
client.sendNotify(id, "sendRmComb");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -1,5 +1,6 @@
|
|||||||
package fr.titionfire.ffsaf.rest;
|
package fr.titionfire.ffsaf.rest;
|
||||||
|
|
||||||
|
import fr.titionfire.ffsaf.rest.data.UserInfo;
|
||||||
import io.quarkus.security.Authenticated;
|
import io.quarkus.security.Authenticated;
|
||||||
import io.quarkus.security.identity.SecurityIdentity;
|
import io.quarkus.security.identity.SecurityIdentity;
|
||||||
import jakarta.inject.Inject;
|
import jakarta.inject.Inject;
|
||||||
@ -9,6 +10,7 @@ import jakarta.ws.rs.Produces;
|
|||||||
import jakarta.ws.rs.core.MediaType;
|
import jakarta.ws.rs.core.MediaType;
|
||||||
import jakarta.ws.rs.core.Response;
|
import jakarta.ws.rs.core.Response;
|
||||||
import org.eclipse.microprofile.config.inject.ConfigProperty;
|
import org.eclipse.microprofile.config.inject.ConfigProperty;
|
||||||
|
import org.eclipse.microprofile.jwt.JsonWebToken;
|
||||||
|
|
||||||
import java.net.URI;
|
import java.net.URI;
|
||||||
import java.net.URISyntaxException;
|
import java.net.URISyntaxException;
|
||||||
@ -22,6 +24,9 @@ public class AuthEndpoints {
|
|||||||
@Inject
|
@Inject
|
||||||
SecurityIdentity securityIdentity;
|
SecurityIdentity securityIdentity;
|
||||||
|
|
||||||
|
@Inject
|
||||||
|
JsonWebToken accessToken;
|
||||||
|
|
||||||
@GET
|
@GET
|
||||||
@Produces(MediaType.TEXT_PLAIN)
|
@Produces(MediaType.TEXT_PLAIN)
|
||||||
public Boolean auth() {
|
public Boolean auth() {
|
||||||
@ -30,9 +35,10 @@ public class AuthEndpoints {
|
|||||||
|
|
||||||
@GET
|
@GET
|
||||||
@Path("/userinfo")
|
@Path("/userinfo")
|
||||||
@Produces(MediaType.TEXT_PLAIN)
|
@Authenticated
|
||||||
public String userinfo() {
|
@Produces(MediaType.APPLICATION_JSON)
|
||||||
return securityIdentity.getPrincipal().getName();
|
public UserInfo userinfo() {
|
||||||
|
return UserInfo.makeUserInfo(accessToken, securityIdentity);
|
||||||
}
|
}
|
||||||
|
|
||||||
@GET
|
@GET
|
||||||
|
|||||||
@ -12,17 +12,13 @@ import jakarta.ws.rs.*;
|
|||||||
import jakarta.ws.rs.core.HttpHeaders;
|
import jakarta.ws.rs.core.HttpHeaders;
|
||||||
import jakarta.ws.rs.core.MediaType;
|
import jakarta.ws.rs.core.MediaType;
|
||||||
import jakarta.ws.rs.core.Response;
|
import jakarta.ws.rs.core.Response;
|
||||||
import org.apache.commons.io.FileUtils;
|
import jodd.net.MimeTypes;
|
||||||
import org.apache.tika.Tika;
|
|
||||||
import org.apache.tika.mime.MimeTypeException;
|
|
||||||
import org.apache.tika.mime.MimeTypes;
|
|
||||||
import org.eclipse.microprofile.config.inject.ConfigProperty;
|
import org.eclipse.microprofile.config.inject.ConfigProperty;
|
||||||
|
|
||||||
import java.io.File;
|
import java.io.*;
|
||||||
import java.io.FilenameFilter;
|
|
||||||
import java.io.IOException;
|
|
||||||
import java.net.URI;
|
import java.net.URI;
|
||||||
import java.net.URISyntaxException;
|
import java.net.URISyntaxException;
|
||||||
|
import java.net.URLConnection;
|
||||||
import java.nio.file.Files;
|
import java.nio.file.Files;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.concurrent.CompletableFuture;
|
import java.util.concurrent.CompletableFuture;
|
||||||
@ -60,12 +56,25 @@ public class CombEndpoints {
|
|||||||
@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(() -> {
|
Future<String> future = CompletableFuture.supplyAsync(() -> {
|
||||||
try{
|
try (InputStream is = new BufferedInputStream(new ByteArrayInputStream(input.getPhoto_data()))) {
|
||||||
String mimeType = new Tika().detect(input.getPhoto_data());
|
String mimeType = URLConnection.guessContentTypeFromStream(is);
|
||||||
String extension = MimeTypes.getDefaultMimeTypes().forName(mimeType).getExtension();
|
String[] detectedExtensions = MimeTypes.findExtensionsByMimeTypes(mimeType, false);
|
||||||
FileUtils.writeByteArrayToFile(new File(media, "ppMembre/" + input.getId() + extension), input.getPhoto_data());
|
if (detectedExtensions.length == 0)
|
||||||
|
throw new IOException("Fail to detect file extension for MIME type " + mimeType);
|
||||||
|
|
||||||
|
FilenameFilter filter = (directory, filename) -> filename.startsWith(String.valueOf(id));
|
||||||
|
File[] files = new File(media, "ppMembre").listFiles(filter);
|
||||||
|
if (files != null) {
|
||||||
|
for (File file : files) {
|
||||||
|
//noinspection ResultOfMethodCallIgnored
|
||||||
|
file.delete();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
String extension = "." + detectedExtensions[0];
|
||||||
|
Files.write(new File(media, "ppMembre/" + input.getId() + extension).toPath(), input.getPhoto_data());
|
||||||
return "OK";
|
return "OK";
|
||||||
} catch (IOException | MimeTypeException e) {
|
} catch (IOException e) {
|
||||||
return e.getMessage();
|
return e.getMessage();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@ -112,7 +121,7 @@ public class CombEndpoints {
|
|||||||
if (filePair == null)
|
if (filePair == null)
|
||||||
return Response.temporaryRedirect(uri).build();
|
return Response.temporaryRedirect(uri).build();
|
||||||
|
|
||||||
String mimeType = new Tika().detect(filePair.getKey().getName());
|
String mimeType = URLConnection.guessContentTypeFromName(filePair.getKey().getName());
|
||||||
|
|
||||||
Response.ResponseBuilder resp = Response.ok(filePair.getValue());
|
Response.ResponseBuilder resp = Response.ok(filePair.getValue());
|
||||||
resp.type(MediaType.APPLICATION_OCTET_STREAM);
|
resp.type(MediaType.APPLICATION_OCTET_STREAM);
|
||||||
|
|||||||
61
src/main/java/fr/titionfire/ffsaf/rest/CompteEndpoints.java
Normal file
61
src/main/java/fr/titionfire/ffsaf/rest/CompteEndpoints.java
Normal file
@ -0,0 +1,61 @@
|
|||||||
|
package fr.titionfire.ffsaf.rest;
|
||||||
|
|
||||||
|
import fr.titionfire.ffsaf.domain.service.KeycloakService;
|
||||||
|
import fr.titionfire.ffsaf.rest.from.MemberPermForm;
|
||||||
|
import io.smallrye.mutiny.Uni;
|
||||||
|
import jakarta.annotation.security.RolesAllowed;
|
||||||
|
import jakarta.inject.Inject;
|
||||||
|
import jakarta.ws.rs.GET;
|
||||||
|
import jakarta.ws.rs.PUT;
|
||||||
|
import jakarta.ws.rs.Path;
|
||||||
|
import jakarta.ws.rs.PathParam;
|
||||||
|
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
@Path("api/compte")
|
||||||
|
public class CompteEndpoints {
|
||||||
|
|
||||||
|
@Inject
|
||||||
|
KeycloakService service;
|
||||||
|
|
||||||
|
@GET
|
||||||
|
@Path("{id}")
|
||||||
|
@RolesAllowed("federation_admin")
|
||||||
|
public Uni<?> getCompte(@PathParam("id") String id) {
|
||||||
|
return service.fetchCompte(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
@PUT
|
||||||
|
@Path("{id}/init")
|
||||||
|
@RolesAllowed("federation_admin")
|
||||||
|
public Uni<?> initCompte(@PathParam("id") long id) {
|
||||||
|
return service.initCompte(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
@GET
|
||||||
|
@Path("{id}/roles")
|
||||||
|
@RolesAllowed("federation_admin")
|
||||||
|
public Uni<?> getRole(@PathParam("id") String id) {
|
||||||
|
return service.fetchRole(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
@PUT
|
||||||
|
@Path("{id}/roles")
|
||||||
|
@RolesAllowed("federation_admin")
|
||||||
|
public Uni<?> updateRole(@PathParam("id") String id, MemberPermForm form) {
|
||||||
|
List<String> toAdd = new ArrayList<>();
|
||||||
|
List<String> toRemove = new ArrayList<>();
|
||||||
|
|
||||||
|
if (form.isFederation_admin()) toAdd.add("federation_admin");
|
||||||
|
else toRemove.add("federation_admin");
|
||||||
|
if (form.isSafca_super_admin()) toAdd.add("safca_super_admin");
|
||||||
|
else toRemove.add("safca_super_admin");
|
||||||
|
if (form.isSafca_user()) toAdd.add("safca_user");
|
||||||
|
else toRemove.add("safca_user");
|
||||||
|
if (form.isSafca_create_compet()) toAdd.add("safca_create_compet");
|
||||||
|
else toRemove.add("safca_create_compet");
|
||||||
|
|
||||||
|
return service.updateRole(id, toAdd, toRemove);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -19,6 +19,7 @@ import java.util.Date;
|
|||||||
@RegisterForReflection
|
@RegisterForReflection
|
||||||
public class SimpleMembre {
|
public class SimpleMembre {
|
||||||
private long id;
|
private long id;
|
||||||
|
private String userId;
|
||||||
private String lname = "";
|
private String lname = "";
|
||||||
private String fname = "";
|
private String fname = "";
|
||||||
private Categorie categorie;
|
private Categorie categorie;
|
||||||
@ -38,6 +39,7 @@ public class SimpleMembre {
|
|||||||
|
|
||||||
return new SimpleMembreBuilder()
|
return new SimpleMembreBuilder()
|
||||||
.id(model.getId())
|
.id(model.getId())
|
||||||
|
.userId(model.getUserId())
|
||||||
.lname(model.getLname())
|
.lname(model.getLname())
|
||||||
.fname(model.getFname())
|
.fname(model.getFname())
|
||||||
.categorie(model.getCategorie())
|
.categorie(model.getCategorie())
|
||||||
|
|||||||
38
src/main/java/fr/titionfire/ffsaf/rest/data/UserInfo.java
Normal file
38
src/main/java/fr/titionfire/ffsaf/rest/data/UserInfo.java
Normal file
@ -0,0 +1,38 @@
|
|||||||
|
package fr.titionfire.ffsaf.rest.data;
|
||||||
|
|
||||||
|
import io.quarkus.runtime.annotations.RegisterForReflection;
|
||||||
|
import io.quarkus.security.identity.SecurityIdentity;
|
||||||
|
import lombok.Builder;
|
||||||
|
import lombok.Data;
|
||||||
|
import org.eclipse.microprofile.jwt.JsonWebToken;
|
||||||
|
|
||||||
|
import java.util.Set;
|
||||||
|
|
||||||
|
@Data
|
||||||
|
@Builder
|
||||||
|
@RegisterForReflection
|
||||||
|
public class UserInfo {
|
||||||
|
String id;
|
||||||
|
String name;
|
||||||
|
String givenName;
|
||||||
|
String familyName;
|
||||||
|
String email;
|
||||||
|
boolean emailVerified;
|
||||||
|
long expiration;
|
||||||
|
Set<String> groups;
|
||||||
|
Set<String> roles;
|
||||||
|
|
||||||
|
public static UserInfo makeUserInfo(JsonWebToken accessToken, SecurityIdentity securityIdentity) {
|
||||||
|
UserInfo.UserInfoBuilder builder = UserInfo.builder();
|
||||||
|
builder.id(accessToken.getSubject());
|
||||||
|
builder.name(accessToken.getName());
|
||||||
|
builder.givenName(accessToken.getClaim("given_name"));
|
||||||
|
builder.familyName(accessToken.getClaim("family_name"));
|
||||||
|
builder.email(accessToken.getClaim("email"));
|
||||||
|
builder.emailVerified(accessToken.getClaim("email_verified"));
|
||||||
|
builder.expiration(accessToken.getExpirationTime());
|
||||||
|
builder.groups(accessToken.getGroups());
|
||||||
|
builder.roles(securityIdentity.getRoles());
|
||||||
|
return builder.build();
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,21 @@
|
|||||||
|
package fr.titionfire.ffsaf.rest.from;
|
||||||
|
|
||||||
|
import jakarta.ws.rs.FormParam;
|
||||||
|
import lombok.Getter;
|
||||||
|
import lombok.ToString;
|
||||||
|
|
||||||
|
@Getter
|
||||||
|
@ToString
|
||||||
|
public class MemberPermForm {
|
||||||
|
@FormParam("federation_admin")
|
||||||
|
private boolean federation_admin;
|
||||||
|
|
||||||
|
@FormParam("safca_user")
|
||||||
|
private boolean safca_user;
|
||||||
|
|
||||||
|
@FormParam("safca_create_compet")
|
||||||
|
private boolean safca_create_compet;
|
||||||
|
|
||||||
|
@FormParam("safca_super_admin")
|
||||||
|
private boolean safca_super_admin;
|
||||||
|
}
|
||||||
@ -0,0 +1,8 @@
|
|||||||
|
package fr.titionfire.ffsaf.utils;
|
||||||
|
|
||||||
|
public class KeycloakException extends Exception{
|
||||||
|
|
||||||
|
public KeycloakException(String msg) {
|
||||||
|
super(msg);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,5 @@
|
|||||||
|
package fr.titionfire.ffsaf.utils;
|
||||||
|
|
||||||
|
public enum RequiredAction {
|
||||||
|
VERIFY_EMAIL, UPDATE_PROFILE, CONFIGURE_TOTP, UPDATE_PASSWORD, TERMS_AND_CONDITIONS
|
||||||
|
}
|
||||||
@ -17,7 +17,8 @@ quarkus.quartz.start-mode=forced
|
|||||||
%dev.quarkus.log.min-level=ALL
|
%dev.quarkus.log.min-level=ALL
|
||||||
%dev.quarkus.log.category."fr.titionfire.ffsaf".level=ALL
|
%dev.quarkus.log.category."fr.titionfire.ffsaf".level=ALL
|
||||||
|
|
||||||
quarkus.oidc.auth-server-url=https://auth.safca.fr/auth/realms/safca
|
|
||||||
|
quarkus.oidc.auth-server-url=https://auth.safca.fr/realms/safca
|
||||||
quarkus.oidc.client-id=backend
|
quarkus.oidc.client-id=backend
|
||||||
quarkus.oidc.credentials.secret=secret
|
quarkus.oidc.credentials.secret=secret
|
||||||
quarkus.oidc.tls.verification=required
|
quarkus.oidc.tls.verification=required
|
||||||
@ -35,6 +36,7 @@ database.pass=
|
|||||||
|
|
||||||
#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.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
|
||||||
@ -47,3 +49,4 @@ quarkus.http.auth.permission.authenticated.policy=authenticated
|
|||||||
# All users can see the welcome page:
|
# All users can see the welcome page:
|
||||||
quarkus.http.auth.permission.public.paths=/index.html
|
quarkus.http.auth.permission.public.paths=/index.html
|
||||||
quarkus.http.auth.permission.public.policy=permit
|
quarkus.http.auth.permission.public.policy=permit
|
||||||
|
quarkus.keycloak.admin-client.server-url=https://auth.safca.fr
|
||||||
|
|||||||
23
src/main/webapp/package-lock.json
generated
23
src/main/webapp/package-lock.json
generated
@ -18,7 +18,8 @@
|
|||||||
"react": "^18.2.0",
|
"react": "^18.2.0",
|
||||||
"react-dom": "^18.2.0",
|
"react-dom": "^18.2.0",
|
||||||
"react-loader-spinner": "^6.1.6",
|
"react-loader-spinner": "^6.1.6",
|
||||||
"react-router-dom": "^6.21.2"
|
"react-router-dom": "^6.21.2",
|
||||||
|
"react-toastify": "^10.0.4"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/react": "^18.2.43",
|
"@types/react": "^18.2.43",
|
||||||
@ -1631,6 +1632,14 @@
|
|||||||
"node": ">=4"
|
"node": ">=4"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/clsx": {
|
||||||
|
"version": "2.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.0.tgz",
|
||||||
|
"integrity": "sha512-m3iNNWpd9rl3jvvcBnu70ylMdrXt8Vlq4HYadnU5fwcOtvkSQWPmj7amUcDT2qYI7risszBjI5AUIUox9D16pg==",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=6"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/color-convert": {
|
"node_modules/color-convert": {
|
||||||
"version": "1.9.3",
|
"version": "1.9.3",
|
||||||
"resolved": "https://registry.npmjs.org/color-convert/-/color-convert-1.9.3.tgz",
|
"resolved": "https://registry.npmjs.org/color-convert/-/color-convert-1.9.3.tgz",
|
||||||
@ -3609,6 +3618,18 @@
|
|||||||
"react-dom": ">=16.8"
|
"react-dom": ">=16.8"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/react-toastify": {
|
||||||
|
"version": "10.0.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/react-toastify/-/react-toastify-10.0.4.tgz",
|
||||||
|
"integrity": "sha512-etR3RgueY8pe88SA67wLm8rJmL1h+CLqUGHuAoNsseW35oTGJEri6eBTyaXnFKNQ80v/eO10hBYLgz036XRGgA==",
|
||||||
|
"dependencies": {
|
||||||
|
"clsx": "^2.1.0"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"react": ">=16",
|
||||||
|
"react-dom": ">=16"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/reflect.getprototypeof": {
|
"node_modules/reflect.getprototypeof": {
|
||||||
"version": "1.0.4",
|
"version": "1.0.4",
|
||||||
"resolved": "https://registry.npmjs.org/reflect.getprototypeof/-/reflect.getprototypeof-1.0.4.tgz",
|
"resolved": "https://registry.npmjs.org/reflect.getprototypeof/-/reflect.getprototypeof-1.0.4.tgz",
|
||||||
|
|||||||
@ -20,7 +20,8 @@
|
|||||||
"react": "^18.2.0",
|
"react": "^18.2.0",
|
||||||
"react-dom": "^18.2.0",
|
"react-dom": "^18.2.0",
|
||||||
"react-loader-spinner": "^6.1.6",
|
"react-loader-spinner": "^6.1.6",
|
||||||
"react-router-dom": "^6.21.2"
|
"react-router-dom": "^6.21.2",
|
||||||
|
"react-toastify": "^10.0.4"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/react": "^18.2.43",
|
"@types/react": "^18.2.43",
|
||||||
|
|||||||
@ -1,5 +1,4 @@
|
|||||||
import {useEffect, useRef} from 'react'
|
import {useEffect, useRef} from 'react'
|
||||||
import './App.css'
|
|
||||||
import {Nav} from "./components/Nav.jsx";
|
import {Nav} from "./components/Nav.jsx";
|
||||||
import {createBrowserRouter, Outlet, RouterProvider, useRouteError} from "react-router-dom";
|
import {createBrowserRouter, Outlet, RouterProvider, useRouteError} from "react-router-dom";
|
||||||
import {Home} from "./pages/Homepage.jsx";
|
import {Home} from "./pages/Homepage.jsx";
|
||||||
@ -7,6 +6,10 @@ import {AdminRoot, getAdminChildren} from "./pages/admin/AdminRoot.jsx";
|
|||||||
import {AuthCallback} from "./components/auhCallback.jsx";
|
import {AuthCallback} from "./components/auhCallback.jsx";
|
||||||
import {KeycloakContextProvider, useAuthDispatch} from "./hooks/useAuth.jsx";
|
import {KeycloakContextProvider, useAuthDispatch} from "./hooks/useAuth.jsx";
|
||||||
import {check_validity} from "./utils/auth.js";
|
import {check_validity} from "./utils/auth.js";
|
||||||
|
import {ToastContainer} from "react-toastify";
|
||||||
|
|
||||||
|
import './App.css'
|
||||||
|
import 'react-toastify/dist/ReactToastify.css';
|
||||||
|
|
||||||
const router = createBrowserRouter([
|
const router = createBrowserRouter([
|
||||||
{
|
{
|
||||||
@ -49,7 +52,7 @@ function Root() {
|
|||||||
if (isInit.current)
|
if (isInit.current)
|
||||||
return;
|
return;
|
||||||
isInit.current = true
|
isInit.current = true
|
||||||
check_validity(b => dispatch({type: 'init', val: b}))
|
check_validity(data => dispatch({type: 'init', val: data}))
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
return <>
|
return <>
|
||||||
@ -58,6 +61,19 @@ function Root() {
|
|||||||
</header>
|
</header>
|
||||||
<div className="container my-4">
|
<div className="container my-4">
|
||||||
<Outlet/>
|
<Outlet/>
|
||||||
|
<ToastContainer
|
||||||
|
position="top-right"
|
||||||
|
autoClose={5000}
|
||||||
|
hideProgressBar={false}
|
||||||
|
newestOnTop={false}
|
||||||
|
closeOnClick
|
||||||
|
rtl={false}
|
||||||
|
pauseOnFocusLoss
|
||||||
|
draggable
|
||||||
|
pauseOnHover
|
||||||
|
theme="light"
|
||||||
|
transition: Flip
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,13 +1,13 @@
|
|||||||
import {LoadingContextProvider, useLoadingSwitcher} from "../hooks/useLoading.jsx";
|
import {LoadingProvider, useLoadingSwitcher} from "../hooks/useLoading.jsx";
|
||||||
import {useFetch} from "../hooks/useFetch.js";
|
import {useFetch} from "../hooks/useFetch.js";
|
||||||
import {AxiosError} from "./AxiosError.jsx";
|
import {AxiosError} from "./AxiosError.jsx";
|
||||||
|
|
||||||
export function ClubSelect({defaultValue, name}) {
|
export function ClubSelect({defaultValue, name}) {
|
||||||
return <LoadingContextProvider>
|
return <LoadingProvider>
|
||||||
<div className="input-group mb-3">
|
<div className="input-group mb-3">
|
||||||
<ClubSelect_ defaultValue={defaultValue} name={name}/>
|
<ClubSelect_ defaultValue={defaultValue} name={name}/>
|
||||||
</div>
|
</div>
|
||||||
</LoadingContextProvider>
|
</LoadingProvider>
|
||||||
}
|
}
|
||||||
|
|
||||||
function ClubSelect_({defaultValue, name}) {
|
function ClubSelect_({defaultValue, name}) {
|
||||||
|
|||||||
9
src/main/webapp/src/components/ColoredCircle.css
Normal file
9
src/main/webapp/src/components/ColoredCircle.css
Normal file
@ -0,0 +1,9 @@
|
|||||||
|
div .colored-circle {
|
||||||
|
display: inline-block;
|
||||||
|
margin-left: 5px;
|
||||||
|
margin-right: 5px;
|
||||||
|
margin-bottom: -2px;
|
||||||
|
border-radius: 50%;
|
||||||
|
height: 20px;
|
||||||
|
width: 20px;
|
||||||
|
}
|
||||||
16
src/main/webapp/src/components/ColoredCircle.jsx
Normal file
16
src/main/webapp/src/components/ColoredCircle.jsx
Normal file
@ -0,0 +1,16 @@
|
|||||||
|
import {Fragment} from "react";
|
||||||
|
import './ColoredCircle.css'
|
||||||
|
|
||||||
|
export const ColoredCircle = ({color, boolean}) => {
|
||||||
|
const styles = {backgroundColor: '#F00'};
|
||||||
|
|
||||||
|
if (boolean === undefined) {
|
||||||
|
styles.backgroundColor = color
|
||||||
|
} else {
|
||||||
|
styles.backgroundColor = (boolean) ? '#00c700' : '#e50000';
|
||||||
|
}
|
||||||
|
|
||||||
|
return <Fragment>
|
||||||
|
<span className="colored-circle" style={styles}/>
|
||||||
|
</Fragment>
|
||||||
|
};
|
||||||
@ -32,9 +32,9 @@ export function Nav() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function AdminMenu() {
|
function AdminMenu() {
|
||||||
const {is_authenticated, data} = useAuth()
|
const {is_authenticated, userinfo} = useAuth()
|
||||||
|
|
||||||
if (!is_authenticated || !data?.realm_access?.roles?.includes("federation_admin"))
|
if (!is_authenticated || !userinfo?.roles?.includes("federation_admin"))
|
||||||
return <></>
|
return <></>
|
||||||
|
|
||||||
return <li className="nav-item dropdown">
|
return <li className="nav-item dropdown">
|
||||||
|
|||||||
@ -25,15 +25,14 @@ function authReducer(auth, action) {
|
|||||||
switch (action.type) {
|
switch (action.type) {
|
||||||
case 'init': {
|
case 'init': {
|
||||||
return {
|
return {
|
||||||
is_authenticated: action.val,
|
is_authenticated: action.val.state,
|
||||||
data: {realm_access: {roles: ["federation_admin"]}}
|
userinfo: action.val.userinfo
|
||||||
//data: action.val ? JSON.parse(atob(token.split('.')[1])) : null
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
case 'update': {
|
case 'update': {
|
||||||
return {
|
return {
|
||||||
...auth,
|
...auth,
|
||||||
data: {realm_access: {roles: ["federation_admin"]}}
|
// data: {realm_access: {roles: ["federation_admin"]}}
|
||||||
// data: JSON.parse(atob(action.token.split('.')[1]))
|
// data: JSON.parse(atob(action.token.split('.')[1]))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -51,5 +50,5 @@ function authReducer(auth, action) {
|
|||||||
|
|
||||||
const initialAuth = {
|
const initialAuth = {
|
||||||
is_authenticated: undefined,
|
is_authenticated: undefined,
|
||||||
data: undefined,
|
userinfo: undefined,
|
||||||
}
|
}
|
||||||
@ -13,7 +13,7 @@ export function useLoadingSwitcher() {
|
|||||||
return useContext(LoadingSwitcherContext);
|
return useContext(LoadingSwitcherContext);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function LoadingContextProvider({children}) {
|
export function LoadingProvider({children}) {
|
||||||
const [showOverlay, setOverlay] = useState(0);
|
const [showOverlay, setOverlay] = useState(0);
|
||||||
|
|
||||||
return <LoadingContext.Provider value={showOverlay}>
|
return <LoadingContext.Provider value={showOverlay}>
|
||||||
|
|||||||
@ -1,15 +1,15 @@
|
|||||||
import {NavLink, Outlet} from "react-router-dom";
|
import {Outlet} from "react-router-dom";
|
||||||
import './AdminRoot.css'
|
import './AdminRoot.css'
|
||||||
import {LoadingContextProvider} 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 "./MemberPage.jsx";
|
||||||
|
|
||||||
export function AdminRoot() {
|
export function AdminRoot() {
|
||||||
return <>
|
return <>
|
||||||
<h1>Espace administration</h1>
|
<h1>Espace administration</h1>
|
||||||
<LoadingContextProvider>
|
<LoadingProvider>
|
||||||
<Outlet/>
|
<Outlet/>
|
||||||
</LoadingContextProvider>
|
</LoadingProvider>
|
||||||
</>
|
</>
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -1,11 +1,13 @@
|
|||||||
import {useNavigate, useParams} from "react-router-dom";
|
import {useNavigate, useParams} from "react-router-dom";
|
||||||
import {useLoadingSwitcher} from "../../hooks/useLoading.jsx";
|
import {LoadingProvider, useLoadingSwitcher} from "../../hooks/useLoading.jsx";
|
||||||
import {useFetch} from "../../hooks/useFetch.js";
|
import {useFetch} from "../../hooks/useFetch.js";
|
||||||
import {AxiosError} from "../../components/AxiosError.jsx";
|
import {AxiosError} from "../../components/AxiosError.jsx";
|
||||||
import {ClubSelect} from "../../components/ClubSelect.jsx";
|
import {ClubSelect} from "../../components/ClubSelect.jsx";
|
||||||
import {useEffect, useState} from "react";
|
import {useEffect, useState} from "react";
|
||||||
import {apiAxios, getCategoryFormBirthDate} from "../../utils/Tools.js";
|
import {apiAxios, getCategoryFormBirthDate} from "../../utils/Tools.js";
|
||||||
import imageCompression from "browser-image-compression";
|
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;
|
const vite_url = import.meta.env.VITE_URL;
|
||||||
|
|
||||||
@ -16,9 +18,51 @@ export function MemberPage() {
|
|||||||
const setLoading = useLoadingSwitcher()
|
const setLoading = useLoadingSwitcher()
|
||||||
const {data, error} = useFetch(`/member/${id}`, setLoading, 1)
|
const {data, error} = useFetch(`/member/${id}`, setLoading, 1)
|
||||||
|
|
||||||
|
return <>
|
||||||
|
<h2>Page membre</h2>
|
||||||
|
<button type="button" className="btn btn-link" onClick={() => navigate("/admin/member")}>
|
||||||
|
<< 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) => {
|
const handleSubmit = (event) => {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
|
|
||||||
setLoading(1)
|
setLoading(1)
|
||||||
|
|
||||||
const formData = new FormData();
|
const formData = new FormData();
|
||||||
@ -35,15 +79,16 @@ export function MemberPage() {
|
|||||||
formData.append("grade_arbitrage", event.target.grade_arbitrage?.value);
|
formData.append("grade_arbitrage", event.target.grade_arbitrage?.value);
|
||||||
|
|
||||||
const send = (formData_) => {
|
const send = (formData_) => {
|
||||||
apiAxios.post(`/member/${id}`, formData_, {
|
apiAxios.post(`/member/${data.id}`, formData_, {
|
||||||
headers: {
|
headers: {
|
||||||
'Accept': '*/*',
|
'Accept': '*/*',
|
||||||
'Content-Type': 'multipart/form-data',
|
'Content-Type': 'multipart/form-data',
|
||||||
}
|
}
|
||||||
}).then(data => {
|
}).then(_ => {
|
||||||
console.log(data.data)
|
toast.success('Profile mis à jours avec succès 🎉');
|
||||||
}).catch(e => {
|
}).catch(e => {
|
||||||
console.log(e.response)
|
console.log(e.response)
|
||||||
|
toast.error('Échec de la mise à jours du profile 😕 (code: ' + e.response.status + ')');
|
||||||
}).finally(() => {
|
}).finally(() => {
|
||||||
if (setLoading)
|
if (setLoading)
|
||||||
setLoading(0)
|
setLoading(0)
|
||||||
@ -70,37 +115,7 @@ export function MemberPage() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return <>
|
return <form onSubmit={handleSubmit}>
|
||||||
<h2>Page membre</h2>
|
|
||||||
<button type="button" className="btn btn-link" onClick={() => navigate("/admin/member")}>
|
|
||||||
<< retour
|
|
||||||
</button>
|
|
||||||
{data
|
|
||||||
? <MemberForm data={data} handleSubmit={handleSubmit}/>
|
|
||||||
: error && <AxiosError error={error}/>
|
|
||||||
}
|
|
||||||
</>
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
function MemberForm({data, handleSubmit}) {
|
|
||||||
return <div>
|
|
||||||
<div className="row">
|
|
||||||
<div className="col-lg-4">
|
|
||||||
<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>
|
|
||||||
</div>
|
|
||||||
<div className="col-lg-8">
|
|
||||||
<form onSubmit={handleSubmit}>
|
|
||||||
<div className="card mb-4">
|
<div className="card mb-4">
|
||||||
<div className="card-header">Information</div>
|
<div className="card-header">Information</div>
|
||||||
<div className="card-body">
|
<div className="card-body">
|
||||||
@ -117,15 +132,15 @@ function MemberForm({data, handleSubmit}) {
|
|||||||
<div className="row">
|
<div className="row">
|
||||||
<ClubSelect defaultValue={data?.club?.id} name="club"/>
|
<ClubSelect defaultValue={data?.club?.id} name="club"/>
|
||||||
</div>
|
</div>
|
||||||
<OptionField name="grade_arbitrage" text="Rôle" value={data.role}
|
<OptionField name="role" text="Rôle" value={data.role}
|
||||||
values={{NA: 'N/A', ASSESSEUR: 'Assesseur', ARBITRE: 'Arbitre'}}/>
|
|
||||||
<OptionField name="role" text="Grade d'arbitrage" value={data.grade_arbitrage}
|
|
||||||
values={{
|
values={{
|
||||||
MEMBRE: 'Membre',
|
MEMBRE: 'Membre',
|
||||||
PRESIDENT: 'Président',
|
PRESIDENT: 'Président',
|
||||||
TRESORIER: 'Trésorier',
|
TRESORIER: 'Trésorier',
|
||||||
SECRETAIRE: 'Secrétaire'
|
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="row">
|
||||||
<div className="input-group mb-3">
|
<div className="input-group mb-3">
|
||||||
<label className="input-group-text" htmlFor="url_photo">Photos
|
<label className="input-group-text" htmlFor="url_photo">Photos
|
||||||
@ -141,17 +156,103 @@ function MemberForm({data, handleSubmit}) {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</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="row">
|
||||||
<div className="col-md-6">
|
<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 mb-4 mb-md-0">
|
||||||
<div className="card-header">Licence</div>
|
<div className="card-header">Licence</div>
|
||||||
<div className="card-body">
|
<div className="card-body">
|
||||||
<p className="mb-1">Web Design</p>
|
<p className="mb-1">Web Design</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>;
|
||||||
<div className="col-md-6">
|
}
|
||||||
|
|
||||||
|
function SelectCard() {
|
||||||
|
return <div className="col-md-6">
|
||||||
<div className="card mb-4 mb-md-0">
|
<div className="card mb-4 mb-md-0">
|
||||||
<div className="card-header">Sélection en équipe de France</div>
|
<div className="card-header">Sélection en équipe de France</div>
|
||||||
<div className="card-body">
|
<div className="card-body">
|
||||||
@ -159,11 +260,76 @@ function MemberForm({data, handleSubmit}) {
|
|||||||
|
|
||||||
</div>
|
</div>
|
||||||
</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>
|
</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>
|
||||||
|
</>
|
||||||
|
}
|
||||||
</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}) {
|
function BirthDayField({inti_date, inti_category}) {
|
||||||
@ -176,8 +342,7 @@ function BirthDayField({inti_date, inti_category}) {
|
|||||||
setCanUpdate(b)
|
setCanUpdate(b)
|
||||||
}, [date, category])
|
}, [date, category])
|
||||||
|
|
||||||
const updateCat = (e) => {
|
const updateCat = _ => {
|
||||||
console.log(date)
|
|
||||||
setCategory(getCategoryFormBirthDate(new Date(date), new Date('2023-09-01')))
|
setCategory(getCategoryFormBirthDate(new Date(date), new Date('2023-09-01')))
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -224,3 +389,23 @@ function TextField({name, text, value, placeholder, type = "text"}) {
|
|||||||
</div>
|
</div>
|
||||||
</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>
|
||||||
|
}
|
||||||
|
</>
|
||||||
|
}
|
||||||
@ -5,10 +5,13 @@ const vite_url = import.meta.env.VITE_URL;
|
|||||||
export function check_validity(online_callback = () => {
|
export function check_validity(online_callback = () => {
|
||||||
}) {
|
}) {
|
||||||
return axios.get(`${vite_url}/api/auth`).then(data => {
|
return axios.get(`${vite_url}/api/auth`).then(data => {
|
||||||
console.log(data.data)
|
if (data.data) {
|
||||||
online_callback(data.data);
|
axios.get(`${vite_url}/api/auth/userinfo`).then(data => {
|
||||||
|
online_callback({state: true, userinfo: data.data});
|
||||||
|
})
|
||||||
|
}
|
||||||
}).catch(() => {
|
}).catch(() => {
|
||||||
online_callback(false);
|
online_callback({state: false});
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user