diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 00000000..a852b1a3 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,99 @@ +# CLAUDE.md + +This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. + +## Repository overview + +This is a full-stack starter template with a React frontend and a Spring Boot backend, meant to be forked as the base for new applications. It ships with a `user` module (soft-delete, roles/permissions) and an `item` module (a worked CRUD example to copy when adding new domain modules). Authentication itself is **not** implemented here: it is delegated to an external `spring-auth` service (https://github.com/OrifInformatique/spring-auth) that this backend talks to over HTTP/WebClient. + +Two independent projects live side by side: +- `backend/` — Spring Boot 3.5.8 / Java 21 REST API +- `frontend/` — React 19 SPA built with Webpack 5 and Tailwind + +## Common commands + +### Backend (`backend/`) + +```bash +mvn spring-boot:run # run the app +mvn test # run all tests +mvn test -Dtest=ItemServiceTest # run a single test class +mvn test -Dtest=ItemServiceTest#methodName # run a single test method +mvn clean # remove target/ build artifacts +mvn package # build the .jar (also generates REST Docs via asciidoctor) +mvn validate # sanity-check project structure +``` + +Docker (preferred dev workflow): +```bash +docker compose build # rebuild after changing ENVIRONMENT in .env +docker compose up # start app + MariaDB containers +docker compose up -d # same, detached +docker exec -it sh # shell into a running container +``` + +Setup: copy `env-dist` → `.env` and `application.properties-dist` → `application.properties` (both are gitignored; never edit the `-dist` originals). `.env` controls `ENVIRONMENT` (dev/test/prod), the Tomcat port, DB URLs/credentials, the `spring-auth` base URL, Azure OAuth2 URLs, and JWT secret/lifetimes. + +### Frontend (`frontend/`) + +```bash +npm install +npm run serve # webpack-dev-server on PORT from .env (default 4000), proxies /auth, /users, /tests to BACKEND_API_URL +npm run build # production build +npm run storybook # Storybook on :6006 +npm run build-storybook +``` + +Setup: copy `.env-template` → `.env` and adjust `APP_ROOT` if not serving from `/`. + +There is no configured test runner or linter in `frontend/package.json` — don't assume `npm test` or `npm run lint` exist. + +## Architecture + +### Backend: layered, module-per-domain + +Code lives under `ch.sectioninformatique.template`, one package per domain (`user`, `item`, `auth`, `security`, `app`, `config`, `test`). Entry point: `AuthApplication.java` (run this class, not a differently-named `*Application`). Each domain module generally follows Controller → Service → Repository, e.g. for `item`: + +- `ItemController` — `@RestController`, endpoints guarded with `@PreAuthorize("hasAuthority('item:read')")` etc. +- `ItemService` — business logic, authorization checks against the authenticated user (author-or-admin ownership checks), talks to repositories +- `ItemRepository` (+ `ItemRepositoryImpl` for custom queries) — Spring Data JPA +- `ItemExceptions` — nested custom exceptions for this domain, thrown from the service and caught by the global handler +- `ItemBuilder`, `ItemSeeder`, `ItemsDTO` — builder, dev data seeding, response DTO + +**`item` is the canonical example module** — when adding a new domain, copy its shape rather than inventing a new pattern. `user` follows the same shape but is more complex (soft delete + dual role model, see below). + +### Auth is delegated, not implemented locally + +`auth/AuthController` does not perform authentication itself — it relays requests (login, register, refresh, Azure OAuth2 callback) to the external `spring-auth` service via `auth/AuthClient` (a Spring WebFlux `WebClient`, configured in `security/WebClientConfig`). `spring-auth`'s base URL comes from `SPRING_AUTH_URL` in `.env`. Locally-issued JWTs from `spring-auth` are then validated on every request by `security/JwtAuthFilter` + `security/UserAuthenticationProvider`, which populate the Spring Security context. See `doc/frontend_backend_auth_architecture.mmd`/`.png` and `doc/process-documentation.md` for full sequence diagrams (standard login, Azure OAuth2 login, refresh token flow). + +Azure OAuth2 login is a three-party redirect dance (Frontend ↔ this Backend ↔ spring-auth ↔ Azure): the backend receives a temporary auth code at `/auth/auth-code`, exchanges it with spring-auth for tokens, stashes them in the HTTP session, then the frontend polls `GET /auth/tokens` to retrieve and clear them. This is why `SecurityConfig` sets `SessionCreationPolicy.ALWAYS` even though the API is otherwise stateless/JWT-based. + +### Roles and permissions + +`security/RoleEnum` (USER, MANAGER, ADMIN, LOCAL_APP_ROLE) each map to a fixed `EnumSet` (`security/PermissionEnum`, e.g. `item:read`, `user:write`) and are converted to Spring Security `GrantedAuthority`s (`ROLE_*` plus the individual permission strings) via `getGrantedAuthorities()`. Controllers authorize with `@PreAuthorize("hasAuthority('item:write')")`-style expressions, sometimes combined with `hasRole(...)`. + +`User` supports **two kinds of role**: a global `mainRole` (managed by `spring-auth`, promoted via `AuthClient.promoteToAdmin`) and a `Set appSpecificRoles` that is local to this app only (promoted purely against the local DB via `UserService.promoteToLocalAppRole`). Don't conflate the two when adding role-related logic. + +`User` also supports soft delete (`deleted` flag, default repository queries exclude it) alongside a separate permanent/hard-delete path (`UserRepositoryPermanentDelete`, `DELETE /users/{id}/false/permanent`) that also cleans up the `users_app_specific_roles` join table first. + +### Error handling and i18n + +Domain exceptions extend `app/exceptions/AppException` (carries an HTTP status) and implement `MessageKeyProvider` (exposes an i18n message key + format args) rather than a hardcoded message. `app/exceptions/GlobalExceptionHandler` is the single `@ControllerAdvice` that catches these, resolves the key through Spring's `MessageSource` against the request locale, and returns a standardized `ErrorDto` JSON body. Auth failures (401/403) go through the security-specific equivalents, `UserAuthenticationEntryPoint` and `CustomAccessDeniedHandler`, which do the same message-key resolution. + +Message bundles are split per domain: `resources/messages/{app,auth,item,security,user}/messages_{en,fr}.properties`. When adding a new exception, add message keys to the matching domain bundle in both languages rather than inlining a string. Locale is resolved by `config/LocaleConfig` (default `fr-FR`, overridable via a `lang` query param). + +### Frontend: feature-based structure + +`frontend/src/features//` groups everything for a feature: `index.jsx` (entry component), `ui/` (subviews), `api/` (HTTP calls), `locales/{en,fr}/.json` (i18n namespace), and optionally `mocks/`. `frontend/src/common/` holds cross-feature layouts (`MainLayout`), reusable UI (`ui/`), hooks, and utils (`useLocalStorage`, `Redirect`, `fileUtils`). + +Routing is centralized in `src/index.js` (React Router v7), nested under `MainLayout` except for standalone routes like `/testAPI`. `i18n.js` auto-discovers **every** `locales//.json` file across `common/` and `features/**` via `require.context` — dropping a new `locales/en/foo.json` + `locales/fr/foo.json` pair under a feature is enough to register a new i18n namespace, no manual wiring needed. + +State: `zustand` is used for feature-local stores (e.g. `features/auth/authStore.jsx` holds the authenticated user/tokens). No global app-wide store — keep new stores scoped to their feature. + +The dev server proxies `/auth`, `/users`, `/tests` requests to `BACKEND_API_URL` (`webpack.config.js`) — when adding a new backend module with its own top-level path, add it to that proxy `context` array or frontend API calls will hit webpack-dev-server instead of the backend. + +## Notes + +- Both `README.md`s (root, `backend/`, `frontend/`) contain more detailed one-time setup instructions (prerequisites, Docker walkthroughs, Azure OAuth2 sequence diagrams) — consult them for environment setup questions. +- `doc/process-documentation.md` has an in-depth, kept-up-to-date module-by-module reference with class/sequence diagrams for the backend; check it before making non-trivial backend architecture changes. +- Backend REST API docs are auto-generated from tests via Spring REST Docs + Asciidoctor (`mvn package`), output to `backend/target/generated-snippets-html` / `backend/docs`. diff --git a/backend/src/main/java/ch/sectioninformatique/template/auth/AuthClient.java b/backend/src/main/java/ch/sectioninformatique/template/auth/AuthClient.java index 5a3931d4..81f0f76d 100644 --- a/backend/src/main/java/ch/sectioninformatique/template/auth/AuthClient.java +++ b/backend/src/main/java/ch/sectioninformatique/template/auth/AuthClient.java @@ -15,6 +15,7 @@ import ch.sectioninformatique.template.auth.AuthExceptions.UserNotFoundException; import ch.sectioninformatique.template.security.SecurityExceptions.InvalidRefreshTokenException; import ch.sectioninformatique.template.user.UserExceptions.UserDeletionException; +import ch.sectioninformatique.template.user.UserExceptions.UserUpdateException; import ch.sectioninformatique.template.user.UserDto; import jakarta.validation.Valid; import reactor.core.publisher.Mono; @@ -352,6 +353,7 @@ public Mono>> deleteGlobalUser(String token, * * @param token The access token * @param userId The ID of the user to delete permanently + * @param hardDelete A boolean for soft or hard delete (default: false) * @return A Mono> containing the permanent * deletion response (e.g., token or status message) */ @@ -597,4 +599,25 @@ public Mono> getTokenWithAuthCode(AuthCodeDto authCodeDt }); }); } + +/** + * Method for updating users + * @param token the access token + * @param login the user's email + * @param userDto the user's DTO + */ + +public Mono> updateUser(String token, String login, UserDto userDto){ + return webClient.put() + .uri(uriWithOptionalLang("/users/" + login)) + .header(HttpHeaders.AUTHORIZATION, token) + .bodyValue(userDto) + .retrieve() + .onStatus(status -> status.value() >= 400, + response -> response.bodyToMono(ErrorDto.class) + .flatMap(error -> Mono.error(new UserUpdateException(error.message())))) + + .toEntity(String.class); +} + } \ No newline at end of file diff --git a/backend/src/main/java/ch/sectioninformatique/template/auth/AuthController.java b/backend/src/main/java/ch/sectioninformatique/template/auth/AuthController.java index c3a4cfd7..4004b4be 100644 --- a/backend/src/main/java/ch/sectioninformatique/template/auth/AuthController.java +++ b/backend/src/main/java/ch/sectioninformatique/template/auth/AuthController.java @@ -24,8 +24,11 @@ import org.springframework.web.bind.annotation.RequestParam; import org.springframework.web.bind.annotation.RestController; +import ch.sectioninformatique.template.user.User; import ch.sectioninformatique.template.user.UserDto; +import ch.sectioninformatique.template.user.UserMapper; import ch.sectioninformatique.template.user.UserService; + import jakarta.servlet.http.HttpServletRequest; import jakarta.servlet.http.HttpSession; import jakarta.validation.Valid; @@ -49,6 +52,9 @@ public class AuthController { /** Client to send authentication requests to the spring-auth application */ private final AuthClient authClient; + /** Mapper for converting between User entities and DTOs */ + private final UserMapper userMapper; + // Logger for debugging and monitoring the authentication flow. private static final Logger log = LoggerFactory.getLogger(AuthController.class); @@ -98,10 +104,11 @@ public ResponseEntity register(@RequestHeader("Authorization") String t return authClient.register(token, user) .flatMap(response -> { // On successful registration, also register user locally - userService.register(user); + User localUser = userService.register(user); + UserDto localUserDto = userMapper.toUserDto(localUser); - // Return HTTP 200 OK with the response body - return Mono.just(response); + // Return HTTP 200 OK with the locally registered user + return Mono.just(ResponseEntity.status(response.getStatusCode()).body(localUserDto)); }) .block(); } diff --git a/backend/src/main/java/ch/sectioninformatique/template/auth/RegisterDto.java b/backend/src/main/java/ch/sectioninformatique/template/auth/RegisterDto.java index e325a5c4..3f3f983c 100644 --- a/backend/src/main/java/ch/sectioninformatique/template/auth/RegisterDto.java +++ b/backend/src/main/java/ch/sectioninformatique/template/auth/RegisterDto.java @@ -1,13 +1,17 @@ package ch.sectioninformatique.template.auth; +import java.util.List; + /** * Data transfer object for user registration. * This record class holds the information required to create a new user * account. * - * @param firstName The user's first name - * @param lastName The user's last name - * @param login The user's login identifier - * @param password The user's password as a character array + * @param firstName The user's first name + * @param lastName The user's last name + * @param login The user's login identifier + * @param password The user's password as a character array + * @param mainRole The user's main role + * @param appSpecificRoles The user's appSpecificRoles */ -public record RegisterDto(String firstName, String lastName, String login, char[] password) {} +public record RegisterDto(String firstName, String lastName, String login, char[] password, String mainRole , List appSpecificRoles ) {} diff --git a/backend/src/main/java/ch/sectioninformatique/template/item/Item.java b/backend/src/main/java/ch/sectioninformatique/template/item/Item.java index ee409334..2cd2c841 100644 --- a/backend/src/main/java/ch/sectioninformatique/template/item/Item.java +++ b/backend/src/main/java/ch/sectioninformatique/template/item/Item.java @@ -1,15 +1,15 @@ package ch.sectioninformatique.template.item; +import ch.sectioninformatique.template.user.User; + import java.util.Date; import org.hibernate.annotations.CreationTimestamp; -import org.hibernate.annotations.Filter; -import org.hibernate.annotations.FilterDef; -import org.hibernate.annotations.ParamDef; +import org.hibernate.annotations.OnDelete; +import org.hibernate.annotations.OnDeleteAction; import org.hibernate.annotations.SQLDelete; import org.hibernate.annotations.UpdateTimestamp; -import ch.sectioninformatique.template.user.User; import jakarta.persistence.Column; import jakarta.persistence.Entity; import jakarta.persistence.FetchType; @@ -19,6 +19,7 @@ import jakarta.persistence.JoinColumn; import jakarta.persistence.ManyToOne; import jakarta.persistence.Table; + import lombok.Builder; import lombok.Data; import lombok.NoArgsConstructor; @@ -34,8 +35,6 @@ @Builder @NoArgsConstructor @SQLDelete(sql = "UPDATE items SET deleted = true WHERE id = ?") -@FilterDef(name = "delete", parameters = @ParamDef(name = "deleted", type = Boolean.class)) -@Filter(name = "delete", condition = "deleted = :deleted") public class Item { /** @@ -62,7 +61,8 @@ public class Item { * Uses eager fetching to ensure author information is always available. */ @ManyToOne(fetch = FetchType.EAGER) - @JoinColumn(name = "author_id") + @OnDelete(action = OnDeleteAction.SET_NULL) + @JoinColumn(name = "author_id", nullable = true) private User author; diff --git a/backend/src/main/java/ch/sectioninformatique/template/item/ItemRepository.java b/backend/src/main/java/ch/sectioninformatique/template/item/ItemRepository.java index 13a5d4d2..1a7f3910 100644 --- a/backend/src/main/java/ch/sectioninformatique/template/item/ItemRepository.java +++ b/backend/src/main/java/ch/sectioninformatique/template/item/ItemRepository.java @@ -17,6 +17,13 @@ @SuppressWarnings("null") public interface ItemRepository extends CrudRepository { + /** + * Finds all items that are not soft deleted. + * + * @return a list of non-deleted items + */ + List findAllByDeletedFalse(); + /** * Finds all items, including soft deleted ones. * @@ -42,4 +49,12 @@ public interface ItemRepository extends CrudRepository { @Transactional @Query("DELETE FROM Item i WHERE i.id = :id") void deletePermanentlyById(Long id); + + /** + * Deletes all items permantently. + */ + @Modifying + @Transactional + @Query("DELETE FROM Item") + void deleteAllPermanently(); } diff --git a/backend/src/main/java/ch/sectioninformatique/template/item/ItemService.java b/backend/src/main/java/ch/sectioninformatique/template/item/ItemService.java index 6d345bff..e55ea0de 100644 --- a/backend/src/main/java/ch/sectioninformatique/template/item/ItemService.java +++ b/backend/src/main/java/ch/sectioninformatique/template/item/ItemService.java @@ -2,11 +2,9 @@ import jakarta.persistence.EntityManager; -import java.util.ArrayList; import java.util.List; import java.util.Optional; -import org.hibernate.Session; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.springframework.beans.factory.annotation.Autowired; @@ -107,22 +105,26 @@ public Optional getItem(final Long id) { return itemRepository.findById(id); } + /** + * By default, getItems method returns only non-deleted items. + * + * @return A List containing all non-deleted items + */ + public List getItems() { + return getItems(false); + } + /** * Retrieves all items in the system. * - * @return An Iterable containing all items + * @param includeDeleted Whether to include soft-deleted items + * @return A List containing all items including or not soft-deleted ones */ - public Iterable getItems(boolean includeDeleted) - { - Session session = entityManager.unwrap(Session.class); - if(includeDeleted) { - session.disableFilter("delete"); - } else { - session.enableFilter("delete").setParameter("deleted", false); + public List getItems(boolean includeDeleted) { + if (includeDeleted) { + return itemRepository.findAllIncludingDeleted(); } - List items = new ArrayList<>(); - itemRepository.findAll().forEach(items::add); - return items; + return itemRepository.findAllByDeletedFalse(); } /** diff --git a/backend/src/main/java/ch/sectioninformatique/template/security/RoleController.java b/backend/src/main/java/ch/sectioninformatique/template/security/RoleController.java new file mode 100644 index 00000000..c31db8e1 --- /dev/null +++ b/backend/src/main/java/ch/sectioninformatique/template/security/RoleController.java @@ -0,0 +1,27 @@ +package ch.sectioninformatique.template.security; + +import org.springframework.http.ResponseEntity; +import org.springframework.stereotype.Controller; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestMapping; + + +@Controller +@RequestMapping("/roles") +public class RoleController { + private final RoleService roleService; + + RoleController(RoleService roleService) { + this.roleService = roleService; + } + + /** + * Retrieves all roles in the system. + * + * @return A ResponseEntity containing the list of all roles + */ + @GetMapping("/all") + public ResponseEntity getRoles() { + return ResponseEntity.ok().body(roleService.getAllRoles()); + } +} diff --git a/backend/src/main/java/ch/sectioninformatique/template/security/RoleRepository.java b/backend/src/main/java/ch/sectioninformatique/template/security/RoleRepository.java index 50eee2b8..638f3afe 100644 --- a/backend/src/main/java/ch/sectioninformatique/template/security/RoleRepository.java +++ b/backend/src/main/java/ch/sectioninformatique/template/security/RoleRepository.java @@ -1,10 +1,10 @@ package ch.sectioninformatique.template.security; +import java.util.Optional; + import org.springframework.data.repository.CrudRepository; import org.springframework.stereotype.Repository; -import java.util.Optional; - /** * Repository interface for Role entity operations. * This interface: diff --git a/backend/src/main/java/ch/sectioninformatique/template/security/RoleService.java b/backend/src/main/java/ch/sectioninformatique/template/security/RoleService.java new file mode 100644 index 00000000..71f63790 --- /dev/null +++ b/backend/src/main/java/ch/sectioninformatique/template/security/RoleService.java @@ -0,0 +1,38 @@ +package ch.sectioninformatique.template.security; + +import java.util.Optional; + +import org.springframework.stereotype.Service; + +/** + * Service layer for role-related operations. + * Provides a clear boundary between controller logic and repository access. + */ +@Service +public class RoleService { + + private final RoleRepository roleRepository; + + public RoleService(RoleRepository roleRepository) { + this.roleRepository = roleRepository; + } + + /** + * Returns all roles available in the system. + * + * @return all Role entities + */ + public Iterable getAllRoles() { + return roleRepository.findAll(); + } + + /** + * Finds a role by its name. + * + * @param name role enum to search for + * @return Optional containing the Role if found + */ + public Optional findByName(RoleEnum name) { + return roleRepository.findByName(name); + } +} diff --git a/backend/src/main/java/ch/sectioninformatique/template/user/User.java b/backend/src/main/java/ch/sectioninformatique/template/user/User.java index 08076bf5..981a02c2 100644 --- a/backend/src/main/java/ch/sectioninformatique/template/user/User.java +++ b/backend/src/main/java/ch/sectioninformatique/template/user/User.java @@ -1,18 +1,34 @@ package ch.sectioninformatique.template.user; -import jakarta.persistence.*; +import ch.sectioninformatique.template.security.Role; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.FetchType; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.ManyToMany; +import jakarta.persistence.ManyToOne; import jakarta.persistence.Table; -import org.hibernate.annotations.*; -import org.springframework.security.core.authority.SimpleGrantedAuthority; -import ch.sectioninformatique.template.security.Role; +import java.util.ArrayList; +import java.util.Collection; +import java.util.Date; +import java.util.HashSet; +import java.util.List; +import java.util.Set; -import org.springframework.security.core.GrantedAuthority; -import java.util.*; import lombok.Builder; import lombok.Data; import lombok.NoArgsConstructor; +import org.hibernate.annotations.CreationTimestamp; +import org.hibernate.annotations.SQLDelete; +import org.hibernate.annotations.UpdateTimestamp; +import org.springframework.security.core.GrantedAuthority; +import org.springframework.security.core.authority.SimpleGrantedAuthority; + /** * Entity class representing a user in the system. * This class implements Spring Security's UserDetails interface to provide @@ -26,8 +42,6 @@ @Builder @NoArgsConstructor @SQLDelete(sql = "UPDATE users SET deleted = true WHERE id = ?") -@FilterDef(name = "deletedFilter", parameters = @ParamDef(name = "isDeleted", type = Boolean.class)) -@Filter(name = "deletedFilter", condition = "deleted = :isDeleted") public class User { /** Unique identifier for the user */ diff --git a/backend/src/main/java/ch/sectioninformatique/template/user/UserController.java b/backend/src/main/java/ch/sectioninformatique/template/user/UserController.java index e0ef4bbd..42f4634b 100644 --- a/backend/src/main/java/ch/sectioninformatique/template/user/UserController.java +++ b/backend/src/main/java/ch/sectioninformatique/template/user/UserController.java @@ -3,7 +3,6 @@ import java.util.List; import java.util.Map; -import org.springframework.beans.factory.annotation.Autowired; import org.springframework.context.MessageSource; import org.springframework.context.i18n.LocaleContextHolder; import org.springframework.http.ResponseEntity; @@ -14,6 +13,7 @@ import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.PathVariable; import org.springframework.web.bind.annotation.PutMapping; +import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.RequestHeader; import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RequestParam; @@ -47,7 +47,6 @@ public class UserController { /** * Client for making user-related HTTP requests to the authentication service */ - @Autowired private final AuthClient authClient; /** @@ -101,7 +100,7 @@ else if (deleted == true){ } /** - * Handles permanent DELETE requests to "/{userId}/{global}/permanent" + * Handles permanent DELETE requests to "/{userId}/{global}/{hardDelete}" * Permanently deletes a user either locally or from the global auth service, * based on the 'global' flag * If 'global' is false, permanently deletes the user from the local database @@ -111,6 +110,7 @@ else if (deleted == true){ * * @param userId The ID of the user to permanently delete * @param global Flag indicating whether to delete locally or globally + * @param hardDelete A boolean for soft or hard delete (default: false) * @return ResponseEntity with permanent deletion result message */ @DeleteMapping("/{userLogin}") @@ -286,4 +286,41 @@ public ResponseEntity promoteToLocalAppRole(@PathVariable String userLogin) { LocaleContextHolder.getLocale()); return ResponseEntity.ok().body(message); } + + /** + * Updates a user's information. + * This endpoint: + * - Requires the 'user:update' authority + * - Validates the user exists and updates their information + * - Returns success/error message + * + * @param id The ID of the user to update + * @param user The updated user information + * @return ResponseEntity with success message or error details + */ + @PutMapping("/{userLogin}") + @PreAuthorize("hasAuthority('user:update')") + public ResponseEntity updateUser(@PathVariable String userLogin, @RequestBody UserDto user, @RequestHeader("Authorization") String token ) { + + ResponseEntity reponse = userService.updateUser(userLogin, user, token); + return reponse; + } + + /** + * Restores a soft-deleted user. + * This endpoint: + * - Requires the 'user:update' authority + * - Validates the user exists and is deleted + * - Returns success/error message + * + * @param userLogin The login of the user to restore + * @return ResponseEntity with success message or error details + */ + @PutMapping("/{userLogin}/restore") + @PreAuthorize("hasAuthority('user:update')") + public ResponseEntity restoreUser(@PathVariable String userLogin) { + + userService.restoreUser(userLogin); + return ResponseEntity.ok().body("User restored successfully."); + } } diff --git a/backend/src/main/java/ch/sectioninformatique/template/user/UserMapper.java b/backend/src/main/java/ch/sectioninformatique/template/user/UserMapper.java index 7439a449..48877990 100644 --- a/backend/src/main/java/ch/sectioninformatique/template/user/UserMapper.java +++ b/backend/src/main/java/ch/sectioninformatique/template/user/UserMapper.java @@ -41,7 +41,8 @@ public interface UserMapper { * @return A UserDto containing the user's information */ @Mapping(target = "mainRole", expression = "java(user.getMainRole().getName().name())") - @Mapping(target = "appSpecificRoles", expression = "java(user.getAppSpecificRolesString())") + @Mapping(target = "appSpecificRoles", expression = "java(user.getAppSpecificRolesString().stream().sorted().toList())" +) @Mapping(target = "permissions", source = "authorities", qualifiedByName = "authoritiesToPermissions") @Mapping(target = "token", ignore = true) @Mapping(target = "deleted", source = "deleted") diff --git a/backend/src/main/java/ch/sectioninformatique/template/user/UserRepository.java b/backend/src/main/java/ch/sectioninformatique/template/user/UserRepository.java index ac9ba245..212ac224 100644 --- a/backend/src/main/java/ch/sectioninformatique/template/user/UserRepository.java +++ b/backend/src/main/java/ch/sectioninformatique/template/user/UserRepository.java @@ -32,14 +32,25 @@ public interface UserRepository extends JpaRepository, UserRepositor */ Optional findByLogin(String login); + /** + * Returns all users that are not soft-deleted. + * + * @return List of active users + */ + List findAllByDeletedFalse(); + /** * Returns all users including those that are soft-deleted. + * + * @return List of all users */ @Query("SELECT u FROM User u") List findAllIncludingDeleted(); /** * Returns only soft-deleted users. + * + * @return List of soft-deleted users */ @Query("SELECT u FROM User u WHERE u.deleted = true") List findAllDeleted(); diff --git a/backend/src/main/java/ch/sectioninformatique/template/user/UserService.java b/backend/src/main/java/ch/sectioninformatique/template/user/UserService.java index 9c582252..795bc8f7 100644 --- a/backend/src/main/java/ch/sectioninformatique/template/user/UserService.java +++ b/backend/src/main/java/ch/sectioninformatique/template/user/UserService.java @@ -7,7 +7,11 @@ import java.util.Set; import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.context.MessageSource; import org.springframework.context.annotation.Lazy; +import org.springframework.context.i18n.LocaleContextHolder; +import org.springframework.http.HttpStatusCode; +import org.springframework.http.ResponseEntity; import org.springframework.stereotype.Service; import org.springframework.lang.NonNull; @@ -31,12 +35,10 @@ import ch.sectioninformatique.template.user.UserExceptions.PermanentUserDeletionException; import ch.sectioninformatique.template.user.UserExceptions.UserRetrievalException; import ch.sectioninformatique.template.user.UserExceptions.InactiveUserException; -import jakarta.persistence.EntityManager; + import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; -import org.hibernate.Session; - /** * Service class for managing user-related operations. * This class provides functionality for: @@ -49,11 +51,10 @@ @RequiredArgsConstructor @Service @Slf4j - +@SuppressWarnings("null") public class UserService { - /** EntityManager for database operations */ - private final EntityManager entityManager; + private final MessageSource messageSource; /** Repository for user data access */ private final UserRepository userRepository; @@ -109,13 +110,12 @@ public UserDto promoteToLocalAppRole(@NonNull String userLogin) { /** * Retrieves all users in the system (not including soft-deleted users). * - * @return List of all User entities + * @return List of all users as UserDto */ public List allUsers() { - Session session = entityManager.unwrap(Session.class); - session.enableFilter("deletedFilter").setParameter("isDeleted", false); List users = new ArrayList<>(); - userRepository.findAll().forEach(users::add); + users = userRepository.findAllByDeletedFalse(); + List usersDto = new ArrayList<>(); for (User user : users) { usersDto.add(userMapper.toUserDto(user)); @@ -126,11 +126,12 @@ public List allUsers() { /** * Retrieves all users including soft-deleted ones. * - * @return List of all User entities including deleted + * @return List of all users as UserDto including deleted ones */ public List allWithDeletedUsers() { List users = new ArrayList<>(); - userRepository.findAllIncludingDeleted().forEach(users::add); + users = userRepository.findAllIncludingDeleted(); + List usersDto = new ArrayList<>(); for (User user : users) { usersDto.add(userMapper.toUserDto(user)); @@ -141,13 +142,12 @@ public List allWithDeletedUsers() { /** * Retrieves only soft-deleted users. * - * @return List of soft-deleted User entities + * @return List of soft-deleted users as UserDto */ - public List deletedUsers() { - Session session = entityManager.unwrap(Session.class); - session.enableFilter("deletedFilter").setParameter("isDeleted", true); + public List deletedUsers() { List users = new ArrayList<>(); - userRepository.findAllDeleted().forEach(users::add); + users = userRepository.findAllDeleted(); + List usersDto = new ArrayList<>(); for (User user : users) { usersDto.add(userMapper.toUserDto(user)); @@ -197,11 +197,28 @@ public User register(RegisterDto registerDto) { User user = userMapper.signUpToUser(registerDto); - // Add default USER role - Role userRole = roleRepository.findByName(RoleEnum.USER) - .orElseThrow(DefaultRoleNotFoundException::new); - user.setMainRole(userRole); - + // Define the user's main role (transversal for all applications) + if(registerDto.mainRole() == null || registerDto.mainRole().isBlank()){ + // No role is provided, add default USER role + Role userRole = roleRepository.findByName(RoleEnum.USER).orElseThrow(RoleNotFoundException::new); + user.setMainRole(userRole); + } + else{ + // Add the provided role + Role userRole = roleRepository.findByName(RoleEnum.valueOf(registerDto.mainRole())).orElseThrow(RoleNotFoundException::new); + user.setMainRole(userRole); + } + + // Define the user's specific role(s) for this application + if(registerDto.appSpecificRoles() != null ){ + + Set appSpecificRoles = new HashSet<>(); + for (String role : registerDto.appSpecificRoles()) { + appSpecificRoles.add(roleRepository.findByName(RoleEnum.valueOf(role)).orElseThrow(RoleNotFoundException::new)); + } + user.setAppSpecificRoles(appSpecificRoles); + } + User savedUser = userRepository.save(user); return savedUser; } catch (UserValidationException | DuplicateUserException | DefaultRoleNotFoundException e) { @@ -236,7 +253,7 @@ public User getOrCreateAuthenticatedUser(UserDto userDto) { if (localUser == null) { RegisterDto newUser = new RegisterDto(userDto.getFirstName(), userDto.getLastName(), - userDto.getLogin(), null); + userDto.getLogin(), null, null, null); localUser = this.register(newUser); } @@ -492,6 +509,7 @@ public reactor.core.publisher.Mono deleteGlobalAndLocal(String token, St * * @param token The authorization token * @param userId The ID of the user to permanently delete + * @param hardDelete A boolean for soft or hard delete (default: false) * @return Message from the global deletion response * @throws UserDeletionException if the deletion fails or response is invalid */ @@ -509,6 +527,69 @@ public reactor.core.publisher.Mono deleteGlobalAndLocalPermanent(String }); } + /** + * Updates a user's informations. + * + * @param login The login (username) of the user to update + * @param newUser The updated user information + * @param token The authorization token for the request + * @return ResponseEntity containing the update result + */ + public ResponseEntity updateUser(String login, UserDto newUser, String token) { + + // Retrieve the existing user from the database + User existingUser = userRepository.findByLogin(login) + .orElseThrow(() -> new UserNotFoundException(login)); + + // Retrieve the new main role from the database + Role newMainRole = roleRepository.findByName(RoleEnum.valueOf(newUser.getMainRole())) + .orElseThrow(() -> new RoleNotFoundException(newUser.getMainRole())); + + // Call the AuthClient to update the user in the global auth service + // If the response is not successful, return an error response + ResponseEntity response = authClient.updateUser(token, login, newUser).block(); + if (response == null || !response.getStatusCode().is2xxSuccessful()) { + return ResponseEntity.status(HttpStatusCode.valueOf(500)).body(response.getBody()); + } + + // Update the existing user's information with the new data in the local database + if (newUser.getAppSpecificRoles() != null){ + Set newAppSpecificRoles = new HashSet<>(); + for (String role : newUser.getAppSpecificRoles()) { + Role newRole = roleRepository.findByName(RoleEnum.valueOf(role)) + .orElseThrow(() -> new RoleNotFoundException(role)); + newAppSpecificRoles.add(newRole); + } + + existingUser.setAppSpecificRoles(new HashSet<>(newAppSpecificRoles)); + } + + existingUser.setFirstName(newUser.getFirstName()); + existingUser.setLastName(newUser.getLastName()); + existingUser.setLogin(newUser.getLogin()); + existingUser.setMainRole(newMainRole); + + // Save modified Entity + userRepository.save(existingUser); + + return ResponseEntity.ok().body(messageSource.getMessage("user.update.success", null, LocaleContextHolder.getLocale())); + } + + /** + * Restores a soft-deleted user. + * @param userLogin The login of the user to restore + */ + public void restoreUser(String userLogin) { + User userToRestore = userRepository.findByLogin(userLogin) + .orElseThrow(() -> new UserNotFoundException(userLogin)); + + // Change deleted value in the Entity + userToRestore.setDeleted(false); + + // Save modified Entity + userRepository.save(userToRestore); + } + public UserDto getOrCreateUser(UserDto userDto){ Optional optionalUser = userRepository.findByLogin(userDto.getLogin()); diff --git a/backend/src/main/resources/messages/user/messages_en.properties b/backend/src/main/resources/messages/user/messages_en.properties index 121873b1..1d18ec1d 100644 --- a/backend/src/main/resources/messages/user/messages_en.properties +++ b/backend/src/main/resources/messages/user/messages_en.properties @@ -24,3 +24,4 @@ user.duplicate=Duplicate user detected: {0} user.delete.permanent.failed=Failed to permanently delete user: {0} user.deleted.local=Local user deleted successfully user.promoted.local=User promoted to local app role successfully +user.update.success=User updated successfully \ No newline at end of file diff --git a/backend/src/main/resources/messages/user/messages_fr.properties b/backend/src/main/resources/messages/user/messages_fr.properties index c3b7a6b6..3848887d 100644 --- a/backend/src/main/resources/messages/user/messages_fr.properties +++ b/backend/src/main/resources/messages/user/messages_fr.properties @@ -24,3 +24,4 @@ user.duplicate=Utilisateur en double détecté : {0} user.delete.permanent.failed=Échec de la suppression définitive de l''utilisateur : {0} user.deleted.local=Utilisateur local supprimé avec succès user.promoted.local=Utilisateur promu au rôle local de l''application avec succès +user.update.success=Utilisateur mis à jour avec succès diff --git a/backend/src/test/java/ch/sectioninformatique/template/item/ItemServiceTest.java b/backend/src/test/java/ch/sectioninformatique/template/item/ItemServiceTest.java new file mode 100644 index 00000000..6898954f --- /dev/null +++ b/backend/src/test/java/ch/sectioninformatique/template/item/ItemServiceTest.java @@ -0,0 +1,110 @@ +package ch.sectioninformatique.template.item; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +import java.util.List; +import java.util.Optional; + +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; + +import ch.sectioninformatique.template.security.Role; +import ch.sectioninformatique.template.security.RoleEnum; +import ch.sectioninformatique.template.security.RoleRepository; +import ch.sectioninformatique.template.user.User; +import ch.sectioninformatique.template.user.UserRepository; + +@SpringBootTest +public class ItemServiceTest { + + @Autowired + private ItemService itemService; + + @Autowired + private ItemRepository itemRepository; + + @Autowired + private UserRepository userRepository; + + @Autowired + private RoleRepository roleRepository; + + @Test + public void getItemsTest() { + // Clear items table + itemRepository.deleteAllPermanently(); + + Optional role = roleRepository.findByName(RoleEnum.USER); + User author = User.builder() + .firstName("author") + .lastName("test") + .login("test.author@test.com") + .mainRole(role.get()) + .build(); + author = userRepository.save(author); + + Item item = new Item(); + item.setName("Test Item 1"); + item.setDescription("This is a test item."); + item.setAuthor(author); + itemRepository.save(item); + + item = new Item(); + item.setName("Test Item 2"); + item.setDescription("This is a test item."); + item.setAuthor(author); + itemRepository.save(item); + + item = new Item(); + item.setName("Test Item 3"); + item.setDescription("This is soft deleted test item."); + item.setAuthor(author); + item.setDeleted(true); + item = itemRepository.save(item); + + // Check that default getItems returns only non-deleted items + List items = itemService.getItems(); + assertEquals(2, items.size()); + + // Check that getItems with includeDeleted = false returns only non-deleted items + items = itemService.getItems(false); + assertEquals(2, items.size()); + + // Check that getItems with includeDeleted = true returns all items, uncluding deleted ones + items = itemService.getItems(true); + assertEquals(3, items.size()); + + // Clear items table + itemRepository.deleteAllPermanently(); + // Clear author from database + userRepository.deletePermanentlyById(author.getId()); + } + + @Test + public void deleteAuthorTest() { + + Optional role = roleRepository.findByName(RoleEnum.USER); + User author = User.builder() + .firstName("author") + .lastName("test") + .login("test.author@test.com") + .mainRole(role.get()) + .build(); + userRepository.save(author); + + Item item = new Item(); + item.setName("Test Item"); + item.setDescription("This is a test item."); + item.setAuthor(author); + + itemRepository.save(item); + + userRepository.deletePermanentlyById(author.getId()); + + Item updatedItem = itemRepository.findById(item.getId()).orElseThrow(); + + //Then + assertEquals(null, updatedItem.getAuthor()); + } +} diff --git a/backend/src/test/java/ch/sectioninformatique/template/user/UserControllerTest.java b/backend/src/test/java/ch/sectioninformatique/template/user/UserControllerTest.java index 86fea4fd..b9996776 100644 --- a/backend/src/test/java/ch/sectioninformatique/template/user/UserControllerTest.java +++ b/backend/src/test/java/ch/sectioninformatique/template/user/UserControllerTest.java @@ -115,7 +115,7 @@ private String getMessage(String key, Object... args) { private UserDto createTemporaryUser() { String uniqueLogin = "temp.permanent." + System.currentTimeMillis() + "@test.com"; - userService.register(new RegisterDto("Temp", "User", uniqueLogin, null)); + userService.register(new RegisterDto("Temp", "User", uniqueLogin, null, null, null)); return userService.findByLogin(uniqueLogin); } diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 959a4afe..70919c45 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -9962,8 +9962,27 @@ "license": "MIT" }, "node_modules/react-project-template": { - "resolved": "", - "link": true + "version": "1.0.0", + "resolved": "file:", + "license": "ISC", + "dependencies": { + "@headlessui/react": "^2.1.9", + "@heroicons/react": "^2.1.5", + "@orif-informatique/react-components-library": "^1.2.2", + "axios": "^1.13.2", + "clsx": "^2.1.1", + "i18n": "^0.15.3", + "i18next": "^25.7.4", + "i18next-browser-languagedetector": "^8.2.0", + "i18next-http-backend": "^3.0.2", + "idb": "^8.0.0", + "react": "^19.1.1", + "react-dom": "^19.1.1", + "react-i18next": "^16.5.3", + "react-project-template": "file:", + "react-router-dom": "^7.8.2", + "zustand": "^5.0.8" + } }, "node_modules/react-router": { "version": "7.9.5", diff --git a/frontend/src/common/utils/Redirect.jsx b/frontend/src/common/utils/Redirect.jsx index 84b99ad8..f321ae3a 100644 --- a/frontend/src/common/utils/Redirect.jsx +++ b/frontend/src/common/utils/Redirect.jsx @@ -5,7 +5,9 @@ const Redirect = ({ to }) => { const navigate = useNavigate(); useEffect(() => { navigate(to); - }); -} + }, [navigate, to]); + + return null; +}; export default Redirect; \ No newline at end of file diff --git a/frontend/src/features/auth/ui/api/loginService.js b/frontend/src/features/auth/ui/api/loginService.js index 94d0eec4..8030515c 100644 --- a/frontend/src/features/auth/ui/api/loginService.js +++ b/frontend/src/features/auth/ui/api/loginService.js @@ -1,7 +1,9 @@ +import { useNavigate } from 'react-router-dom'; import useAuthStore from '../../authStore'; import api from './apiClient'; export const useLogin = () => { + const navigate = useNavigate(); const setAccessToken = useAuthStore((state) => state.setAccessToken); const setUser = useAuthStore((state) => state.setUser); @@ -31,6 +33,8 @@ export const useLogin = () => { if (user) setUser(user); if (token) localStorage.setItem('loginType', 'local'); + navigate(-1); // Navigate back to the previous page after login + console.log('Logged in — token set:', !!token, 'user set:', !!user); } catch (error) { console.error('Erreur lors de la connexion :', error); diff --git a/frontend/src/features/auth/ui/login/index.jsx b/frontend/src/features/auth/ui/login/index.jsx index 6be9bce8..8b6eb3dc 100644 --- a/frontend/src/features/auth/ui/login/index.jsx +++ b/frontend/src/features/auth/ui/login/index.jsx @@ -66,10 +66,6 @@ const Login = () => { refreshAccessToken(); }, [BACKEND_API_URL, accessToken, clearAuth, loginType, setAccessToken]); - const handleOAuth2Login = () => { - const loginUrl = new URL('/auth/login/azure', BACKEND_API_URL); - window.location.assign(loginUrl.toString()); - }; const handleLogoutClick = async () => { await handleLogout(); diff --git a/frontend/src/features/home/index.jsx b/frontend/src/features/home/index.jsx index ad5686bf..30119596 100644 --- a/frontend/src/features/home/index.jsx +++ b/frontend/src/features/home/index.jsx @@ -2,6 +2,7 @@ import React from 'react' import { useTranslation } from 'react-i18next' import Title from '../../common/ui/title' import Items from '../items/items' +import UserList from '../users' const Home = () => { const { t } = useTranslation("home", "common"); @@ -9,6 +10,8 @@ const Home = () => { <> {t("home_title")} + + ) } diff --git a/frontend/src/features/items/api/api.js b/frontend/src/features/items/api/api.js index 19e8a31c..be55d71e 100644 --- a/frontend/src/features/items/api/api.js +++ b/frontend/src/features/items/api/api.js @@ -1,9 +1,4 @@ -import itemsData from "../mocks/items.json"; import api from "../../auth/ui/api/apiClient"; -import useAuthStore from "../../auth/authStore"; - -// Mutable copy of the mock data so mutations don't affect the original import -let items = [...itemsData]; /** * Gets all the items. @@ -15,12 +10,8 @@ export const getItems = async (includeDeleted = false) => { try { - // Uncomment below to use the real backend. - /* - const response = await api.get(`/items`, { params: { includeDeleted } }); + const response = await api.get(`/items/`, { params: { includeDeleted } }); return response.data; - */ - return includeDeleted ? [...items] : items.filter((item) => !item.isDeleted); } catch(error) { @@ -32,18 +23,8 @@ export const modifyItem = async (id, data) => { try { - // Uncomment below to use the real backend. - /* const response = await api.put(`/items/${id}`, data); return response.data; - */ - const index = items.findIndex((item) => item.id === id); - if (index !== -1) - { - items[index] = { ...items[index], ...data }; - return items[index]; - } - return null; } catch(error) { @@ -55,18 +36,8 @@ export const deleteItem = async (id) => { try { - // Uncomment below to use the real backend. - /* const response = await api.delete(`/items/${id}`); return response.data; - */ - const index = items.findIndex((item) => item.id === id); - if (index !== -1) - { - items[index].isDeleted = true; - return items[index]; - } - return null; } catch(error) { @@ -78,18 +49,8 @@ export const restoreItem = async (id) => { try { - // Uncomment below to use the real backend. - /* const response = await api.post(`/items/${id}/restore`); return response.data; - */ - const index = items.findIndex((item) => item.id === id); - if (index !== -1) - { - items[index].isDeleted = false; - return items[index]; - } - return null; } catch(error) { @@ -101,18 +62,10 @@ export const hardDeleteItem = async (id) => { try { - // Uncomment below to use the real backend. - /* const response = await api.delete(`/items/${id}/hard`); return response.data; - */ - const index = items.findIndex((item) => item.id === id); - if (index !== -1) - { - items.splice(index, 1); - return { id }; - } - return null; + + } catch(error) { @@ -124,24 +77,8 @@ export const hardDeleteItem = async (id) => export const createItem = async (data) => { try { - // Uncomment below to use the real backend. - /* const response = await api.post(`/items`, data); return response.data; - */ - const user = useAuthStore.getState().user; - const authorName = user ? `${user.firstName} ${user.lastName}` : "Unknown"; - const newItem = { - id: items.length ? Math.max(...items.map((item) => item.id)) + 1 : 1, - name: data.name, - description: data.description, - author: authorName, - createdAt: new Date().toISOString(), - updatedAt: new Date().toISOString(), - isDeleted: false, - }; - items.push(newItem); - return newItem; } catch(error) { diff --git a/frontend/src/features/items/items.jsx b/frontend/src/features/items/items.jsx index d813d154..a5d9e29e 100644 --- a/frontend/src/features/items/items.jsx +++ b/frontend/src/features/items/items.jsx @@ -1,4 +1,4 @@ -import React, { useEffect, useState } from 'react'; +import React, { useMemo, useCallback, useEffect, useState } from 'react'; import { useTranslation } from 'react-i18next'; import { getItems, deleteItem, restoreItem, hardDeleteItem } from './api/api'; @@ -38,7 +38,7 @@ const Items = () => { }, [showDeleted]); // Imperative refresh for event handlers (after mutations). - const fetchItems = async () => { + const fetchItems = useCallback(async () => { setIsLoading(true); setError(null); try { @@ -49,16 +49,20 @@ const Items = () => { } finally { setIsLoading(false); } - }; + }, [showDeleted, t]); - const actions = { - edit: { permission: "user:update", onClick: (item) => { setSelectedItem(items.find((i) => i.id === item.id) ?? item); setFormOpen(true); } }, - delete: { permission: "user:delete", onClick: (item) => deleteItem(item.id).then(() => fetchItems()).catch((err) => console.error("Delete failed:", err)) }, - restore: { permission: "user:write", onClick: (item) => restoreItem(item.id).then(() => fetchItems()).catch((err) => console.error("Restore failed:", err)) }, - hardDelete: { permission: "user:delete", onClick: (item) => hardDeleteItem(item.id).then(() => fetchItems()).catch((err) => console.error("Hard delete failed:", err)) }, - viewDeleted: { permission: "user:read" }, - view: { permission: "user:read", onClick: (item) => { setSelectedItem(items.find((i) => i.id === item.id) ?? item); setItemOpen(true); } }, - }; + useEffect(() => { + fetchItems(); + }, [fetchItems]); + + const actions = useMemo(() => ({ + // TODO: Replace hardcoded edit with a proper edit form/modal + edit: { permission: "item:update", onClick: (item) => { setSelectedItem(item); setFormOpen(true); } }, + delete: { permission: "item:delete", onClick: (item) => deleteItem(item.id).then(() => fetchItems()).catch((err) => console.error("Delete failed:", err)) }, + restore: { permission: "item:write", onClick: (item) => restoreItem(item.id).then(() => fetchItems()).catch((err) => console.error("Restore failed:", err)) }, + hardDelete: { permission: "item:delete", onClick: (item) => hardDeleteItem(item.id).then(() => fetchItems()).catch((err) => console.error("Hard delete failed:", err)) }, + viewDeleted: { permission: "item:read" }, + }), [fetchItems]); return (
diff --git a/frontend/src/features/items/mocks/items.json b/frontend/src/features/items/mocks/items.json deleted file mode 100644 index 9e604cc3..00000000 --- a/frontend/src/features/items/mocks/items.json +++ /dev/null @@ -1,29 +0,0 @@ -[ - { - "id": 1, - "name": "Leek", - "description": "A leek is a vegetable that is often used in cooking. It has a mild onion-like flavor and is commonly used in soups, stews, and other dishes. (It is also a popular item in the Vocaloid community, often associated with the character Hatsune Miku.)", - "author": "Hatsune Miku", - "createdAt": "2024-06-01T12:00:00Z", - "updatedAt": "2024-06-01T12:00:00Z", - "isDeleted": false - }, - { - "id": 2, - "name": "Baguette", - "description": "A baguette is a long, thin loaf of French bread that is known for its crisp crust and soft interior. It is a staple in French cuisine and is often enjoyed with butter, cheese, or as part of a sandwich. (It is also a popular item in the Vocaloid community, often associated with the character Kasane Teto.)", - "author": "Kasane Teto", - "createdAt": "2024-06-02T12:00:00Z", - "updatedAt": "2024-06-02T12:00:00Z", - "isDeleted": false - }, - { - "id": 3, - "name": "Handphone", - "description": "A handphone is a portable device that combines mobile telephone and computing functions into one unit. It is commonly used for communication, internet access, and various applications. (It is also a popular item in the Vocaloid community, often associated with the character Akita Neru.)", - "author": "Akita Neru", - "createdAt": "2024-06-03T12:00:00Z", - "updatedAt": "2024-06-03T12:00:00Z", - "isDeleted": true - } -] \ No newline at end of file diff --git a/frontend/src/features/users/api/api.js b/frontend/src/features/users/api/api.js new file mode 100644 index 00000000..a1a58947 --- /dev/null +++ b/frontend/src/features/users/api/api.js @@ -0,0 +1,114 @@ +import api from "../../auth/ui/api/apiClient"; + +export const getUsers = async () => { + try { + const response = await api.get(`/users/all`); + return response.data; + } + catch(error) { + console.error(`Error while fetching users: ${error.message}`); + return []; + } +}; +export const deleteUserLocal = async (userLogin) => { + console.log("userID :" + userLogin); + try { + const response = await api.delete(`/users/${userLogin}?global=false&hard=false`); + return response.data; + } + catch(error) { + console.error(`Error while deleting user: ${error.message}`); + return null; + } +}; + +export const getRoles = async () => { + try { + const response = await api.get(`/roles/all`); + return response.data; + } + catch(error) { + console.error(`Error while fetching roles: ${error.message}`); + return []; + }; +} + +export const deleteUserDistant = async (userLogin) => { + console.log("userID :" + userLogin); + try { + const response = await api.delete(`/users/${userLogin}?global=true&hard=false`); + return response.data; + } + catch(error) { + console.error(`Error while deleting user: ${error.message}`); + return null; + } +}; + +export const hardDeleteUserLocal = async (userLogin) => { + console.log("userID :" + userLogin); + try { + const response = await api.delete(`/users/${userLogin}?global=false&hard=true`); + return response.data; + } + catch(error) { + console.error(`Error while hard deleting user: ${error.message}`); + return null; + } +}; + +export const hardDeleteUserDistant = async (id) => { + try { + console.log("userID :" + id); + const response = await api.delete(`/users/${id}/true/permanent`); + return response.data; + } + catch(error) { + console.error(`Error while hard deleting user: ${error.message}`); + return null; + } +}; + +export const createUser = async (data) => { + try { + const response = await api.post(`/auth/register`, data); + return response.data; + } + catch(error) { + console.error(`Error while creating user: ${error.message}`); + return null; + } +}; + +export const updateUser = async (id, data) => { + try { + const response = await api.put(`/users/${id}`, data); + return response.data; + } + catch(error) { + console.error(`Error while updating user: ${error.message}`); + return null; + } +}; + +export const getUserWithDeleted = async () => { + try { + const response = await api.get(`/users?deleted=true`); + return response.data; + } + catch(error) { + console.error(`Error while fetching users with deleted: ${error.message}`); + return []; + } +}; + +export const restoreUser = async (userLogin) => { + try { + const response = await api.put(`/users/${userLogin}/restore`); + return response.data; + } + catch(error) { + console.error(`Error while restoring user: ${error.message}`); + return null; + } +}; \ No newline at end of file diff --git a/frontend/src/features/users/index.jsx b/frontend/src/features/users/index.jsx new file mode 100644 index 00000000..1a7a53b2 --- /dev/null +++ b/frontend/src/features/users/index.jsx @@ -0,0 +1,99 @@ +import React, { useEffect, useState, useMemo } from 'react'; +import { useTranslation } from 'react-i18next' +import useAuthStore from '../auth/authStore'; + +import {deleteUserLocal, hardDeleteUserLocal, deleteUserDistant, hardDeleteUserDistant, restoreUser, getUsers, getUserWithDeleted} from './api/api'; +import { Button, PopUp, List } from '@orif-informatique/react-components-library'; +import UserForm from './userForm'; + +function UserList() { + + const { t } = useTranslation('users'); + const [users, setUser] = useState([]); + const token = useAuthStore(state => state.accessToken); + const user = useAuthStore(state => state.user); + const [showDeleted, setShowDeleted] = useState(false); + const [formOpen, setFormOpen] = useState(false); + const [selectedUser, setSelectedUser] = useState(null); + const usersPermissions = user?.permissions || []; + const usersAppRoles = user?.appSpecificRoles || []; + + const fetchUsers = async () => { + try { + // console.log(showDeleted) + const response = showDeleted + ? await getUserWithDeleted() + : await getUsers(); + + setUser(response); + } catch (error) { + console.error('Error fetching users:', error); + setUser([]); + } + }; + + useEffect(() => { + fetchUsers(); + }, [showDeleted]); + + const actions = useMemo(() => ({ + edit: { permission: "user:update", onClick: (user) => { setSelectedUser(user), setFormOpen(true)}}, + delete: { permission: "user:delete", onClick: (user) => { deleteUserLocal(user.login).then(() => deleteUserDistant(user.login).then(() => fetchUsers()).catch((err) => console.error("Delete failed:", err)))}}, + hardDelete: { permission: "user:delete", onClick: (user) => hardDeleteUserLocal(user.login).then(() => hardDeleteUserDistant(user.login).then(() => fetchUsers())).catch((err) => console.error("Hard delete failed:", err)) }, + viewDeleted: { permission: "user:read"}, + restore: { permission: "user:update", onClick: (user) => restoreUser(user.login).then(() => fetchUsers()).catch((err) => console.error("Restore failed:", err)) } + }), [showDeleted]); + + + const allowedAction = useMemo(() => + Object.entries(actions) + .filter(([actionKey, action]) => usersPermissions.includes(action.permission)) + .reduce((acc, [actionKey, action]) => { + acc[actionKey] = action; + console.log(`Action "${actionKey}" is allowed for user with permissions:`, usersPermissions); + return acc; + }, {}) + , [user, actions]); + + return ( +
+ {formOpen ? ( + setFormOpen(false)} + title={selectedUser ? "Edit User" : "Create User"} + children={ setFormOpen(false)} />} + /> + ) : null} + {user?.permissions?.includes("user:write") && ( +
+ ); + } + +export default UserList; \ No newline at end of file diff --git a/frontend/src/features/users/locales/en/users.json b/frontend/src/features/users/locales/en/users.json new file mode 100644 index 00000000..670fb080 --- /dev/null +++ b/frontend/src/features/users/locales/en/users.json @@ -0,0 +1,16 @@ +{ + "actions": "Actions", + "show_deleted": "Show deleted items", + "no_items": "No items to display.", + "id": "ID", + "firstName": "First Name", + "lastName" : "Last Name", + "login" : "Login", + "mainRole" : "Main Role", + "appSpecificRoles" : "App Specific Roles", + "createdAt": "Created At", + "updatedAt": "Updated At", + "confirm_hard_delete": "Confirm Permanent Deletion", + "confirm_hard_delete_text": "Are you sure you want to permanently delete this user ? This action cannot be undone.", + "no_user_found": "No user found" +} \ No newline at end of file diff --git a/frontend/src/features/users/locales/fr/users.json b/frontend/src/features/users/locales/fr/users.json new file mode 100644 index 00000000..ce95da49 --- /dev/null +++ b/frontend/src/features/users/locales/fr/users.json @@ -0,0 +1,16 @@ +{ + "actions": "Actions", + "show_deleted": "Afficher les éléments supprimés", + "no_items": "Aucun élément à afficher.", + "id": "ID", + "firstName": "Prénom", + "lastName" : "Nom", + "login" : "Email", + "mainRole": "Role principal", + "appSpecificRoles" : "Roles spécifique de l'app", + "createdAt": "Créé le", + "updatedAt": "Mis à jour le", + "confirm_hard_delete": "Confirmer la suppression permanente", + "confirm_hard_delete_text": "Êtes-vous sûr de vouloir supprimer définitivement cet utilisateur ?", + "no_user_found" : "Aucun utilisateur trouvé" +} \ No newline at end of file diff --git a/frontend/src/features/users/userForm.jsx b/frontend/src/features/users/userForm.jsx new file mode 100644 index 00000000..8f583f66 --- /dev/null +++ b/frontend/src/features/users/userForm.jsx @@ -0,0 +1,105 @@ +import React, { useState, useEffect } from 'react' +import { Button, InputText, MultiSelect } from '@orif-informatique/react-components-library'; + +import { createUser, updateUser, getRoles } from "./api/api"; + +function UserForm({ user, onClose }) { +const [firstName, setFirstName] = useState(user ? user.firstName : ""); +const [lastName, setLastName] = useState(user ? user.lastName : ""); +const [login, setLogin] = useState(user ? user.login : ""); +const [password, setPassword] = useState(""); +const [userRoles, setUserRoles] = useState(user ? user.mainRole : ""); +const [roles, setRoles] = useState([]); +const [appSpefRole, setAppSpefRole] = useState([]) +const [userAppSpefRole, setUserAppSpefRole] = useState(user ? user.appSpecificRoles : []) +const roleName = [] +const userRolesName = [] + +appSpefRole.map(r => roleName.push(r.name)) +userAppSpefRole.map(r => userRolesName.push(r.name)) + + + +useEffect(() => { + const fetchRoles = async () => { + const rolesData = await getRoles(); + setRoles(rolesData); + setAppSpefRole(rolesData) + }; + + fetchRoles(); +}, [user]); + +useEffect(() => { + if (user) { + setFirstName(user.firstName || ""); + setLastName(user.lastName || ""); + setLogin(user.login || ""); + setPassword(user.password || ""); + setUserRoles(user.mainRole || ""); + setUserAppSpefRole(user.appSpecificRoles || []) + + console.log("USER_APP_SPECIFIC_ROLES : " + user.appSpecificRoles) + + console.log("User data loaded into form:", { firstName, lastName, login, password, userRoles, userAppSpefRole }); + } +}, [user]); + +return ( + <> + + setFirstName(e.target.value)} /> + setLastName(e.target.value)} /> + setLogin(e.target.value)} /> + setPassword(e.target.value)} /> + {user ? +
+ + +
: null} + + + + + +
+
+ + + ); +} + +export default UserForm; \ No newline at end of file diff --git a/frontend/src/i18n.js b/frontend/src/i18n.js index ecd73a71..15b0bc2b 100644 --- a/frontend/src/i18n.js +++ b/frontend/src/i18n.js @@ -27,7 +27,7 @@ function loadLocales() { resources[lng][namespace] = context(key); } }); - + console.log(resources) return resources; }