Compare commits

6 Commits

68 changed files with 11115 additions and 0 deletions

114
server/app/pom.xml Normal file
View File

@@ -0,0 +1,114 @@
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<parent>
<groupId>fr.bureauservice</groupId>
<artifactId>portail</artifactId>
<version>0.0.1</version>
</parent>
<artifactId>app</artifactId>
<packaging>jar</packaging>
<name>app</name>
<dependencies>
<!-- Lombok -->
<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
<optional>true</optional>
</dependency>
<!-- Web -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<!-- Bean Validation -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-validation</artifactId>
</dependency>
<!-- Security -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-security</artifactId>
</dependency>
<!-- JPA -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-jpa</artifactId>
</dependency>
<!-- JWT -->
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt-api</artifactId>
<version>0.11.5</version>
</dependency>
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt-jackson</artifactId>
<version>0.11.5</version>
<scope>runtime</scope>
</dependency>
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt-impl</artifactId>
<version>0.11.5</version>
<scope>runtime</scope>
</dependency>
<!-- MySQL 8.4 driver -->
<dependency>
<groupId>com.mysql</groupId>
<artifactId>mysql-connector-j</artifactId>
</dependency>
<!-- OpenAPI / Swagger -->
<dependency>
<groupId>org.springdoc</groupId>
<artifactId>springdoc-openapi-starter-webmvc-ui</artifactId>
<version>3.0.3</version>
</dependency>
<!-- Spring Dotenv -->
<dependency>
<groupId>me.paulschwarz</groupId>
<artifactId>spring-dotenv</artifactId>
<version>4.0.0</version>
</dependency>
<!-- Tests -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>
</dependencies>
<build>
<plugins>
<plugin>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId>
</plugin>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-compiler-plugin</artifactId>
<configuration>
<source>21</source>
<target>21</target>
<compilerArgs>--enable-preview</compilerArgs>
</configuration>
</plugin>
</plugins>
</build>
</project>

View File

@@ -0,0 +1,13 @@
package fr.bureauservice.app;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
@SpringBootApplication
public class PortailApplication {
static void main(String[] args) {
SpringApplication.run(PortailApplication.class, args);
}
}

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,6 @@
package fr.bureauservice.app.dto.auth;
public record AuthRequest(
String username,
String password
) {}

View File

@@ -0,0 +1,7 @@
package fr.bureauservice.app.dto.auth;
public record AuthResponse(
String username,
String accessToken,
String refreshToken
) {}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,12 @@
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

View File

@@ -0,0 +1,13 @@
package fr.bureauservice.app;
import org.junit.jupiter.api.Test;
import org.springframework.boot.test.context.SpringBootTest;
@SpringBootTest
class PortailApplicationTests {
@Test
void contextLoads() {
}
}

59
webclient/README.md Normal file
View 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
View 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

File diff suppressed because it is too large Load Diff

34
webclient/package.json Normal file
View 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"
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

BIN
webclient/public/logo.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.7 KiB

View 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)
]
};

View File

View File

@@ -0,0 +1,2 @@
<app-navbar></app-navbar>
<router-outlet></router-outlet>

View 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: '',
},
];

View 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
View 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');
}

View File

@@ -0,0 +1,7 @@
.dropdown-item i {
margin-right: 8px;
}
.navbar {
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
}

View 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>

View 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();
}
}

View 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;
}),
);
};

View 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']))),
);
};

View 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)),
);
};

View File

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

View File

@@ -0,0 +1,4 @@
export interface Credentials {
username: string;
password: string;
}

View File

@@ -0,0 +1,8 @@
export interface User {
id: number;
firstName: string;
lastName: string;
username: string;
email: string;
role: string;
}

View File

@@ -0,0 +1 @@
<p>admin works!</p>

View File

@@ -0,0 +1,9 @@
import { Component } from '@angular/core';
@Component({
selector: 'app-admin',
imports: [],
templateUrl: './admin.html',
styleUrl: './admin.css',
})
export class Admin {}

View File

@@ -0,0 +1,10 @@
.min-vh-100 {
min-height: 100vh;
}
.auth-card {
width: 100%;
max-width: 400px;
border-radius: 10px;
border: none;
}

View 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>

View 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();
}
}

View File

@@ -0,0 +1,10 @@
.min-vh-100 {
min-height: 100vh;
}
.auth-card {
width: 100%;
max-width: 520px;
border-radius: 10px;
border: none;
}

View 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>

View 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 '';
}
}

View File

View 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>

View 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();
}
}

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

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

View 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();
}
}

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

View File

@@ -0,0 +1,4 @@
export const environment = {
production: true,
apiUrl: 'https://portail.bs.local/api'
};

View File

@@ -0,0 +1,4 @@
export const environment = {
production: false,
apiUrl: 'http://localhost:8080/api'
};

27
webclient/src/index.html Normal file
View 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
View 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));

View 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
View File

@@ -0,0 +1 @@
/* You can add global styles to this file, and also import other style files */

View 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
View 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"
}
]
}

View 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"
]
}