feat: add keycloak role configuration

This commit is contained in:
Thibaut Valentin 2024-01-30 20:04:41 +01:00
parent e3306f4b5a
commit 8824a547bc
7 changed files with 213 additions and 87 deletions

View File

@ -13,6 +13,7 @@ import jakarta.ws.rs.core.Response;
import org.eclipse.microprofile.config.inject.ConfigProperty; import org.eclipse.microprofile.config.inject.ConfigProperty;
import org.jboss.logging.Logger; import org.jboss.logging.Logger;
import org.keycloak.admin.client.Keycloak; import org.keycloak.admin.client.Keycloak;
import org.keycloak.admin.client.resource.RoleScopeResource;
import org.keycloak.admin.client.resource.UserResource; import org.keycloak.admin.client.resource.UserResource;
import org.keycloak.representations.idm.GroupRepresentation; import org.keycloak.representations.idm.GroupRepresentation;
import org.keycloak.representations.idm.RoleRepresentation; import org.keycloak.representations.idm.RoleRepresentation;
@ -94,6 +95,21 @@ public class KeycloakService {
}); });
} }
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) { public Uni<String> initCompte(long id) {
return membreService.getById(id).invoke(Unchecked.consumer(membreModel -> { return membreService.getById(id).invoke(Unchecked.consumer(membreModel -> {
if (membreModel.getUserId() != null) if (membreModel.getUserId() != null)

View File

@ -1,6 +1,7 @@
package fr.titionfire.ffsaf.rest; package fr.titionfire.ffsaf.rest;
import fr.titionfire.ffsaf.domain.service.KeycloakService; import fr.titionfire.ffsaf.domain.service.KeycloakService;
import fr.titionfire.ffsaf.rest.from.MemberPermForm;
import io.smallrye.mutiny.Uni; import io.smallrye.mutiny.Uni;
import jakarta.annotation.security.RolesAllowed; import jakarta.annotation.security.RolesAllowed;
import jakarta.inject.Inject; import jakarta.inject.Inject;
@ -9,6 +10,9 @@ import jakarta.ws.rs.PUT;
import jakarta.ws.rs.Path; import jakarta.ws.rs.Path;
import jakarta.ws.rs.PathParam; import jakarta.ws.rs.PathParam;
import java.util.ArrayList;
import java.util.List;
@Path("api/compte") @Path("api/compte")
public class CompteEndpoints { public class CompteEndpoints {
@ -28,4 +32,30 @@ public class CompteEndpoints {
public Uni<?> initCompte(@PathParam("id") long id) { public Uni<?> initCompte(@PathParam("id") long id) {
return service.initCompte(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);
}
} }

View File

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

View File

@ -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}) {

View File

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

View File

@ -1,15 +1,15 @@
import {NavLink, Outlet} from "react-router-dom"; import {NavLink, 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>
</> </>
} }

View File

@ -1,5 +1,5 @@
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, useFetchPut} from "../../hooks/useFetch.js"; import {useFetch, useFetchPut} 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";
@ -18,63 +18,6 @@ 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)
const handleSubmit = (event) => {
event.preventDefault();
setLoading(1)
const formData = new FormData();
formData.append("id", data.id);
formData.append("lname", event.target.lname?.value);
formData.append("fname", event.target.fname?.value);
formData.append("categorie", event.target.category?.value);
formData.append("club", event.target.club?.value);
formData.append("genre", event.target.genre?.value);
formData.append("country", event.target.country?.value);
formData.append("birth_date", new Date(event.target.birth_date?.value).toUTCString());
formData.append("email", event.target.email?.value);
formData.append("role", event.target.role?.value);
formData.append("grade_arbitrage", event.target.grade_arbitrage?.value);
const send = (formData_) => {
apiAxios.post(`/member/${id}`, formData_, {
headers: {
'Accept': '*/*',
'Content-Type': 'multipart/form-data',
}
}).then(data => {
console.log(data.data) // TODO
}).catch(e => {
console.log(e.response) // TODO
}).finally(() => {
if (setLoading)
setLoading(0)
})
}
const imageFile = event.target.url_photo.files[0];
if (imageFile) {
console.log(`originalFile size ${imageFile.size / 1024 / 1024} MB`);
const options = {
maxSizeMB: 1,
maxWidthOrHeight: 1920,
useWebWorker: true,
}
imageCompression(imageFile, options).then(compressedFile => {
console.log(`compressedFile size ${compressedFile.size / 1024 / 1024} MB`); // smaller than maxSizeMB
formData.append("photo_data", compressedFile)
send(formData)
});
} else {
send(formData)
}
}
const handleSubmitPerm = (event) => {
}
return <> return <>
<h2>Page membre</h2> <h2>Page membre</h2>
<button type="button" className="btn btn-link" onClick={() => navigate("/admin/member")}> <button type="button" className="btn btn-link" onClick={() => navigate("/admin/member")}>
@ -85,11 +28,11 @@ export function MemberPage() {
<div className="row"> <div className="row">
<div className="col-lg-4"> <div className="col-lg-4">
<PhotoCard data={data}/> <PhotoCard data={data}/>
<CompteInfo userData={data}/> <LoadingProvider><CompteInfo userData={data}/></LoadingProvider>
</div> </div>
<div className="col-lg-8"> <div className="col-lg-8">
<InformationForm handleSubmit={handleSubmit} data={data}/> <InformationForm data={data}/>
<PremForm handleSubmitPerm={handleSubmitPerm}/> <LoadingProvider><PremForm userData={data}/></LoadingProvider>
<div className="row"> <div className="row">
<LicenceCard/> <LicenceCard/>
<SelectCard/> <SelectCard/>
@ -116,7 +59,62 @@ function PhotoCard({data}) {
</div>; </div>;
} }
function InformationForm({handleSubmit, data}) { function InformationForm({data}) {
const setLoading = useLoadingSwitcher()
const handleSubmit = (event) => {
event.preventDefault();
setLoading(1)
const formData = new FormData();
formData.append("id", data.id);
formData.append("lname", event.target.lname?.value);
formData.append("fname", event.target.fname?.value);
formData.append("categorie", event.target.category?.value);
formData.append("club", event.target.club?.value);
formData.append("genre", event.target.genre?.value);
formData.append("country", event.target.country?.value);
formData.append("birth_date", new Date(event.target.birth_date?.value).toUTCString());
formData.append("email", event.target.email?.value);
formData.append("role", event.target.role?.value);
formData.append("grade_arbitrage", event.target.grade_arbitrage?.value);
const send = (formData_) => {
apiAxios.post(`/member/${data.id}`, formData_, {
headers: {
'Accept': '*/*',
'Content-Type': 'multipart/form-data',
}
}).then(data => {
toast.success('Profile mis à jours avec succès 🎉');
}).catch(e => {
console.log(e.response)
toast.error('Échec de la mise à jours du profile 😕 (code: ' + e.response.status + ')');
}).finally(() => {
if (setLoading)
setLoading(0)
})
}
const imageFile = event.target.url_photo.files[0];
if (imageFile) {
console.log(`originalFile size ${imageFile.size / 1024 / 1024} MB`);
const options = {
maxSizeMB: 1,
maxWidthOrHeight: 1920,
useWebWorker: true,
}
imageCompression(imageFile, options).then(compressedFile => {
console.log(`compressedFile size ${compressedFile.size / 1024 / 1024} MB`); // smaller than maxSizeMB
formData.append("photo_data", compressedFile)
send(formData)
});
} else {
send(formData)
}
}
return <form onSubmit={handleSubmit}> return <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>
@ -161,31 +159,85 @@ function InformationForm({handleSubmit, data}) {
</form>; </form>;
} }
function PremForm({handleSubmitPerm}) { 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(data => {
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}> return <form onSubmit={handleSubmitPerm}>
<div className="card mb-4"> <div className="card mb-4">
<div className="card-header">Permission</div> <div className="card-header">Permission</div>
<div className="card-body"> <div className="card-body">
<div className="row g-3"> <div className="row g-3">
<div className="col"> {userData.userId
<h5>FFSAF intra</h5> ? <PremFormContent userData={userData}/>
<CheckField name="federation_admin" text="Accès à l'intra" value={false}/> : <div className="col">
<div className="input-group mb-3">
<div>Ce membre ne dispose pas de compte...</div>
</div> </div>
<div className="col">
<h5>SAFCA</h5>
<CheckField name="safca_user" text="Accès à l'application" value={false}/>
<CheckField name="safca_create_compet" text="Créer des compétion" value={false}/>
<CheckField name="safca_super_admin" text="Super administrateur" value={false}/>
</div> </div>
}
</div> </div>
<div className="row"> <div className="row">
<div className="col-md-12 text-right"> <div className="col-md-12 text-right">
<button type="submit" className="btn btn-primary">Enregistrer</button> {userData.userId && <button type="submit" className="btn btn-primary">Enregistrer</button>}
</div> </div>
</div> </div>
</div> </div>
</div> </div>
</form>; </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() { function LicenceCard() {
@ -214,12 +266,15 @@ function SelectCard() {
function CompteInfo({userData}) { function CompteInfo({userData}) {
const creatAccount = () => { const creatAccount = () => {
let err = {};
toast.promise( toast.promise(
apiAxios.put(`/compte/${userData.id}/init`), apiAxios.put(`/compte/${userData.id}/init`).catch(e => {
err = e
}),
{ {
pending: 'Création du compte en cours', pending: 'Création du compte en cours',
success: 'Compte créé avec succès 🎉', success: 'Compte créé avec succès 🎉',
error: 'Échec de la création du compte 😕' error: 'Échec de la création du compte 😕 (code: ' + err.response.status + ')'
} }
) )
} }
@ -229,7 +284,8 @@ function CompteInfo({userData}) {
<div className="card-body text-center"> <div className="card-body text-center">
{userData.userId {userData.userId
? <CompteInfoContent userData={userData}/> ? <CompteInfoContent userData={userData}/>
: <> :
<>
<div className="row"> <div className="row">
<div className="input-group mb-3"> <div className="input-group mb-3">
<div>Ce membre ne dispose pas de compte...</div> <div>Ce membre ne dispose pas de compte...</div>
@ -240,13 +296,16 @@ function CompteInfo({userData}) {
<button className="btn btn-primary" onClick={creatAccount}>Initialiser le compte</button> <button className="btn btn-primary" onClick={creatAccount}>Initialiser le compte</button>
</div> </div>
</div> </div>
</>} </>
}
</div> </div>
</div> </div>
} }
function CompteInfoContent({userData}) { function CompteInfoContent({
userData
}) {
const setLoading = useLoadingSwitcher() const setLoading = useLoadingSwitcher()
const {data, error} = useFetch(`/compte/${userData.userId}`, setLoading, 1) const {data, error} = useFetch(`/compte/${userData.userId}`, setLoading, 1)