Compare commits
4 Commits
86f4308ce0
...
app
| Author | SHA1 | Date | |
|---|---|---|---|
| e89f3fc72c | |||
| ebc27a0923 | |||
| 7d2170a20f | |||
| f67b4a9fd6 |
@@ -0,0 +1,99 @@
|
|||||||
|
package fr.bureauservice.app.config;
|
||||||
|
|
||||||
|
import fr.bureauservice.app.service.auth.JpaUserDetailsService;
|
||||||
|
import fr.bureauservice.app.util.auth.JwtAuthFilter;
|
||||||
|
import org.springframework.context.annotation.Bean;
|
||||||
|
import org.springframework.context.annotation.Configuration;
|
||||||
|
import org.springframework.security.authentication.AuthenticationManager;
|
||||||
|
import org.springframework.security.authentication.AuthenticationProvider;
|
||||||
|
import org.springframework.security.authentication.dao.DaoAuthenticationProvider;
|
||||||
|
import org.springframework.security.config.Customizer;
|
||||||
|
import org.springframework.security.config.annotation.authentication.configuration.AuthenticationConfiguration;
|
||||||
|
import org.springframework.security.config.annotation.method.configuration.EnableMethodSecurity;
|
||||||
|
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
|
||||||
|
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
|
||||||
|
import org.springframework.security.config.annotation.web.configurers.AbstractHttpConfigurer;
|
||||||
|
import org.springframework.security.config.http.SessionCreationPolicy;
|
||||||
|
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
|
||||||
|
import org.springframework.security.crypto.password.PasswordEncoder;
|
||||||
|
import org.springframework.security.web.SecurityFilterChain;
|
||||||
|
import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter;
|
||||||
|
import org.springframework.web.cors.CorsConfiguration;
|
||||||
|
import org.springframework.web.cors.CorsConfigurationSource;
|
||||||
|
import org.springframework.web.cors.UrlBasedCorsConfigurationSource;
|
||||||
|
|
||||||
|
import java.util.Arrays;
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
@Configuration
|
||||||
|
@EnableWebSecurity
|
||||||
|
@EnableMethodSecurity
|
||||||
|
public class SecurityConfig {
|
||||||
|
|
||||||
|
private final JwtAuthFilter jwtAuthFilter;
|
||||||
|
private final JpaUserDetailsService userDetailsService;
|
||||||
|
|
||||||
|
public SecurityConfig(JwtAuthFilter jwtAuthFilter, JpaUserDetailsService userDetailsService) {
|
||||||
|
this.jwtAuthFilter = jwtAuthFilter;
|
||||||
|
this.userDetailsService = userDetailsService;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Bean
|
||||||
|
public SecurityFilterChain securityFilterChain(HttpSecurity http) {
|
||||||
|
http
|
||||||
|
.cors(Customizer.withDefaults())
|
||||||
|
.csrf(AbstractHttpConfigurer::disable)
|
||||||
|
.sessionManagement(session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
|
||||||
|
.authenticationProvider(authenticationProvider())
|
||||||
|
.addFilterBefore(jwtAuthFilter, UsernamePasswordAuthenticationFilter.class)
|
||||||
|
.httpBasic(AbstractHttpConfigurer::disable)
|
||||||
|
.formLogin(AbstractHttpConfigurer::disable)
|
||||||
|
.authorizeHttpRequests(authz -> authz
|
||||||
|
.requestMatchers("/api/auth/register", "/api/auth/login", "/api/auth/refresh", "/api/auth/logout").permitAll()
|
||||||
|
.requestMatchers("/api/auth/me").authenticated()
|
||||||
|
.requestMatchers("/api/users/**").authenticated()
|
||||||
|
.requestMatchers("/api/app/**").authenticated()
|
||||||
|
.anyRequest().permitAll()
|
||||||
|
);
|
||||||
|
|
||||||
|
return http.build();
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@Bean
|
||||||
|
public CorsConfigurationSource corsConfigurationSource() {
|
||||||
|
CorsConfiguration config = new CorsConfiguration();
|
||||||
|
|
||||||
|
config.setAllowedOrigins(Arrays.asList(
|
||||||
|
"http://localhost:4200",
|
||||||
|
"http://127.0.0.1:4200"
|
||||||
|
));
|
||||||
|
|
||||||
|
config.setAllowCredentials(true);
|
||||||
|
config.setAllowedHeaders(List.of("*"));
|
||||||
|
config.setAllowedMethods(Arrays.asList("GET", "POST", "PUT", "DELETE", "OPTIONS"));
|
||||||
|
|
||||||
|
config.setExposedHeaders(Arrays.asList("Authorization", "Content-Type", "Set-Cookie"));
|
||||||
|
|
||||||
|
UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
|
||||||
|
source.registerCorsConfiguration("/**", config);
|
||||||
|
return source;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Bean
|
||||||
|
public AuthenticationProvider authenticationProvider() {
|
||||||
|
DaoAuthenticationProvider authProvider = new DaoAuthenticationProvider(userDetailsService);
|
||||||
|
authProvider.setPasswordEncoder(passwordEncoder());
|
||||||
|
return authProvider;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Bean
|
||||||
|
public PasswordEncoder passwordEncoder() {
|
||||||
|
return new BCryptPasswordEncoder();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Bean
|
||||||
|
public AuthenticationManager authenticationManager(AuthenticationConfiguration config) {
|
||||||
|
return config.getAuthenticationManager();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,142 @@
|
|||||||
|
package fr.bureauservice.app.controller.auth;
|
||||||
|
|
||||||
|
import fr.bureauservice.app.dto.auth.AuthRequest;
|
||||||
|
import fr.bureauservice.app.dto.auth.AuthResponse;
|
||||||
|
import fr.bureauservice.app.dto.user.UserDTO;
|
||||||
|
import fr.bureauservice.app.mapper.user.UserMapper;
|
||||||
|
import fr.bureauservice.app.service.auth.AuthService;
|
||||||
|
import jakarta.servlet.http.Cookie;
|
||||||
|
import jakarta.servlet.http.HttpServletRequest;
|
||||||
|
import jakarta.servlet.http.HttpServletResponse;
|
||||||
|
import org.springframework.http.HttpHeaders;
|
||||||
|
import org.springframework.http.ResponseCookie;
|
||||||
|
import org.springframework.http.ResponseEntity;
|
||||||
|
import org.springframework.security.core.annotation.AuthenticationPrincipal;
|
||||||
|
import org.springframework.security.core.userdetails.UserDetails;
|
||||||
|
import org.springframework.web.bind.annotation.*;
|
||||||
|
|
||||||
|
import java.util.Arrays;
|
||||||
|
|
||||||
|
@RestController
|
||||||
|
@RequestMapping("/api/auth")
|
||||||
|
public class AuthController {
|
||||||
|
|
||||||
|
private final AuthService authService;
|
||||||
|
|
||||||
|
public AuthController(AuthService authService) {
|
||||||
|
this.authService = authService;
|
||||||
|
}
|
||||||
|
|
||||||
|
@PostMapping("/register")
|
||||||
|
public ResponseEntity<AuthResponse> register(@RequestBody UserDTO dto,
|
||||||
|
HttpServletResponse response) {
|
||||||
|
return authService.register(UserMapper.fromDto(dto))
|
||||||
|
.map(authResponse -> createAuthResponse(authResponse, response))
|
||||||
|
.orElse(ResponseEntity.badRequest().build());
|
||||||
|
}
|
||||||
|
|
||||||
|
@PostMapping("/login")
|
||||||
|
public ResponseEntity<AuthResponse> authenticate(@RequestBody AuthRequest request,
|
||||||
|
HttpServletResponse response) {
|
||||||
|
return authService.authenticate(request)
|
||||||
|
.map(authResponse -> createAuthResponse(authResponse, response))
|
||||||
|
.orElse(ResponseEntity.badRequest().build());
|
||||||
|
}
|
||||||
|
|
||||||
|
@GetMapping("/logout")
|
||||||
|
public ResponseEntity<Void> logout(HttpServletResponse response) {
|
||||||
|
ResponseCookie accessTokenCookie = ResponseCookie.from("accessToken", "")
|
||||||
|
.httpOnly(true)
|
||||||
|
.secure(false)
|
||||||
|
.path("/")
|
||||||
|
.maxAge(0)
|
||||||
|
.sameSite("Lax")
|
||||||
|
.build();
|
||||||
|
response.addHeader(HttpHeaders.SET_COOKIE, accessTokenCookie.toString());
|
||||||
|
|
||||||
|
ResponseCookie refreshTokenCookie = ResponseCookie.from("refreshToken", "")
|
||||||
|
.httpOnly(true)
|
||||||
|
.secure(false)
|
||||||
|
.path("/")
|
||||||
|
.maxAge(0)
|
||||||
|
.sameSite("Lax")
|
||||||
|
.build();
|
||||||
|
response.addHeader(HttpHeaders.SET_COOKIE, refreshTokenCookie.toString());
|
||||||
|
|
||||||
|
return ResponseEntity.ok().build();
|
||||||
|
}
|
||||||
|
|
||||||
|
@GetMapping("/me")
|
||||||
|
public ResponseEntity<UserDTO> getCurrentUser(@AuthenticationPrincipal UserDetails userDetails) {
|
||||||
|
if (userDetails == null) {
|
||||||
|
return ResponseEntity.status(401).build();
|
||||||
|
}
|
||||||
|
|
||||||
|
return authService.getCurrentUser(userDetails.getUsername())
|
||||||
|
.map(user -> ResponseEntity.ok(UserMapper.toDto(user)))
|
||||||
|
.orElse(ResponseEntity.notFound().build());
|
||||||
|
}
|
||||||
|
|
||||||
|
@PostMapping("/refresh")
|
||||||
|
public ResponseEntity<AuthResponse> refreshPost(HttpServletRequest request,
|
||||||
|
HttpServletResponse response) {
|
||||||
|
return handleRefresh(request, response);
|
||||||
|
}
|
||||||
|
|
||||||
|
@GetMapping("/refresh")
|
||||||
|
public ResponseEntity<AuthResponse> refreshGet(HttpServletRequest request,
|
||||||
|
HttpServletResponse response) {
|
||||||
|
return handleRefresh(request, response);
|
||||||
|
}
|
||||||
|
|
||||||
|
private ResponseEntity<AuthResponse> handleRefresh(HttpServletRequest request,
|
||||||
|
HttpServletResponse response) {
|
||||||
|
String refreshToken = null;
|
||||||
|
|
||||||
|
if (request.getCookies() != null) {
|
||||||
|
refreshToken = Arrays.stream(request.getCookies())
|
||||||
|
.filter(c -> "refreshToken".equals(c.getName()))
|
||||||
|
.findFirst()
|
||||||
|
.map(Cookie::getValue)
|
||||||
|
.orElse(null);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (refreshToken == null) {
|
||||||
|
return ResponseEntity.noContent().build();
|
||||||
|
}
|
||||||
|
|
||||||
|
return authService.refresh(refreshToken)
|
||||||
|
.map(authResponse -> createAuthResponse(authResponse, response))
|
||||||
|
.orElse(ResponseEntity.noContent().build());
|
||||||
|
}
|
||||||
|
|
||||||
|
private ResponseEntity<AuthResponse> createAuthResponse(AuthResponse authResponse,
|
||||||
|
HttpServletResponse response) {
|
||||||
|
// Access token en httpOnly cookie
|
||||||
|
ResponseCookie accessTokenCookie = ResponseCookie.from("accessToken", authResponse.accessToken())
|
||||||
|
.httpOnly(true)
|
||||||
|
.secure(false) // true en prod
|
||||||
|
.path("/")
|
||||||
|
.maxAge(15 * 60) // 15 minutes
|
||||||
|
.sameSite("Lax")
|
||||||
|
.build();
|
||||||
|
response.addHeader(HttpHeaders.SET_COOKIE, accessTokenCookie.toString());
|
||||||
|
|
||||||
|
// Refresh token en httpOnly cookie
|
||||||
|
ResponseCookie refreshTokenCookie = ResponseCookie.from("refreshToken", authResponse.refreshToken())
|
||||||
|
.httpOnly(true)
|
||||||
|
.secure(false)
|
||||||
|
.path("/")
|
||||||
|
.maxAge(60 * 60 * 24 * 7) // 7 jours
|
||||||
|
.sameSite("Lax")
|
||||||
|
.build();
|
||||||
|
response.addHeader(HttpHeaders.SET_COOKIE, refreshTokenCookie.toString());
|
||||||
|
|
||||||
|
// Ne retourner que le username sans les tokens
|
||||||
|
return ResponseEntity.ok(new AuthResponse(
|
||||||
|
authResponse.username(),
|
||||||
|
null,
|
||||||
|
null
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
package fr.bureauservice.app.controller.user;
|
||||||
|
|
||||||
|
import fr.bureauservice.app.model.user.User;
|
||||||
|
import fr.bureauservice.app.service.user.UserService;
|
||||||
|
import org.springframework.beans.factory.annotation.Autowired;
|
||||||
|
import org.springframework.http.ResponseEntity;
|
||||||
|
import org.springframework.web.bind.annotation.*;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
@RestController
|
||||||
|
@RequestMapping("/api/users")
|
||||||
|
public class UserController {
|
||||||
|
|
||||||
|
private final UserService userService;
|
||||||
|
|
||||||
|
@Autowired
|
||||||
|
public UserController(UserService userService) {
|
||||||
|
this.userService = userService;
|
||||||
|
}
|
||||||
|
|
||||||
|
@GetMapping
|
||||||
|
public List<User> getAllUsers() {
|
||||||
|
return userService.getAllUsers();
|
||||||
|
}
|
||||||
|
|
||||||
|
@GetMapping("/{id}")
|
||||||
|
public ResponseEntity<User> getUserById(@PathVariable Long id) {
|
||||||
|
return userService.getUserById(id)
|
||||||
|
.map(ResponseEntity::ok)
|
||||||
|
.orElse(ResponseEntity.notFound().build());
|
||||||
|
}
|
||||||
|
|
||||||
|
@PostMapping
|
||||||
|
public void saveUser(@RequestBody User user) {
|
||||||
|
userService.saveUser(user);
|
||||||
|
}
|
||||||
|
|
||||||
|
@PutMapping("/{id}")
|
||||||
|
public ResponseEntity<User> updateUser(@PathVariable Long id, @RequestBody User user) {
|
||||||
|
return userService.getUserById(id)
|
||||||
|
.map(existingUser -> userService.updateUser(user)
|
||||||
|
.map(ResponseEntity::ok)
|
||||||
|
.orElse(ResponseEntity.notFound().build()))
|
||||||
|
.orElse(ResponseEntity.notFound().build());
|
||||||
|
}
|
||||||
|
|
||||||
|
@DeleteMapping("/{id}")
|
||||||
|
public ResponseEntity<User> deleteUserById(@PathVariable Long id) {
|
||||||
|
return userService.deleteUserById(id)
|
||||||
|
.map(user -> ResponseEntity.ok().body(user))
|
||||||
|
.orElse(ResponseEntity.notFound().build());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
package fr.bureauservice.app.dto.auth;
|
||||||
|
|
||||||
|
public record AuthRequest(
|
||||||
|
String username,
|
||||||
|
String password
|
||||||
|
) {}
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
package fr.bureauservice.app.dto.auth;
|
||||||
|
|
||||||
|
public record AuthResponse(
|
||||||
|
String username,
|
||||||
|
String accessToken,
|
||||||
|
String refreshToken
|
||||||
|
) {}
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
package fr.bureauservice.app.dto.user;
|
||||||
|
|
||||||
|
import lombok.AllArgsConstructor;
|
||||||
|
import lombok.Data;
|
||||||
|
import lombok.NoArgsConstructor;
|
||||||
|
|
||||||
|
@Data
|
||||||
|
@AllArgsConstructor
|
||||||
|
@NoArgsConstructor
|
||||||
|
public class UserDTO {
|
||||||
|
private Long id;
|
||||||
|
private String firstName;
|
||||||
|
private String lastName;
|
||||||
|
private String username;
|
||||||
|
private String email;
|
||||||
|
private String password;
|
||||||
|
private String role;
|
||||||
|
}
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
package fr.bureauservice.app.mapper.user;
|
||||||
|
|
||||||
|
import fr.bureauservice.app.dto.user.UserDTO;
|
||||||
|
import fr.bureauservice.app.model.user.Role;
|
||||||
|
import fr.bureauservice.app.model.user.User;
|
||||||
|
|
||||||
|
public class UserMapper {
|
||||||
|
|
||||||
|
public static User fromDto(UserDTO userDTO) {
|
||||||
|
User user = new User();
|
||||||
|
user.setId(userDTO.getId());
|
||||||
|
user.setFirstName(userDTO.getFirstName());
|
||||||
|
user.setLastName(userDTO.getLastName());
|
||||||
|
user.setUsername(userDTO.getUsername());
|
||||||
|
user.setEmail(userDTO.getEmail());
|
||||||
|
user.setPassword(userDTO.getPassword());
|
||||||
|
user.setRole(Role.valueOf(userDTO.getRole() != null ? userDTO.getRole() : "USER"));
|
||||||
|
return user;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static UserDTO toDto(User user) {
|
||||||
|
UserDTO userDTO = new UserDTO();
|
||||||
|
userDTO.setId(user.getId());
|
||||||
|
userDTO.setFirstName(user.getFirstName());
|
||||||
|
userDTO.setLastName(user.getLastName());
|
||||||
|
userDTO.setUsername(user.getUsername());
|
||||||
|
userDTO.setEmail(user.getEmail());
|
||||||
|
userDTO.setPassword(user.getPassword());
|
||||||
|
userDTO.setRole(user.getRole() != null ? user.getRole().getDisplayName() : null);
|
||||||
|
return userDTO;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
package fr.bureauservice.app.model.auth;
|
||||||
|
|
||||||
|
import com.fasterxml.jackson.annotation.JsonBackReference;
|
||||||
|
import fr.bureauservice.app.model.user.User;
|
||||||
|
import jakarta.persistence.*;
|
||||||
|
import jakarta.validation.constraints.NotNull;
|
||||||
|
import lombok.AllArgsConstructor;
|
||||||
|
import lombok.Data;
|
||||||
|
import lombok.NoArgsConstructor;
|
||||||
|
|
||||||
|
import java.util.Date;
|
||||||
|
|
||||||
|
@Entity
|
||||||
|
@Data
|
||||||
|
@AllArgsConstructor
|
||||||
|
@NoArgsConstructor
|
||||||
|
@Table(name = "tokens")
|
||||||
|
public class Token {
|
||||||
|
|
||||||
|
@Id
|
||||||
|
@GeneratedValue(strategy = GenerationType.IDENTITY)
|
||||||
|
private Long id;
|
||||||
|
|
||||||
|
@NotNull
|
||||||
|
private String value;
|
||||||
|
|
||||||
|
private Date expirationDate;
|
||||||
|
|
||||||
|
@OneToOne
|
||||||
|
@JoinColumn(name = "user_id", referencedColumnName = "id")
|
||||||
|
@JsonBackReference
|
||||||
|
private User user;
|
||||||
|
}
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
package fr.bureauservice.app.model.user;
|
||||||
|
|
||||||
|
import lombok.Getter;
|
||||||
|
|
||||||
|
@Getter
|
||||||
|
public enum Role {
|
||||||
|
USER("User"),
|
||||||
|
ADMIN("Administrator");
|
||||||
|
|
||||||
|
private final String displayName;
|
||||||
|
|
||||||
|
Role(String displayName) {
|
||||||
|
this.displayName = displayName;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,62 @@
|
|||||||
|
package fr.bureauservice.app.model.user;
|
||||||
|
|
||||||
|
import com.fasterxml.jackson.annotation.JsonManagedReference;
|
||||||
|
import jakarta.validation.constraints.Email;
|
||||||
|
import jakarta.validation.constraints.NotBlank;
|
||||||
|
import jakarta.validation.constraints.NotNull;
|
||||||
|
import fr.bureauservice.app.model.auth.Token;
|
||||||
|
import jakarta.persistence.*;
|
||||||
|
import lombok.AllArgsConstructor;
|
||||||
|
import lombok.Data;
|
||||||
|
import lombok.NoArgsConstructor;
|
||||||
|
import org.springframework.security.core.GrantedAuthority;
|
||||||
|
import org.springframework.security.core.authority.SimpleGrantedAuthority;
|
||||||
|
import org.springframework.security.core.userdetails.UserDetails;
|
||||||
|
|
||||||
|
import java.util.Collection;
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
@Entity
|
||||||
|
@Data
|
||||||
|
@AllArgsConstructor
|
||||||
|
@NoArgsConstructor
|
||||||
|
@Table(name = "users")
|
||||||
|
public class User implements UserDetails {
|
||||||
|
|
||||||
|
@Id
|
||||||
|
@GeneratedValue(strategy = GenerationType.IDENTITY)
|
||||||
|
private Long id;
|
||||||
|
|
||||||
|
@NotBlank
|
||||||
|
@Column(length = 30, nullable = false)
|
||||||
|
private String firstName;
|
||||||
|
|
||||||
|
@NotBlank
|
||||||
|
@Column(length = 30, nullable = false)
|
||||||
|
private String lastName;
|
||||||
|
|
||||||
|
@Column(length = 50, unique = true)
|
||||||
|
private String username;
|
||||||
|
|
||||||
|
@Email
|
||||||
|
@NotBlank
|
||||||
|
@Column(length = 120, unique = true, nullable = false)
|
||||||
|
private String email;
|
||||||
|
|
||||||
|
@NotBlank
|
||||||
|
@Column(length = 120, nullable = false)
|
||||||
|
private String password;
|
||||||
|
|
||||||
|
@NotNull
|
||||||
|
@Enumerated(EnumType.STRING)
|
||||||
|
private Role role = Role.USER;
|
||||||
|
|
||||||
|
@OneToOne(mappedBy = "user")
|
||||||
|
@JsonManagedReference
|
||||||
|
private Token token;
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public Collection<? extends GrantedAuthority> getAuthorities() {
|
||||||
|
return List.of(new SimpleGrantedAuthority(Role.USER.name()));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
package fr.bureauservice.app.repository.auth;
|
||||||
|
|
||||||
|
import org.springframework.data.jpa.repository.JpaRepository;
|
||||||
|
import org.springframework.stereotype.Repository;
|
||||||
|
import fr.bureauservice.app.model.auth.Token;
|
||||||
|
|
||||||
|
import java.util.Optional;
|
||||||
|
|
||||||
|
@Repository
|
||||||
|
public interface TokenRepository extends JpaRepository<Token, Long> {
|
||||||
|
Optional<Token> findByValue(String value);
|
||||||
|
}
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
package fr.bureauservice.app.repository.user;
|
||||||
|
|
||||||
|
import fr.bureauservice.app.model.user.User;
|
||||||
|
import org.springframework.data.jpa.repository.JpaRepository;
|
||||||
|
import org.springframework.stereotype.Repository;
|
||||||
|
|
||||||
|
import java.util.Optional;
|
||||||
|
|
||||||
|
@Repository
|
||||||
|
public interface UserRepository extends JpaRepository<User, Long> {
|
||||||
|
Optional<User> findByUsername(String username);
|
||||||
|
Optional<User> findByEmail(String email);
|
||||||
|
}
|
||||||
@@ -0,0 +1,132 @@
|
|||||||
|
package fr.bureauservice.app.service.auth;
|
||||||
|
|
||||||
|
import jakarta.transaction.Transactional;
|
||||||
|
import lombok.RequiredArgsConstructor;
|
||||||
|
import fr.bureauservice.app.dto.auth.AuthRequest;
|
||||||
|
import fr.bureauservice.app.dto.auth.AuthResponse;
|
||||||
|
import fr.bureauservice.app.model.user.User;
|
||||||
|
import fr.bureauservice.app.model.auth.Token;
|
||||||
|
import fr.bureauservice.app.repository.user.UserRepository;
|
||||||
|
import fr.bureauservice.app.repository.auth.TokenRepository;
|
||||||
|
import org.springframework.security.authentication.AuthenticationManager;
|
||||||
|
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
|
||||||
|
import org.springframework.security.core.userdetails.UserDetails;
|
||||||
|
import org.springframework.security.crypto.password.PasswordEncoder;
|
||||||
|
import org.springframework.stereotype.Service;
|
||||||
|
|
||||||
|
import java.util.Date;
|
||||||
|
import java.util.Optional;
|
||||||
|
|
||||||
|
@Service
|
||||||
|
@RequiredArgsConstructor
|
||||||
|
public class AuthService {
|
||||||
|
|
||||||
|
private final AuthenticationManager authenticationManager;
|
||||||
|
private final JwtService jwtService;
|
||||||
|
private final JpaUserDetailsService userDetailsService;
|
||||||
|
private final TokenRepository tokenRepository;
|
||||||
|
private final UserRepository userRepository;
|
||||||
|
private final PasswordEncoder passwordEncoder;
|
||||||
|
|
||||||
|
private final int REFRESH_TOKEN_EXPIRATION_TIME = 1000 * 60 * 60 * 24 * 7; // 7 jours
|
||||||
|
|
||||||
|
public Optional<AuthResponse> register(User user) {
|
||||||
|
String username = user.getUsername();
|
||||||
|
|
||||||
|
if (userRepository.findByUsername(username).isPresent()) {
|
||||||
|
System.err.println("User already exists: " + username);
|
||||||
|
return Optional.empty();
|
||||||
|
}
|
||||||
|
|
||||||
|
user.setPassword(passwordEncoder.encode(user.getPassword()));
|
||||||
|
userDetailsService.registerNewUser(user);
|
||||||
|
|
||||||
|
return Optional.of(new AuthResponse(username, null, null));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Transactional
|
||||||
|
public Optional<AuthResponse> authenticate(AuthRequest request) {
|
||||||
|
// 1. On valide les credentials en tout premier lieu !
|
||||||
|
authenticationManager.authenticate(
|
||||||
|
new UsernamePasswordAuthenticationToken(request.username(), request.password())
|
||||||
|
);
|
||||||
|
|
||||||
|
String accessToken = jwtService.generateToken(request.username());
|
||||||
|
|
||||||
|
UserDetails userDetails = userDetailsService.loadUserByUsername(request.username());
|
||||||
|
|
||||||
|
User user = userRepository.findByUsername(userDetails.getUsername())
|
||||||
|
.orElseThrow(() -> new RuntimeException("User not found: " + userDetails.getUsername()));
|
||||||
|
|
||||||
|
Token token = user.getToken() != null
|
||||||
|
? tokenRepository.findById(user.getToken().getId()).orElse(null)
|
||||||
|
: null;
|
||||||
|
|
||||||
|
if (token == null || isRefreshTokenExpired(token)) {
|
||||||
|
System.out.println("[Authenticate] Refresh token absent ou expiré pour user: " + user.getUsername());
|
||||||
|
token = generateNewRefreshToken(user);
|
||||||
|
}
|
||||||
|
|
||||||
|
return Optional.of(new AuthResponse(
|
||||||
|
user.getUsername(),
|
||||||
|
accessToken,
|
||||||
|
token.getValue()
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Transactional
|
||||||
|
public Optional<User> getCurrentUser(String username) {
|
||||||
|
return userRepository.findByUsername(username)
|
||||||
|
.map(user -> {
|
||||||
|
if (user.getToken() != null && isRefreshTokenExpired(user.getToken())) {
|
||||||
|
System.out.println("[AuthService] Refresh token expired for user: " + user.getUsername());
|
||||||
|
user.setToken(null);
|
||||||
|
userRepository.save(user);
|
||||||
|
}
|
||||||
|
return user;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
@Transactional
|
||||||
|
public Optional<AuthResponse> refresh(String refreshTokenValue) {
|
||||||
|
|
||||||
|
if (tokenRepository.findByValue(refreshTokenValue).isPresent()) {
|
||||||
|
Token savedToken = tokenRepository.findByValue(refreshTokenValue).get();
|
||||||
|
|
||||||
|
User user = savedToken.getUser();
|
||||||
|
|
||||||
|
if (isRefreshTokenExpired(savedToken)) {
|
||||||
|
return Optional.empty();
|
||||||
|
}
|
||||||
|
|
||||||
|
String newAccessToken = jwtService.generateToken(user.getUsername());
|
||||||
|
return Optional.of(new AuthResponse(
|
||||||
|
user.getUsername(),
|
||||||
|
newAccessToken,
|
||||||
|
refreshTokenValue
|
||||||
|
));
|
||||||
|
}
|
||||||
|
return Optional.empty();
|
||||||
|
}
|
||||||
|
|
||||||
|
private Token generateNewRefreshToken(User user) {
|
||||||
|
System.out.println("[AuthService] Generating new refresh token for user: " + user.getUsername());
|
||||||
|
|
||||||
|
Token token = user.getToken();
|
||||||
|
if (token == null) {
|
||||||
|
token = new Token();
|
||||||
|
token.setUser(user);
|
||||||
|
}
|
||||||
|
|
||||||
|
token.setValue(java.util.UUID.randomUUID().toString());
|
||||||
|
token.setExpirationDate(new Date(System.currentTimeMillis() + REFRESH_TOKEN_EXPIRATION_TIME));
|
||||||
|
|
||||||
|
tokenRepository.save(token);
|
||||||
|
user.setToken(token);
|
||||||
|
return token;
|
||||||
|
}
|
||||||
|
|
||||||
|
private boolean isRefreshTokenExpired(Token token) {
|
||||||
|
return token.getExpirationDate().getTime() < System.currentTimeMillis();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
package fr.bureauservice.app.service.auth;
|
||||||
|
|
||||||
|
import fr.bureauservice.app.model.user.User;
|
||||||
|
import fr.bureauservice.app.repository.user.UserRepository;
|
||||||
|
import org.springframework.security.core.userdetails.UserDetails;
|
||||||
|
import org.springframework.security.core.userdetails.UserDetailsService;
|
||||||
|
import org.springframework.security.core.userdetails.UsernameNotFoundException;
|
||||||
|
import org.springframework.stereotype.Service;
|
||||||
|
|
||||||
|
@Service
|
||||||
|
public class JpaUserDetailsService implements UserDetailsService {
|
||||||
|
|
||||||
|
private final UserRepository userRepository;
|
||||||
|
|
||||||
|
public JpaUserDetailsService(UserRepository userRepository) {
|
||||||
|
this.userRepository = userRepository;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
|
||||||
|
return userRepository.findByUsername(username)
|
||||||
|
.orElseThrow(() -> new UsernameNotFoundException("User not found: " + username));
|
||||||
|
}
|
||||||
|
|
||||||
|
public void registerNewUser(User user) {
|
||||||
|
if (userRepository.findByUsername(user.getUsername()).isPresent()) {
|
||||||
|
throw new IllegalArgumentException("Username already exists: " + user.getUsername());
|
||||||
|
}
|
||||||
|
userRepository.save(user);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,65 @@
|
|||||||
|
package fr.bureauservice.app.service.auth;
|
||||||
|
|
||||||
|
import io.jsonwebtoken.Claims;
|
||||||
|
import io.jsonwebtoken.Jwts;
|
||||||
|
import io.jsonwebtoken.SignatureAlgorithm;
|
||||||
|
import io.jsonwebtoken.security.Keys;
|
||||||
|
import org.springframework.beans.factory.annotation.Value;
|
||||||
|
import org.springframework.security.core.userdetails.UserDetails;
|
||||||
|
import org.springframework.stereotype.Service;
|
||||||
|
|
||||||
|
import java.security.Key;
|
||||||
|
import java.util.Base64;
|
||||||
|
import java.util.Date;
|
||||||
|
import java.util.function.Function;
|
||||||
|
|
||||||
|
@Service
|
||||||
|
public class JwtService {
|
||||||
|
|
||||||
|
private static final long EXPIRATION_TIME = 1000 * 60 * 10; // 10min
|
||||||
|
|
||||||
|
private final Key signingKey;
|
||||||
|
|
||||||
|
public JwtService(@Value("${jwt.secret}") String secret) {
|
||||||
|
this.signingKey = Keys.hmacShaKeyFor(Base64.getDecoder().decode(secret));
|
||||||
|
}
|
||||||
|
|
||||||
|
public String generateToken(String username) {
|
||||||
|
return Jwts.builder()
|
||||||
|
.setSubject(username)
|
||||||
|
.setIssuedAt(new Date())
|
||||||
|
.setExpiration(new Date(System.currentTimeMillis() + EXPIRATION_TIME))
|
||||||
|
.signWith(signingKey, SignatureAlgorithm.HS256)
|
||||||
|
.compact();
|
||||||
|
}
|
||||||
|
|
||||||
|
public Claims extractAllClaims(String token) {
|
||||||
|
return Jwts.parserBuilder()
|
||||||
|
.setSigningKey(signingKey)
|
||||||
|
.build()
|
||||||
|
.parseClaimsJws(token)
|
||||||
|
.getBody();
|
||||||
|
}
|
||||||
|
|
||||||
|
public <T> T extractClaim(String token, Function<Claims, T> claimsResolver) {
|
||||||
|
final Claims claims = extractAllClaims(token);
|
||||||
|
return claimsResolver.apply(claims);
|
||||||
|
}
|
||||||
|
|
||||||
|
public String extractUsername(String token) {
|
||||||
|
return extractClaim(token, Claims::getSubject);
|
||||||
|
}
|
||||||
|
|
||||||
|
private Date extractExpiration(String token) {
|
||||||
|
return extractClaim(token, Claims::getExpiration);
|
||||||
|
}
|
||||||
|
|
||||||
|
private boolean isTokenExpired(String token) {
|
||||||
|
return extractExpiration(token).before(new Date());
|
||||||
|
}
|
||||||
|
|
||||||
|
public boolean isTokenValid(String token, UserDetails userDetails) {
|
||||||
|
final String username = extractUsername(token);
|
||||||
|
return (username.equals(userDetails.getUsername()) && !isTokenExpired(token));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
package fr.bureauservice.app.service.auth;
|
||||||
|
|
||||||
|
import fr.bureauservice.app.model.auth.Token;
|
||||||
|
import fr.bureauservice.app.repository.auth.TokenRepository;
|
||||||
|
import jakarta.transaction.Transactional;
|
||||||
|
import org.springframework.beans.factory.annotation.Autowired;
|
||||||
|
import org.springframework.stereotype.Service;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Optional;
|
||||||
|
|
||||||
|
@Service
|
||||||
|
public class TokenService {
|
||||||
|
|
||||||
|
private final TokenRepository tokenRepository;
|
||||||
|
|
||||||
|
@Autowired
|
||||||
|
public TokenService(TokenRepository tokenRepository) {
|
||||||
|
this.tokenRepository = tokenRepository;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Transactional
|
||||||
|
public void saveToken(Token token) {
|
||||||
|
if (token.getId() == null) {
|
||||||
|
tokenRepository.save(token);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public Optional<Token> getTokenById(Long id) {
|
||||||
|
return tokenRepository.findById(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
public List<Token> getAllTokens() {
|
||||||
|
return tokenRepository.findAll();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Transactional
|
||||||
|
public Optional<Token> updateToken(Token token) {
|
||||||
|
return tokenRepository.findById(token.getId()).map(existingToken -> {
|
||||||
|
existingToken.setValue(token.getValue());
|
||||||
|
return tokenRepository.save(existingToken);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
@Transactional
|
||||||
|
public Optional<Token> deleteTokenById(Long id) {
|
||||||
|
Optional<Token> token = tokenRepository.findById(id);
|
||||||
|
token.ifPresent(tokenRepository::delete);
|
||||||
|
return token;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,61 @@
|
|||||||
|
package fr.bureauservice.app.service.user;
|
||||||
|
|
||||||
|
import fr.bureauservice.app.model.user.User;
|
||||||
|
import fr.bureauservice.app.repository.user.UserRepository;
|
||||||
|
import jakarta.transaction.Transactional;
|
||||||
|
import org.springframework.beans.factory.annotation.Autowired;
|
||||||
|
import org.springframework.stereotype.Service;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Optional;
|
||||||
|
|
||||||
|
@Service
|
||||||
|
public class UserService {
|
||||||
|
|
||||||
|
private final UserRepository userRepository;
|
||||||
|
|
||||||
|
@Autowired
|
||||||
|
public UserService(UserRepository userRepository) {
|
||||||
|
this.userRepository = userRepository;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Transactional
|
||||||
|
public void saveUser(User user) {
|
||||||
|
if (user.getId() == null) {
|
||||||
|
userRepository.save(user);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public List<User> getAllUsers() {
|
||||||
|
return userRepository.findAll();
|
||||||
|
}
|
||||||
|
|
||||||
|
public Optional<User> getUserById(Long id) {
|
||||||
|
return userRepository.findById(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
public Optional<User> getUserByUsername(String username) {
|
||||||
|
return userRepository.findByUsername(username);
|
||||||
|
}
|
||||||
|
|
||||||
|
public Optional<User> getUserByEmail(String email) {
|
||||||
|
return userRepository.findByEmail(email);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Transactional
|
||||||
|
public Optional<User> updateUser(User user) {
|
||||||
|
return userRepository.findById(user.getId()).map(existingUser -> {
|
||||||
|
existingUser.setEmail(user.getEmail());
|
||||||
|
existingUser.setUsername(user.getUsername());
|
||||||
|
existingUser.setPassword(user.getPassword());
|
||||||
|
return userRepository.save(existingUser);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
@Transactional
|
||||||
|
public Optional<User> deleteUserById(Long id) {
|
||||||
|
Optional<User> user = userRepository.findById(id);
|
||||||
|
user.ifPresent(userRepository::delete);
|
||||||
|
return user;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,74 @@
|
|||||||
|
package fr.bureauservice.app.util.auth;
|
||||||
|
|
||||||
|
import fr.bureauservice.app.service.auth.JpaUserDetailsService;
|
||||||
|
import fr.bureauservice.app.service.auth.JwtService;
|
||||||
|
import jakarta.servlet.FilterChain;
|
||||||
|
import jakarta.servlet.ServletException;
|
||||||
|
import jakarta.servlet.http.Cookie;
|
||||||
|
import jakarta.servlet.http.HttpServletRequest;
|
||||||
|
import jakarta.servlet.http.HttpServletResponse;
|
||||||
|
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
|
||||||
|
import org.springframework.security.core.context.SecurityContextHolder;
|
||||||
|
import org.springframework.security.core.userdetails.UserDetails;
|
||||||
|
import org.springframework.security.web.authentication.WebAuthenticationDetailsSource;
|
||||||
|
import org.springframework.stereotype.Component;
|
||||||
|
import org.springframework.web.filter.OncePerRequestFilter;
|
||||||
|
|
||||||
|
import java.io.IOException;
|
||||||
|
import java.util.Arrays;
|
||||||
|
|
||||||
|
@Component
|
||||||
|
public class JwtAuthFilter extends OncePerRequestFilter {
|
||||||
|
private final JwtService jwtService;
|
||||||
|
private final JpaUserDetailsService userDetailsService;
|
||||||
|
|
||||||
|
public JwtAuthFilter(JwtService jwtService, JpaUserDetailsService userDetailsService) {
|
||||||
|
this.jwtService = jwtService;
|
||||||
|
this.userDetailsService = userDetailsService;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
protected void doFilterInternal(HttpServletRequest request,
|
||||||
|
HttpServletResponse response,
|
||||||
|
FilterChain filterChain
|
||||||
|
) throws ServletException, IOException {
|
||||||
|
|
||||||
|
// Récupère le token du cookie httpOnly
|
||||||
|
String jwt = null;
|
||||||
|
if (request.getCookies() != null) {
|
||||||
|
jwt = Arrays.stream(request.getCookies())
|
||||||
|
.filter(c -> "accessToken".equals(c.getName()))
|
||||||
|
.findFirst()
|
||||||
|
.map(Cookie::getValue)
|
||||||
|
.orElse(null);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (jwt == null || SecurityContextHolder.getContext().getAuthentication() != null) {
|
||||||
|
filterChain.doFilter(request, response);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
final String username = jwtService.extractUsername(jwt);
|
||||||
|
|
||||||
|
if (username != null) {
|
||||||
|
UserDetails userDetails = this.userDetailsService.loadUserByUsername(username);
|
||||||
|
|
||||||
|
if (jwtService.isTokenValid(jwt, userDetails)) {
|
||||||
|
UsernamePasswordAuthenticationToken authToken = new UsernamePasswordAuthenticationToken(
|
||||||
|
userDetails,
|
||||||
|
null,
|
||||||
|
userDetails.getAuthorities()
|
||||||
|
);
|
||||||
|
|
||||||
|
authToken.setDetails(new WebAuthenticationDetailsSource().buildDetails(request));
|
||||||
|
SecurityContextHolder.getContext().setAuthentication(authToken);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (Exception e) {
|
||||||
|
// Token invalide, on continue sans authentification
|
||||||
|
}
|
||||||
|
|
||||||
|
filterChain.doFilter(request, response);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1 +1,12 @@
|
|||||||
spring.application.name=app
|
spring.application.name=app
|
||||||
|
|
||||||
|
spring.config.import=optional:file:.env[.properties]
|
||||||
|
|
||||||
|
spring.datasource.url=${MYSQL_URL}
|
||||||
|
spring.datasource.username=${MYSQL_USER}
|
||||||
|
spring.datasource.password=${MYSQL_PASS}
|
||||||
|
|
||||||
|
spring.jpa.hibernate.ddl-auto=update
|
||||||
|
spring.jpa.show-sql=true
|
||||||
|
|
||||||
|
jwt.secret=5c6fbfca8ab9a88c4c6308cc8fd7f4f57543cb072e4db605a718d65d3fa16509
|
||||||
|
|||||||
59
webclient/README.md
Normal file
59
webclient/README.md
Normal file
@@ -0,0 +1,59 @@
|
|||||||
|
# Webclient
|
||||||
|
|
||||||
|
This project was generated using [Angular CLI](https://github.com/angular/angular-cli) version 21.2.8.
|
||||||
|
|
||||||
|
## Development server
|
||||||
|
|
||||||
|
To start a local development server, run:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
ng serve
|
||||||
|
```
|
||||||
|
|
||||||
|
Once the server is running, open your browser and navigate to `http://localhost:4200/`. The application will automatically reload whenever you modify any of the source files.
|
||||||
|
|
||||||
|
## Code scaffolding
|
||||||
|
|
||||||
|
Angular CLI includes powerful code scaffolding tools. To generate a new component, run:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
ng generate component component-name
|
||||||
|
```
|
||||||
|
|
||||||
|
For a complete list of available schematics (such as `components`, `directives`, or `pipes`), run:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
ng generate --help
|
||||||
|
```
|
||||||
|
|
||||||
|
## Building
|
||||||
|
|
||||||
|
To build the project run:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
ng build
|
||||||
|
```
|
||||||
|
|
||||||
|
This will compile your project and store the build artifacts in the `dist/` directory. By default, the production build optimizes your application for performance and speed.
|
||||||
|
|
||||||
|
## Running unit tests
|
||||||
|
|
||||||
|
To execute unit tests with the [Vitest](https://vitest.dev/) test runner, use the following command:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
ng test
|
||||||
|
```
|
||||||
|
|
||||||
|
## Running end-to-end tests
|
||||||
|
|
||||||
|
For end-to-end (e2e) testing, run:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
ng e2e
|
||||||
|
```
|
||||||
|
|
||||||
|
Angular CLI does not come with an end-to-end testing framework by default. You can choose one that suits your needs.
|
||||||
|
|
||||||
|
## Additional Resources
|
||||||
|
|
||||||
|
For more information on using the Angular CLI, including detailed command references, visit the [Angular CLI Overview and Command Reference](https://angular.dev/tools/cli) page.
|
||||||
78
webclient/angular.json
Normal file
78
webclient/angular.json
Normal file
@@ -0,0 +1,78 @@
|
|||||||
|
{
|
||||||
|
"$schema": "./node_modules/@angular/cli/lib/config/schema.json",
|
||||||
|
"version": 1,
|
||||||
|
"cli": {
|
||||||
|
"packageManager": "npm"
|
||||||
|
},
|
||||||
|
"newProjectRoot": "projects",
|
||||||
|
"projects": {
|
||||||
|
"webclient": {
|
||||||
|
"projectType": "application",
|
||||||
|
"schematics": {},
|
||||||
|
"root": "",
|
||||||
|
"sourceRoot": "src",
|
||||||
|
"prefix": "app",
|
||||||
|
"architect": {
|
||||||
|
"build": {
|
||||||
|
"builder": "@angular/build:application",
|
||||||
|
"options": {
|
||||||
|
"browser": "src/main.ts",
|
||||||
|
"tsConfig": "tsconfig.app.json",
|
||||||
|
"assets": [
|
||||||
|
{
|
||||||
|
"glob": "**/*",
|
||||||
|
"input": "public"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"styles": [
|
||||||
|
"node_modules/bootstrap/dist/css/bootstrap.min.css",
|
||||||
|
"node_modules/bootstrap-icons/font/bootstrap-icons.css",
|
||||||
|
"src/styles.css"
|
||||||
|
],
|
||||||
|
"scripts": [
|
||||||
|
"node_modules/bootstrap/dist/js/bootstrap.bundle.min.js"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"configurations": {
|
||||||
|
"production": {
|
||||||
|
"budgets": [
|
||||||
|
{
|
||||||
|
"type": "initial",
|
||||||
|
"maximumWarning": "500kB",
|
||||||
|
"maximumError": "1MB"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "anyComponentStyle",
|
||||||
|
"maximumWarning": "4kB",
|
||||||
|
"maximumError": "8kB"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"outputHashing": "all"
|
||||||
|
},
|
||||||
|
"development": {
|
||||||
|
"optimization": false,
|
||||||
|
"extractLicenses": false,
|
||||||
|
"sourceMap": true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"defaultConfiguration": "production"
|
||||||
|
},
|
||||||
|
"serve": {
|
||||||
|
"builder": "@angular/build:dev-server",
|
||||||
|
"configurations": {
|
||||||
|
"production": {
|
||||||
|
"buildTarget": "webclient:build:production"
|
||||||
|
},
|
||||||
|
"development": {
|
||||||
|
"buildTarget": "webclient:build:development"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"defaultConfiguration": "development"
|
||||||
|
},
|
||||||
|
"test": {
|
||||||
|
"builder": "@angular/build:unit-test"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
8719
webclient/package-lock.json
generated
Normal file
8719
webclient/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
34
webclient/package.json
Normal file
34
webclient/package.json
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
{
|
||||||
|
"name": "webclient",
|
||||||
|
"version": "0.0.0",
|
||||||
|
"scripts": {
|
||||||
|
"ng": "ng",
|
||||||
|
"start": "ng serve",
|
||||||
|
"build": "ng build",
|
||||||
|
"watch": "ng build --watch --configuration development",
|
||||||
|
"test": "ng test"
|
||||||
|
},
|
||||||
|
"private": true,
|
||||||
|
"packageManager": "npm@11.12.1",
|
||||||
|
"dependencies": {
|
||||||
|
"@angular/common": "^21.2.0",
|
||||||
|
"@angular/compiler": "^21.2.0",
|
||||||
|
"@angular/core": "^21.2.0",
|
||||||
|
"@angular/forms": "^21.2.0",
|
||||||
|
"@angular/platform-browser": "^21.2.0",
|
||||||
|
"@angular/router": "^21.2.0",
|
||||||
|
"bootstrap": "^5.3.8",
|
||||||
|
"bootstrap-icons": "^1.13.1",
|
||||||
|
"rxjs": "~7.8.0",
|
||||||
|
"tslib": "^2.3.0"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@angular/build": "^21.2.8",
|
||||||
|
"@angular/cli": "^21.2.8",
|
||||||
|
"@angular/compiler-cli": "^21.2.0",
|
||||||
|
"jsdom": "^28.0.0",
|
||||||
|
"prettier": "^3.8.1",
|
||||||
|
"typescript": "~5.9.2",
|
||||||
|
"vitest": "^4.0.8"
|
||||||
|
}
|
||||||
|
}
|
||||||
BIN
webclient/public/favicon.ico
Normal file
BIN
webclient/public/favicon.ico
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 15 KiB |
BIN
webclient/public/logo.png
Normal file
BIN
webclient/public/logo.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 6.7 KiB |
11
webclient/src/app/app.config.ts
Normal file
11
webclient/src/app/app.config.ts
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
import { ApplicationConfig, provideBrowserGlobalErrorListeners } from '@angular/core';
|
||||||
|
import { provideRouter } from '@angular/router';
|
||||||
|
|
||||||
|
import { routes } from './app.routes';
|
||||||
|
|
||||||
|
export const appConfig: ApplicationConfig = {
|
||||||
|
providers: [
|
||||||
|
provideBrowserGlobalErrorListeners(),
|
||||||
|
provideRouter(routes)
|
||||||
|
]
|
||||||
|
};
|
||||||
0
webclient/src/app/app.css
Normal file
0
webclient/src/app/app.css
Normal file
2
webclient/src/app/app.html
Normal file
2
webclient/src/app/app.html
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
<app-navbar></app-navbar>
|
||||||
|
<router-outlet></router-outlet>
|
||||||
53
webclient/src/app/app.routes.ts
Normal file
53
webclient/src/app/app.routes.ts
Normal file
@@ -0,0 +1,53 @@
|
|||||||
|
import { Routes } from '@angular/router';
|
||||||
|
import { authOnlyCanActivate, authOnlyCanMatch } from './guards/auth-only.guard';
|
||||||
|
import { guestOnlyCanActivate, guestOnlyCanMatch } from './guards/guest-only.guard';
|
||||||
|
import { Login } from './pages/auth/login/login';
|
||||||
|
import { Profile } from './pages/profile/profile';
|
||||||
|
import { Home } from './pages/home/home';
|
||||||
|
import { Register } from './pages/auth/register/register';
|
||||||
|
import { adminOnlyCanActivate, adminOnlyCanMatch } from './guards/admin-only.guard';
|
||||||
|
import {Admin} from './pages/admin/admin/admin';
|
||||||
|
|
||||||
|
export const routes: Routes = [
|
||||||
|
{
|
||||||
|
path: '',
|
||||||
|
children: [
|
||||||
|
{
|
||||||
|
path: '',
|
||||||
|
component: Home,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: 'home',
|
||||||
|
component: Home,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: 'register',
|
||||||
|
component: Register,
|
||||||
|
canMatch: [guestOnlyCanMatch],
|
||||||
|
canActivate: [guestOnlyCanActivate],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: 'login',
|
||||||
|
component: Login,
|
||||||
|
canMatch: [guestOnlyCanMatch],
|
||||||
|
canActivate: [guestOnlyCanActivate],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: 'profile',
|
||||||
|
component: Profile,
|
||||||
|
canMatch: [authOnlyCanMatch],
|
||||||
|
canActivate: [authOnlyCanActivate],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: 'admin',
|
||||||
|
component: Admin,
|
||||||
|
canMatch: [adminOnlyCanMatch],
|
||||||
|
canActivate: [adminOnlyCanActivate],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: '**',
|
||||||
|
redirectTo: '',
|
||||||
|
},
|
||||||
|
];
|
||||||
23
webclient/src/app/app.spec.ts
Normal file
23
webclient/src/app/app.spec.ts
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
import { TestBed } from '@angular/core/testing';
|
||||||
|
import { App } from './app';
|
||||||
|
|
||||||
|
describe('App', () => {
|
||||||
|
beforeEach(async () => {
|
||||||
|
await TestBed.configureTestingModule({
|
||||||
|
imports: [App],
|
||||||
|
}).compileComponents();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should create the app', () => {
|
||||||
|
const fixture = TestBed.createComponent(App);
|
||||||
|
const app = fixture.componentInstance;
|
||||||
|
expect(app).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should render title', async () => {
|
||||||
|
const fixture = TestBed.createComponent(App);
|
||||||
|
await fixture.whenStable();
|
||||||
|
const compiled = fixture.nativeElement as HTMLElement;
|
||||||
|
expect(compiled.querySelector('h1')?.textContent).toContain('Hello, karaforge-web');
|
||||||
|
});
|
||||||
|
});
|
||||||
13
webclient/src/app/app.ts
Normal file
13
webclient/src/app/app.ts
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
import { Component, signal } from '@angular/core';
|
||||||
|
import { RouterOutlet } from '@angular/router';
|
||||||
|
import { Navbar } from './components/navbar/navbar';
|
||||||
|
|
||||||
|
@Component({
|
||||||
|
selector: 'app-root',
|
||||||
|
imports: [RouterOutlet, Navbar],
|
||||||
|
templateUrl: './app.html',
|
||||||
|
styleUrl: './app.css',
|
||||||
|
})
|
||||||
|
export class App {
|
||||||
|
protected readonly title = signal('karaforge-web');
|
||||||
|
}
|
||||||
7
webclient/src/app/components/navbar/navbar.css
Normal file
7
webclient/src/app/components/navbar/navbar.css
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
.dropdown-item i {
|
||||||
|
margin-right: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.navbar {
|
||||||
|
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
|
||||||
|
}
|
||||||
61
webclient/src/app/components/navbar/navbar.html
Normal file
61
webclient/src/app/components/navbar/navbar.html
Normal file
@@ -0,0 +1,61 @@
|
|||||||
|
<nav class="navbar navbar-expand-lg navbar-light border-bottom">
|
||||||
|
<div class="container">
|
||||||
|
<a class="navbar-brand fw-bold" [routerLink]="'/'">
|
||||||
|
<img ngSrc="/logo.png" alt="logo" height="32" width="32"/>
|
||||||
|
Portail Bureau Service
|
||||||
|
</a>
|
||||||
|
|
||||||
|
<div class="ms-auto">
|
||||||
|
@if (getUser(); as user) {
|
||||||
|
<div class="dropdown">
|
||||||
|
<button
|
||||||
|
class="btn btn-outline-secondary dropdown-toggle"
|
||||||
|
type="button"
|
||||||
|
id="userDropdown"
|
||||||
|
data-bs-toggle="dropdown"
|
||||||
|
aria-expanded="false">
|
||||||
|
<i class="bi bi-person-circle me-2"></i>
|
||||||
|
<span class="d-none d-sm-inline">{{ user.username }}</span>
|
||||||
|
</button>
|
||||||
|
<ul class="dropdown-menu dropdown-menu-end" aria-labelledby="userDropdown">
|
||||||
|
<li>
|
||||||
|
<a class="dropdown-item" [routerLink]="'/profile'">
|
||||||
|
<i class="bi bi-person"></i> Profile
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<a class="dropdown-item" [routerLink]="'/moulinettes'">
|
||||||
|
<i class="bi bi-person-lines-fill"></i> Liste des moulinettes
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
|
||||||
|
@if (authService.hasRole('Administrator')) {
|
||||||
|
<li>
|
||||||
|
<a class="dropdown-item" [routerLink]="'/admin'">
|
||||||
|
<i class="bi bi-shield-lock"></i> Administration
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<a class="dropdown-item" [routerLink]="'/admin/rules'">
|
||||||
|
<i class="bi bi-signpost-split"></i> Gestion des moulinettes
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
}
|
||||||
|
<li>
|
||||||
|
<hr class="dropdown-divider">
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<a class="dropdown-item" (click)="logout()">
|
||||||
|
<i class="bi bi-box-arrow-right"></i> Se déconnecter
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
} @else {
|
||||||
|
<div class="btn-group gap-4">
|
||||||
|
<button class="btn btn-outline-dark" [routerLink]="'/login'">Se connecter</button>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</nav>
|
||||||
35
webclient/src/app/components/navbar/navbar.ts
Normal file
35
webclient/src/app/components/navbar/navbar.ts
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
import {Component, inject} from '@angular/core';
|
||||||
|
import {Router, RouterLink} from '@angular/router';
|
||||||
|
import { AuthService } from '../../services/auth/auth-service';
|
||||||
|
import {NgOptimizedImage} from "@angular/common";
|
||||||
|
|
||||||
|
@Component({
|
||||||
|
selector: 'app-navbar',
|
||||||
|
standalone: true,
|
||||||
|
imports: [
|
||||||
|
RouterLink,
|
||||||
|
NgOptimizedImage,
|
||||||
|
],
|
||||||
|
templateUrl: './navbar.html',
|
||||||
|
styleUrl: './navbar.css'
|
||||||
|
})
|
||||||
|
export class Navbar {
|
||||||
|
|
||||||
|
protected readonly authService = inject(AuthService);
|
||||||
|
private readonly router: Router = inject(Router);
|
||||||
|
|
||||||
|
logout() {
|
||||||
|
this.authService.logout().subscribe({
|
||||||
|
next: () => {
|
||||||
|
this.router.navigate(['/login'], {queryParams: {redirect: '/profile'}}).then();
|
||||||
|
},
|
||||||
|
error: (err) => {
|
||||||
|
console.error('Logout failed:', err);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
getUser() {
|
||||||
|
return this.authService.user();
|
||||||
|
}
|
||||||
|
}
|
||||||
43
webclient/src/app/guards/admin-only.guard.ts
Normal file
43
webclient/src/app/guards/admin-only.guard.ts
Normal file
@@ -0,0 +1,43 @@
|
|||||||
|
import { inject } from '@angular/core';
|
||||||
|
import { CanActivateFn, CanMatchFn, Router, ActivatedRouteSnapshot, Route } from '@angular/router';
|
||||||
|
import { AuthService } from '../services/auth/auth-service';
|
||||||
|
import { filter, map, take } from 'rxjs';
|
||||||
|
import { toObservable } from '@angular/core/rxjs-interop';
|
||||||
|
|
||||||
|
export const adminOnlyCanActivate: CanActivateFn = (route: ActivatedRouteSnapshot) => {
|
||||||
|
const authService = inject(AuthService);
|
||||||
|
const router = inject(Router);
|
||||||
|
|
||||||
|
return toObservable(authService.isInitialized).pipe(
|
||||||
|
filter((initialized) => initialized),
|
||||||
|
take(1),
|
||||||
|
map(() => {
|
||||||
|
if (!authService.isLoggedIn()) {
|
||||||
|
return router.createUrlTree(['/login'], { queryParams: { redirect: router.url } });
|
||||||
|
}
|
||||||
|
if (!authService.hasRole('Administrator')) {
|
||||||
|
return router.parseUrl('/home');
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const adminOnlyCanMatch: CanMatchFn = (route: Route) => {
|
||||||
|
const authService = inject(AuthService);
|
||||||
|
const router = inject(Router);
|
||||||
|
|
||||||
|
return toObservable(authService.isInitialized).pipe(
|
||||||
|
filter((initialized) => initialized),
|
||||||
|
take(1),
|
||||||
|
map(() => {
|
||||||
|
if (!authService.isLoggedIn()) {
|
||||||
|
return router.createUrlTree(['/login']);
|
||||||
|
}
|
||||||
|
if (!authService.hasRole('Administrator')) {
|
||||||
|
return router.parseUrl('/home');
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
};
|
||||||
31
webclient/src/app/guards/auth-only.guard.ts
Normal file
31
webclient/src/app/guards/auth-only.guard.ts
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
import { inject } from '@angular/core';
|
||||||
|
import { CanActivateFn, CanMatchFn, Router, ActivatedRouteSnapshot, Route } from '@angular/router';
|
||||||
|
import { AuthService } from '../services/auth/auth-service';
|
||||||
|
import { filter, map, take } from 'rxjs';
|
||||||
|
import { toObservable } from '@angular/core/rxjs-interop';
|
||||||
|
|
||||||
|
export const authOnlyCanActivate: CanActivateFn = (route: ActivatedRouteSnapshot) => {
|
||||||
|
const authService = inject(AuthService);
|
||||||
|
const router = inject(Router);
|
||||||
|
|
||||||
|
return toObservable(authService.isInitialized).pipe(
|
||||||
|
filter((initialized) => initialized),
|
||||||
|
take(1),
|
||||||
|
map(() =>
|
||||||
|
authService.isLoggedIn()
|
||||||
|
? true
|
||||||
|
: router.createUrlTree(['/login'], { queryParams: { redirect: router.url } }),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const authOnlyCanMatch: CanMatchFn = (route: Route) => {
|
||||||
|
const authService = inject(AuthService);
|
||||||
|
const router = inject(Router);
|
||||||
|
|
||||||
|
return toObservable(authService.isInitialized).pipe(
|
||||||
|
filter((initialized) => initialized),
|
||||||
|
take(1),
|
||||||
|
map(() => (authService.isLoggedIn() ? true : router.createUrlTree(['/login']))),
|
||||||
|
);
|
||||||
|
};
|
||||||
27
webclient/src/app/guards/guest-only.guard.ts
Normal file
27
webclient/src/app/guards/guest-only.guard.ts
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
import { inject } from '@angular/core';
|
||||||
|
import { Router, CanActivateFn, CanMatchFn } from '@angular/router';
|
||||||
|
import { AuthService } from '../services/auth/auth-service';
|
||||||
|
import { filter, map, take } from 'rxjs';
|
||||||
|
import { toObservable } from '@angular/core/rxjs-interop';
|
||||||
|
|
||||||
|
export const guestOnlyCanActivate: CanActivateFn = () => {
|
||||||
|
const authService = inject(AuthService);
|
||||||
|
const router = inject(Router);
|
||||||
|
|
||||||
|
return toObservable(authService.isInitialized).pipe(
|
||||||
|
filter((initialized) => initialized),
|
||||||
|
take(1),
|
||||||
|
map(() => (authService.isLoggedIn() ? router.parseUrl('/home') : true)),
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const guestOnlyCanMatch: CanMatchFn = () => {
|
||||||
|
const authService = inject(AuthService);
|
||||||
|
const router = inject(Router);
|
||||||
|
|
||||||
|
return toObservable(authService.isInitialized).pipe(
|
||||||
|
filter((initialized) => initialized),
|
||||||
|
take(1),
|
||||||
|
map(() => (authService.isLoggedIn() ? router.parseUrl('/home') : true)),
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
import {HttpErrorResponse, HttpInterceptorFn} from '@angular/common/http';
|
||||||
|
import {inject} from '@angular/core';
|
||||||
|
import {catchError, switchMap, throwError} from 'rxjs';
|
||||||
|
import {AuthService} from '../../services/auth/auth-service';
|
||||||
|
|
||||||
|
let isRefreshing = false;
|
||||||
|
|
||||||
|
export const authTokenInterceptor: HttpInterceptorFn = (req, next) => {
|
||||||
|
|
||||||
|
const authService: AuthService = inject(AuthService);
|
||||||
|
const token = authService.getAccessToken();
|
||||||
|
|
||||||
|
// Ajout de l’Authorization si on a un access token en mémoire
|
||||||
|
const authReq = token
|
||||||
|
? req.clone({setHeaders: {Authorization: `Bearer ${token}`}, withCredentials: true})
|
||||||
|
: req.clone({withCredentials: true});
|
||||||
|
|
||||||
|
return next(authReq).pipe(
|
||||||
|
catchError((error: any) => {
|
||||||
|
const is401 = error instanceof HttpErrorResponse && error.status === 401;
|
||||||
|
|
||||||
|
// si 401 et pas déjà en refresh, tente un refresh puis rejoue la requête une fois
|
||||||
|
if (is401 && !isRefreshing) {
|
||||||
|
isRefreshing = true;
|
||||||
|
return inject(AuthService).refresh().pipe(
|
||||||
|
switchMap(newToken => {
|
||||||
|
isRefreshing = false;
|
||||||
|
if (!newToken) return throwError(() => error);
|
||||||
|
const retryReq = req.clone({
|
||||||
|
setHeaders: {Authorization: `Bearer ${newToken}`},
|
||||||
|
withCredentials: true
|
||||||
|
});
|
||||||
|
return next(retryReq);
|
||||||
|
}),
|
||||||
|
catchError(err => {
|
||||||
|
isRefreshing = false;
|
||||||
|
return throwError(() => err);
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return throwError(() => error);
|
||||||
|
})
|
||||||
|
);
|
||||||
|
};
|
||||||
4
webclient/src/app/interfaces/auth/credentials.ts
Normal file
4
webclient/src/app/interfaces/auth/credentials.ts
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
export interface Credentials {
|
||||||
|
username: string;
|
||||||
|
password: string;
|
||||||
|
}
|
||||||
8
webclient/src/app/interfaces/user/user.ts
Normal file
8
webclient/src/app/interfaces/user/user.ts
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
export interface User {
|
||||||
|
id: number;
|
||||||
|
firstName: string;
|
||||||
|
lastName: string;
|
||||||
|
username: string;
|
||||||
|
email: string;
|
||||||
|
role: string;
|
||||||
|
}
|
||||||
0
webclient/src/app/pages/admin/admin/admin.css
Normal file
0
webclient/src/app/pages/admin/admin/admin.css
Normal file
1
webclient/src/app/pages/admin/admin/admin.html
Normal file
1
webclient/src/app/pages/admin/admin/admin.html
Normal file
@@ -0,0 +1 @@
|
|||||||
|
<p>admin works!</p>
|
||||||
9
webclient/src/app/pages/admin/admin/admin.ts
Normal file
9
webclient/src/app/pages/admin/admin/admin.ts
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
import { Component } from '@angular/core';
|
||||||
|
|
||||||
|
@Component({
|
||||||
|
selector: 'app-admin',
|
||||||
|
imports: [],
|
||||||
|
templateUrl: './admin.html',
|
||||||
|
styleUrl: './admin.css',
|
||||||
|
})
|
||||||
|
export class Admin {}
|
||||||
10
webclient/src/app/pages/auth/login/login.css
Normal file
10
webclient/src/app/pages/auth/login/login.css
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
.min-vh-100 {
|
||||||
|
min-height: 100vh;
|
||||||
|
}
|
||||||
|
|
||||||
|
.auth-card {
|
||||||
|
width: 100%;
|
||||||
|
max-width: 400px;
|
||||||
|
border-radius: 10px;
|
||||||
|
border: none;
|
||||||
|
}
|
||||||
49
webclient/src/app/pages/auth/login/login.html
Normal file
49
webclient/src/app/pages/auth/login/login.html
Normal file
@@ -0,0 +1,49 @@
|
|||||||
|
<div class="container d-flex justify-content-center align-items-center min-vh-100">
|
||||||
|
<div class="card auth-card shadow-sm">
|
||||||
|
<div class="card-body">
|
||||||
|
<h3 class="card-title text-center mb-4">Se connecter</h3>
|
||||||
|
|
||||||
|
<form (submit)="login()" [formGroup]="loginFormGroup">
|
||||||
|
<div class="mb-3">
|
||||||
|
<label for="username" class="form-label">Nom d'utilisateur</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
class="form-control"
|
||||||
|
id="username"
|
||||||
|
formControlName="username"
|
||||||
|
autocomplete="username">
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mb-3">
|
||||||
|
<label for="password" class="form-label">Mot de passe</label>
|
||||||
|
<input
|
||||||
|
type="password"
|
||||||
|
class="form-control"
|
||||||
|
id="password"
|
||||||
|
formControlName="password"
|
||||||
|
autocomplete="current-password">
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@if (invalidCredentials) {
|
||||||
|
<div class="alert alert-danger" role="alert">
|
||||||
|
Nom d'utilisateur ou mot de passe invalide
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
class="btn btn-primary w-100"
|
||||||
|
[disabled]="loginFormGroup.invalid">
|
||||||
|
Se connecter
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<hr class="my-4">
|
||||||
|
|
||||||
|
<p class="text-center text-muted mb-0">
|
||||||
|
Vous n'avez pas de compte ?
|
||||||
|
<a [routerLink]="'/register'">S'inscrire</a>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
52
webclient/src/app/pages/auth/login/login.ts
Normal file
52
webclient/src/app/pages/auth/login/login.ts
Normal file
@@ -0,0 +1,52 @@
|
|||||||
|
import { Component, inject, OnDestroy } from '@angular/core';
|
||||||
|
import { FormBuilder, ReactiveFormsModule, Validators } from '@angular/forms';
|
||||||
|
import { AuthService } from '../../../services/auth/auth-service';
|
||||||
|
import { Router, RouterLink } from '@angular/router';
|
||||||
|
import { Subscription } from 'rxjs';
|
||||||
|
import { Credentials } from '../../../interfaces/auth/credentials';
|
||||||
|
import { User } from '../../../interfaces/user/user';
|
||||||
|
|
||||||
|
@Component({
|
||||||
|
selector: 'app-login',
|
||||||
|
standalone: true,
|
||||||
|
imports: [ReactiveFormsModule, RouterLink],
|
||||||
|
templateUrl: './login.html',
|
||||||
|
styleUrl: './login.css',
|
||||||
|
})
|
||||||
|
export class Login implements OnDestroy {
|
||||||
|
private readonly formBuilder: FormBuilder = inject(FormBuilder);
|
||||||
|
private readonly authService: AuthService = inject(AuthService);
|
||||||
|
private readonly router: Router = inject(Router);
|
||||||
|
|
||||||
|
private loginSubscription: Subscription | null = null;
|
||||||
|
|
||||||
|
loginFormGroup = this.formBuilder.group({
|
||||||
|
username: ['', [Validators.required]],
|
||||||
|
password: ['', [Validators.required]],
|
||||||
|
});
|
||||||
|
|
||||||
|
invalidCredentials = false;
|
||||||
|
|
||||||
|
ngOnDestroy() {
|
||||||
|
this.loginSubscription?.unsubscribe();
|
||||||
|
}
|
||||||
|
|
||||||
|
login() {
|
||||||
|
this.loginSubscription = this.authService
|
||||||
|
.login(this.loginFormGroup.value as Credentials)
|
||||||
|
.subscribe({
|
||||||
|
next: (result: User | null | undefined) => {
|
||||||
|
console.log(result);
|
||||||
|
this.navigateHome();
|
||||||
|
},
|
||||||
|
error: (error) => {
|
||||||
|
console.log(error);
|
||||||
|
this.invalidCredentials = true;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
navigateHome() {
|
||||||
|
this.router.navigate(['/home']).then();
|
||||||
|
}
|
||||||
|
}
|
||||||
10
webclient/src/app/pages/auth/register/register.css
Normal file
10
webclient/src/app/pages/auth/register/register.css
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
.min-vh-100 {
|
||||||
|
min-height: 100vh;
|
||||||
|
}
|
||||||
|
|
||||||
|
.auth-card {
|
||||||
|
width: 100%;
|
||||||
|
max-width: 520px;
|
||||||
|
border-radius: 10px;
|
||||||
|
border: none;
|
||||||
|
}
|
||||||
134
webclient/src/app/pages/auth/register/register.html
Normal file
134
webclient/src/app/pages/auth/register/register.html
Normal file
@@ -0,0 +1,134 @@
|
|||||||
|
<div class="container d-flex justify-content-center align-items-center min-vh-100 py-4">
|
||||||
|
<div class="card auth-card shadow-sm">
|
||||||
|
<div class="card-body">
|
||||||
|
<h3 class="card-title text-center mb-2">Inscription</h3>
|
||||||
|
<p class="text-center text-muted mb-4">Créer un nouveau compte</p>
|
||||||
|
|
||||||
|
<form [formGroup]="registerForm" (ngSubmit)="onRegister()">
|
||||||
|
<div class="mb-3">
|
||||||
|
<label for="firstName" class="form-label">Prénom</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
class="form-control"
|
||||||
|
[class.is-invalid]="isFieldInvalid('firstName')"
|
||||||
|
id="firstName"
|
||||||
|
formControlName="firstName"
|
||||||
|
autocomplete="given-name">
|
||||||
|
@if (isFieldInvalid('firstName')) {
|
||||||
|
<div class="invalid-feedback">{{ getFieldError('firstName') }}</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mb-3">
|
||||||
|
<label for="lastName" class="form-label">Nom</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
class="form-control"
|
||||||
|
[class.is-invalid]="isFieldInvalid('lastName')"
|
||||||
|
id="lastName"
|
||||||
|
formControlName="lastName"
|
||||||
|
autocomplete="family-name">
|
||||||
|
@if (isFieldInvalid('lastName')) {
|
||||||
|
<div class="invalid-feedback">{{ getFieldError('lastName') }}</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mb-3">
|
||||||
|
<label for="username" class="form-label">Nom d'utilisateur</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
class="form-control"
|
||||||
|
[class.is-invalid]="isFieldInvalid('username')"
|
||||||
|
id="username"
|
||||||
|
formControlName="username"
|
||||||
|
autocomplete="username">
|
||||||
|
@if (isFieldInvalid('username')) {
|
||||||
|
<div class="invalid-feedback">{{ getFieldError('username') }}</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mb-3">
|
||||||
|
<label for="email" class="form-label">Email</label>
|
||||||
|
<input
|
||||||
|
type="email"
|
||||||
|
class="form-control"
|
||||||
|
[class.is-invalid]="isFieldInvalid('email')"
|
||||||
|
id="email"
|
||||||
|
formControlName="email"
|
||||||
|
autocomplete="email">
|
||||||
|
@if (isFieldInvalid('email')) {
|
||||||
|
<div class="invalid-feedback">{{ getFieldError('email') }}</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mb-3">
|
||||||
|
<label for="password" class="form-label">Mot de passe</label>
|
||||||
|
<input
|
||||||
|
type="password"
|
||||||
|
class="form-control"
|
||||||
|
[class.is-invalid]="isFieldInvalid('password')"
|
||||||
|
id="password"
|
||||||
|
formControlName="password"
|
||||||
|
autocomplete="new-password">
|
||||||
|
@if (isFieldInvalid('password')) {
|
||||||
|
<div class="invalid-feedback">{{ getFieldError('password') }}</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mb-3">
|
||||||
|
<label for="confirmPassword" class="form-label">Confirmer le mot de passe</label>
|
||||||
|
<input
|
||||||
|
type="password"
|
||||||
|
class="form-control"
|
||||||
|
[class.is-invalid]="isFieldInvalid('confirmPassword')"
|
||||||
|
id="confirmPassword"
|
||||||
|
formControlName="confirmPassword"
|
||||||
|
autocomplete="new-password">
|
||||||
|
@if (isFieldInvalid('confirmPassword')) {
|
||||||
|
<div class="invalid-feedback">{{ getFieldError('confirmPassword') }}</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@if (registerForm.hasError('passwordMismatch') && (registerForm.dirty || registerForm.touched || isSubmitted)) {
|
||||||
|
<div class="alert alert-danger" role="alert">
|
||||||
|
Les mots de passe ne correspondent pas
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
<div class="form-check mb-3">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
class="form-check-input"
|
||||||
|
[class.is-invalid]="isFieldInvalid('termsAndConditions')"
|
||||||
|
id="iAgree"
|
||||||
|
formControlName="termsAndConditions">
|
||||||
|
<label class="form-check-label" for="iAgree">
|
||||||
|
J'accepte les <a href="#" target="_blank" rel="noopener">conditions générales d'utilisation</a>
|
||||||
|
</label>
|
||||||
|
@if (isFieldInvalid('termsAndConditions')) {
|
||||||
|
<div class="invalid-feedback d-block">{{ getFieldError('termsAndConditions') }}</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
class="btn btn-primary w-100"
|
||||||
|
[disabled]="isLoading || registerForm.invalid">
|
||||||
|
@if (isLoading) {
|
||||||
|
<span class="spinner-border spinner-border-sm me-2"></span>
|
||||||
|
Inscription…
|
||||||
|
} @else {
|
||||||
|
S'inscrire
|
||||||
|
}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<hr class="my-4">
|
||||||
|
|
||||||
|
<p class="text-center text-muted mb-0">
|
||||||
|
Vous avez déjà un compte ?
|
||||||
|
<a [routerLink]="'/login'">Se connecter</a>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
154
webclient/src/app/pages/auth/register/register.ts
Normal file
154
webclient/src/app/pages/auth/register/register.ts
Normal file
@@ -0,0 +1,154 @@
|
|||||||
|
import { Component, inject, OnDestroy } from '@angular/core';
|
||||||
|
import {
|
||||||
|
AbstractControl,
|
||||||
|
FormBuilder,
|
||||||
|
FormGroup,
|
||||||
|
ReactiveFormsModule,
|
||||||
|
ValidationErrors,
|
||||||
|
ValidatorFn,
|
||||||
|
Validators,
|
||||||
|
} from '@angular/forms';
|
||||||
|
import { Router, RouterLink } from '@angular/router';
|
||||||
|
import { AuthService } from '../../../services/auth/auth-service';
|
||||||
|
import { Subscription } from 'rxjs';
|
||||||
|
|
||||||
|
@Component({
|
||||||
|
selector: 'app-register',
|
||||||
|
standalone: true,
|
||||||
|
imports: [ReactiveFormsModule, RouterLink],
|
||||||
|
templateUrl: './register.html',
|
||||||
|
styleUrl: './register.css',
|
||||||
|
})
|
||||||
|
export class Register implements OnDestroy {
|
||||||
|
registerForm: FormGroup;
|
||||||
|
isSubmitted = false;
|
||||||
|
isLoading = false;
|
||||||
|
|
||||||
|
private readonly passwordPattern: RegExp = new RegExp(
|
||||||
|
'^(?=.*[a-z])(?=.*[A-Z])(?=.*\\d)(?=.*[\\p{P}\\p{S}]).{8,}$',
|
||||||
|
'u',
|
||||||
|
);
|
||||||
|
|
||||||
|
private readonly router: Router = inject(Router);
|
||||||
|
private readonly authService: AuthService = inject(AuthService);
|
||||||
|
|
||||||
|
private registerSubscription: Subscription | null = null;
|
||||||
|
|
||||||
|
constructor(private readonly formBuilder: FormBuilder) {
|
||||||
|
this.registerForm = this.formBuilder.group(
|
||||||
|
{
|
||||||
|
firstName: [
|
||||||
|
'',
|
||||||
|
[
|
||||||
|
Validators.required,
|
||||||
|
Validators.minLength(3),
|
||||||
|
Validators.maxLength(50),
|
||||||
|
Validators.pattern('^[a-zA-Z]+$'),
|
||||||
|
],
|
||||||
|
],
|
||||||
|
lastName: [
|
||||||
|
'',
|
||||||
|
[
|
||||||
|
Validators.required,
|
||||||
|
Validators.minLength(3),
|
||||||
|
Validators.maxLength(50),
|
||||||
|
Validators.pattern('^[a-zA-Z]+$'),
|
||||||
|
],
|
||||||
|
],
|
||||||
|
username: [
|
||||||
|
'',
|
||||||
|
[
|
||||||
|
Validators.required,
|
||||||
|
Validators.minLength(3),
|
||||||
|
Validators.maxLength(20),
|
||||||
|
Validators.pattern('^[a-zA-Z0-9_]+$'),
|
||||||
|
],
|
||||||
|
],
|
||||||
|
email: [
|
||||||
|
'',
|
||||||
|
[
|
||||||
|
Validators.required,
|
||||||
|
Validators.minLength(3),
|
||||||
|
Validators.maxLength(120),
|
||||||
|
Validators.email,
|
||||||
|
],
|
||||||
|
],
|
||||||
|
password: [
|
||||||
|
'',
|
||||||
|
[
|
||||||
|
Validators.required,
|
||||||
|
Validators.minLength(8),
|
||||||
|
Validators.maxLength(50),
|
||||||
|
Validators.pattern(this.passwordPattern),
|
||||||
|
],
|
||||||
|
],
|
||||||
|
confirmPassword: [
|
||||||
|
'',
|
||||||
|
[
|
||||||
|
Validators.required,
|
||||||
|
Validators.minLength(8),
|
||||||
|
Validators.maxLength(50),
|
||||||
|
Validators.pattern(this.passwordPattern),
|
||||||
|
],
|
||||||
|
],
|
||||||
|
termsAndConditions: [false, Validators.requiredTrue],
|
||||||
|
},
|
||||||
|
{ validators: this.passwordMatchValidator },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
ngOnDestroy(): void {
|
||||||
|
this.registerSubscription?.unsubscribe();
|
||||||
|
}
|
||||||
|
|
||||||
|
private readonly passwordMatchValidator: ValidatorFn = (
|
||||||
|
group: AbstractControl,
|
||||||
|
): ValidationErrors | null => {
|
||||||
|
const password = group.get('password')?.value;
|
||||||
|
const confirmPassword = group.get('confirmPassword')?.value;
|
||||||
|
return password === confirmPassword ? null : { passwordMismatch: true };
|
||||||
|
};
|
||||||
|
|
||||||
|
onRegister() {
|
||||||
|
this.isSubmitted = true;
|
||||||
|
|
||||||
|
if (this.registerForm.valid) {
|
||||||
|
this.isLoading = true;
|
||||||
|
const registrationData = this.registerForm.value;
|
||||||
|
delete registrationData.confirmPassword;
|
||||||
|
this.registerSubscription = this.authService.register(registrationData).subscribe({
|
||||||
|
next: (response) => {
|
||||||
|
this.isLoading = false;
|
||||||
|
this.registerForm.reset();
|
||||||
|
this.isSubmitted = false;
|
||||||
|
alert('Registration successful!');
|
||||||
|
this.router.navigate(['/']).then();
|
||||||
|
},
|
||||||
|
error: (error) => {
|
||||||
|
console.error('Erreur HTTP:', error);
|
||||||
|
this.isLoading = false;
|
||||||
|
alert('An error occurred during registration. Please try again.');
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
isFieldInvalid(fieldName: string): boolean {
|
||||||
|
const field = this.registerForm.get(fieldName);
|
||||||
|
return Boolean(field && field.invalid && (field.dirty || field.touched || this.isSubmitted));
|
||||||
|
}
|
||||||
|
|
||||||
|
getFieldError(fieldName: string): string {
|
||||||
|
const field = this.registerForm.get(fieldName);
|
||||||
|
|
||||||
|
if (field && field.errors) {
|
||||||
|
if (field.errors['required']) return `Ce champ est obligatoire`;
|
||||||
|
if (field.errors['email']) return `Format d'email invalide`;
|
||||||
|
if (field.errors['minlength'])
|
||||||
|
return `Minimum ${field.errors['minlength'].requiredLength} caractères`;
|
||||||
|
if (field.errors['maxlength'])
|
||||||
|
return `Maximum ${field.errors['maxlength'].requiredLength} caractères`;
|
||||||
|
}
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
}
|
||||||
0
webclient/src/app/pages/home/home.css
Normal file
0
webclient/src/app/pages/home/home.css
Normal file
27
webclient/src/app/pages/home/home.html
Normal file
27
webclient/src/app/pages/home/home.html
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
<!-- Home page header -->
|
||||||
|
<header class="bg-dark py-5">
|
||||||
|
<div class="container px-5">
|
||||||
|
<div class="row gx-5 justify-content-center">
|
||||||
|
<div class="col-lg-6">
|
||||||
|
<div class="text-center my-5">
|
||||||
|
@if (getUser(); as user) {
|
||||||
|
<h1 class="display-5 fw-bolder text-white mb-2">Bienvenue, {{ user.firstName }}!</h1>
|
||||||
|
<p class="lead text-white-50 mb-4">Choisissez une action à effectuer</p>
|
||||||
|
<div class="d-grid gap-3 d-sm-flex justify-content-sm-center">
|
||||||
|
<a href="/" class="btn btn-primary btn-lg px-4 me-sm-3">Voir la liste des moulinettes</a>
|
||||||
|
<a class="btn btn-outline-light btn-lg px-4" href="/" target="_blank">Autres outils</a>
|
||||||
|
</div>
|
||||||
|
} @else {
|
||||||
|
<h1 class="display-5 fw-bolder text-white mb-2">Bienvenue !</h1>
|
||||||
|
<p class="lead text-white-50 mb-4">Choisissez une action à effectuer</p>
|
||||||
|
<div class="d-grid gap-3 d-sm-flex justify-content-sm-center">
|
||||||
|
<a [routerLink]="'/login'" class="btn btn-primary btn-lg px-4 me-sm-3">Se connecter</a>
|
||||||
|
<a [routerLink]="'/register'" class="btn btn-outline-light btn-lg px-4">S'inscrire</a>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
22
webclient/src/app/pages/home/home.ts
Normal file
22
webclient/src/app/pages/home/home.ts
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
import {Component, inject} from '@angular/core';
|
||||||
|
import {Router, RouterLink} from '@angular/router';
|
||||||
|
import { AuthService } from '../../services/auth/auth-service';
|
||||||
|
|
||||||
|
@Component({
|
||||||
|
selector: 'app-home',
|
||||||
|
standalone: true,
|
||||||
|
imports: [
|
||||||
|
RouterLink
|
||||||
|
],
|
||||||
|
templateUrl: './home.html',
|
||||||
|
styleUrl: './home.css'
|
||||||
|
})
|
||||||
|
export class Home {
|
||||||
|
|
||||||
|
protected readonly authService: AuthService = inject(AuthService);
|
||||||
|
protected readonly router: Router = inject(Router);
|
||||||
|
|
||||||
|
getUser() {
|
||||||
|
return this.authService.user();
|
||||||
|
}
|
||||||
|
}
|
||||||
31
webclient/src/app/pages/profile/profile.css
Normal file
31
webclient/src/app/pages/profile/profile.css
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
.min-vh-50 {
|
||||||
|
min-height: 50vh;
|
||||||
|
}
|
||||||
|
|
||||||
|
.profile-card {
|
||||||
|
max-width: 380px;
|
||||||
|
width: 100%;
|
||||||
|
border-radius: 18px;
|
||||||
|
border: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.profile-avatar {
|
||||||
|
width: 64px;
|
||||||
|
height: 64px;
|
||||||
|
margin: 0 auto;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: rgba(0, 0, 0, .7);
|
||||||
|
color: white;
|
||||||
|
font-size: 48px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-title {
|
||||||
|
font-size: 1.3rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-subtitle {
|
||||||
|
font-size: 1rem;
|
||||||
|
}
|
||||||
33
webclient/src/app/pages/profile/profile.html
Normal file
33
webclient/src/app/pages/profile/profile.html
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
@if (getUser(); as user) {
|
||||||
|
<div class="container d-flex justify-content-center align-items-center min-vh-50">
|
||||||
|
<div class="card profile-card shadow-sm">
|
||||||
|
<div class="card-body text-center">
|
||||||
|
<div class="profile-avatar mb-3">
|
||||||
|
<i class="bi bi-person-circle"></i>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h5 class="card-title fw-bold mb-1">{{ user.firstName }} {{ user.lastName }}</h5>
|
||||||
|
|
||||||
|
@if (user.role == "Administrator") {
|
||||||
|
<p class="card-subtitle text-muted mb-3">{{ user.username }} ({{ user.role }})</p>
|
||||||
|
} @else {
|
||||||
|
<p class="card-subtitle text-muted mb-3">{{ user.username }}</p>
|
||||||
|
}
|
||||||
|
|
||||||
|
<div class="d-flex align-items-center justify-content-center gap-2 text-secondary">
|
||||||
|
<i class="bi bi-envelope"></i>
|
||||||
|
<span>{{ user.email }}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="d-flex justify-content-center gap-2 mt-4">
|
||||||
|
<button class="btn btn-primary">
|
||||||
|
<i class="bi bi-pencil"></i> Modifier le profil
|
||||||
|
</button>
|
||||||
|
<button class="btn btn-danger" (click)="logout()">
|
||||||
|
<i class="bi bi-box-arrow-right"></i> Se déconnecter
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
32
webclient/src/app/pages/profile/profile.ts
Normal file
32
webclient/src/app/pages/profile/profile.ts
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
import {Component, inject} from '@angular/core';
|
||||||
|
import {Router} from '@angular/router';
|
||||||
|
import { AuthService } from '../../services/auth/auth-service';
|
||||||
|
import { User } from '../../interfaces/user/user';
|
||||||
|
|
||||||
|
@Component({
|
||||||
|
selector: 'app-profile',
|
||||||
|
standalone: true,
|
||||||
|
imports: [],
|
||||||
|
templateUrl: './profile.html',
|
||||||
|
styleUrl: './profile.css'
|
||||||
|
})
|
||||||
|
export class Profile {
|
||||||
|
|
||||||
|
private readonly authService: AuthService = inject(AuthService);
|
||||||
|
private readonly router: Router = inject(Router);
|
||||||
|
|
||||||
|
logout() {
|
||||||
|
this.authService.logout().subscribe({
|
||||||
|
next: () => {
|
||||||
|
this.router.navigate(['/login'], {queryParams: {redirect: '/profile'}}).then();
|
||||||
|
},
|
||||||
|
error: (err) => {
|
||||||
|
console.error('Logout failed:', err);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
getUser(): User | null {
|
||||||
|
return this.authService.user();
|
||||||
|
}
|
||||||
|
}
|
||||||
96
webclient/src/app/services/auth/auth-service.ts
Normal file
96
webclient/src/app/services/auth/auth-service.ts
Normal file
@@ -0,0 +1,96 @@
|
|||||||
|
import { inject, Injectable, signal } from '@angular/core';
|
||||||
|
import { environment } from '../../../environments/environment';
|
||||||
|
import { HttpClient } from '@angular/common/http';
|
||||||
|
import { catchError, map, of, switchMap, tap } from 'rxjs';
|
||||||
|
import { User } from '../../interfaces/user/user';
|
||||||
|
import { Credentials } from '../../interfaces/auth/credentials';
|
||||||
|
|
||||||
|
@Injectable({
|
||||||
|
providedIn: 'root',
|
||||||
|
})
|
||||||
|
export class AuthService {
|
||||||
|
private readonly http: HttpClient = inject(HttpClient);
|
||||||
|
private readonly BASE_URL = `${environment.apiUrl}/auth`;
|
||||||
|
|
||||||
|
readonly user = signal<User | null>(null);
|
||||||
|
private readonly accessToken = signal<string | null>(null);
|
||||||
|
readonly isInitialized = signal(false);
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
this.refresh()
|
||||||
|
.pipe(switchMap(() => this.me()))
|
||||||
|
.subscribe({
|
||||||
|
complete: () => this.isInitialized.set(true),
|
||||||
|
error: () => this.isInitialized.set(true),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
register(user: User) {
|
||||||
|
return this.http.post(this.BASE_URL + '/register', user, { withCredentials: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
login(credentials: Credentials) {
|
||||||
|
return this.http
|
||||||
|
.post<any>(this.BASE_URL + '/login', credentials, { withCredentials: true })
|
||||||
|
.pipe(switchMap(() => this.me()));
|
||||||
|
}
|
||||||
|
|
||||||
|
logout() {
|
||||||
|
return this.http.get(this.BASE_URL + '/logout', { withCredentials: true }).pipe(
|
||||||
|
tap(() => {
|
||||||
|
this.user.set(null);
|
||||||
|
this.accessToken.set(null);
|
||||||
|
}),
|
||||||
|
map(() => null),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
me() {
|
||||||
|
return this.http
|
||||||
|
.get<User>(this.BASE_URL + '/me', {
|
||||||
|
withCredentials: true,
|
||||||
|
})
|
||||||
|
.pipe(
|
||||||
|
tap((u) => this.user.set(u)),
|
||||||
|
catchError(() => {
|
||||||
|
this.user.set(null);
|
||||||
|
this.accessToken.set(null);
|
||||||
|
return of(null);
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
refresh() {
|
||||||
|
return this.http
|
||||||
|
.post<any>(
|
||||||
|
this.BASE_URL + '/refresh',
|
||||||
|
{},
|
||||||
|
{
|
||||||
|
withCredentials: true,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.pipe(
|
||||||
|
tap((res) => {
|
||||||
|
if (res?.accessToken) {
|
||||||
|
this.accessToken.set(res.accessToken);
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
catchError(() => of(null)),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
getAccessToken(): string | null {
|
||||||
|
return this.accessToken();
|
||||||
|
}
|
||||||
|
|
||||||
|
isLoggedIn(): boolean {
|
||||||
|
return this.user() !== null;
|
||||||
|
}
|
||||||
|
|
||||||
|
hasRole(role: string): boolean {
|
||||||
|
const user = this.user();
|
||||||
|
if (!user) return false;
|
||||||
|
const roles = Array.isArray((user as any).roles) ? (user as any).roles : [(user as any).role];
|
||||||
|
return roles?.includes(role) ?? false;
|
||||||
|
}
|
||||||
|
}
|
||||||
4
webclient/src/environments/environment.prod.ts
Normal file
4
webclient/src/environments/environment.prod.ts
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
export const environment = {
|
||||||
|
production: true,
|
||||||
|
apiUrl: 'https://portail.bs.local/api'
|
||||||
|
};
|
||||||
4
webclient/src/environments/environment.ts
Normal file
4
webclient/src/environments/environment.ts
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
export const environment = {
|
||||||
|
production: false,
|
||||||
|
apiUrl: 'http://localhost:8080/api'
|
||||||
|
};
|
||||||
27
webclient/src/index.html
Normal file
27
webclient/src/index.html
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8" />
|
||||||
|
<title>Portail BS</title>
|
||||||
|
<base href="/" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||||
|
<link rel="icon" type="image/x-icon" href="favicon.ico" />
|
||||||
|
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||||
|
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
||||||
|
<link
|
||||||
|
href="https://fonts.googleapis.com/css2?family=Roboto:wght@300;400;500&display=swap"
|
||||||
|
rel="stylesheet"
|
||||||
|
/>
|
||||||
|
<link href="https://fonts.googleapis.com/icon?family=Material+Icons" rel="stylesheet" />
|
||||||
|
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||||
|
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
||||||
|
<link
|
||||||
|
href="https://fonts.googleapis.com/css2?family=Roboto:wght@300;400;500&display=swap"
|
||||||
|
rel="stylesheet"
|
||||||
|
/>
|
||||||
|
<link href="https://fonts.googleapis.com/icon?family=Material+Icons" rel="stylesheet" />
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<app-root></app-root>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
6
webclient/src/main.ts
Normal file
6
webclient/src/main.ts
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
import { bootstrapApplication } from '@angular/platform-browser';
|
||||||
|
import { appConfig } from './app/app.config';
|
||||||
|
import { App } from './app/app';
|
||||||
|
|
||||||
|
bootstrapApplication(App, appConfig)
|
||||||
|
.catch((err) => console.error(err));
|
||||||
38
webclient/src/material-theme.scss
Normal file
38
webclient/src/material-theme.scss
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
// Include theming for Angular Material with `mat.theme()`.
|
||||||
|
// This Sass mixin will define CSS variables that are used for styling Angular Material
|
||||||
|
// components according to the Material 3 design spec.
|
||||||
|
// Learn more about theming and how to use it for your application's
|
||||||
|
// custom components at https://material.angular.dev/guide/theming
|
||||||
|
@use '@angular/material' as mat;
|
||||||
|
|
||||||
|
html {
|
||||||
|
height: 100%;
|
||||||
|
@include mat.theme(
|
||||||
|
(
|
||||||
|
color: (
|
||||||
|
primary: mat.$azure-palette,
|
||||||
|
tertiary: mat.$blue-palette,
|
||||||
|
),
|
||||||
|
typography: Roboto,
|
||||||
|
density: 0,
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
// Default the application to a light color theme. This can be changed to
|
||||||
|
// `dark` to enable the dark color theme, or to `light dark` to defer to the
|
||||||
|
// user's system settings.
|
||||||
|
color-scheme: light;
|
||||||
|
|
||||||
|
// Set a default background, font and text colors for the application using
|
||||||
|
// Angular Material's system-level CSS variables. Learn more about these
|
||||||
|
// variables at https://material.angular.dev/guide/system-variables
|
||||||
|
background-color: var(--mat-sys-surface);
|
||||||
|
color: var(--mat-sys-on-surface);
|
||||||
|
font: var(--mat-sys-body-medium);
|
||||||
|
|
||||||
|
// Reset the user agent margin.
|
||||||
|
margin: 0;
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
1
webclient/src/styles.css
Normal file
1
webclient/src/styles.css
Normal file
@@ -0,0 +1 @@
|
|||||||
|
/* You can add global styles to this file, and also import other style files */
|
||||||
15
webclient/tsconfig.app.json
Normal file
15
webclient/tsconfig.app.json
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
/* To learn more about Typescript configuration file: https://www.typescriptlang.org/docs/handbook/tsconfig-json.html. */
|
||||||
|
/* To learn more about Angular compiler options: https://angular.dev/reference/configs/angular-compiler-options. */
|
||||||
|
{
|
||||||
|
"extends": "./tsconfig.json",
|
||||||
|
"compilerOptions": {
|
||||||
|
"outDir": "./out-tsc/app",
|
||||||
|
"types": []
|
||||||
|
},
|
||||||
|
"include": [
|
||||||
|
"src/**/*.ts"
|
||||||
|
],
|
||||||
|
"exclude": [
|
||||||
|
"src/**/*.spec.ts"
|
||||||
|
]
|
||||||
|
}
|
||||||
33
webclient/tsconfig.json
Normal file
33
webclient/tsconfig.json
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
/* To learn more about Typescript configuration file: https://www.typescriptlang.org/docs/handbook/tsconfig-json.html. */
|
||||||
|
/* To learn more about Angular compiler options: https://angular.dev/reference/configs/angular-compiler-options. */
|
||||||
|
{
|
||||||
|
"compileOnSave": false,
|
||||||
|
"compilerOptions": {
|
||||||
|
"strict": true,
|
||||||
|
"noImplicitOverride": true,
|
||||||
|
"noPropertyAccessFromIndexSignature": true,
|
||||||
|
"noImplicitReturns": true,
|
||||||
|
"noFallthroughCasesInSwitch": true,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"isolatedModules": true,
|
||||||
|
"experimentalDecorators": true,
|
||||||
|
"importHelpers": true,
|
||||||
|
"target": "ES2022",
|
||||||
|
"module": "preserve"
|
||||||
|
},
|
||||||
|
"angularCompilerOptions": {
|
||||||
|
"enableI18nLegacyMessageIdFormat": false,
|
||||||
|
"strictInjectionParameters": true,
|
||||||
|
"strictInputAccessModifiers": true,
|
||||||
|
"strictTemplates": true
|
||||||
|
},
|
||||||
|
"files": [],
|
||||||
|
"references": [
|
||||||
|
{
|
||||||
|
"path": "./tsconfig.app.json"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "./tsconfig.spec.json"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
15
webclient/tsconfig.spec.json
Normal file
15
webclient/tsconfig.spec.json
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
/* To learn more about Typescript configuration file: https://www.typescriptlang.org/docs/handbook/tsconfig-json.html. */
|
||||||
|
/* To learn more about Angular compiler options: https://angular.dev/reference/configs/angular-compiler-options. */
|
||||||
|
{
|
||||||
|
"extends": "./tsconfig.json",
|
||||||
|
"compilerOptions": {
|
||||||
|
"outDir": "./out-tsc/spec",
|
||||||
|
"types": [
|
||||||
|
"vitest/globals"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"include": [
|
||||||
|
"src/**/*.d.ts",
|
||||||
|
"src/**/*.spec.ts"
|
||||||
|
]
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user