From 35d8fbaea0a202a1ab921f1ebc93f00fc0047b11 Mon Sep 17 00:00:00 2001 From: Constantin Simonis Date: Wed, 7 May 2025 13:42:04 +0200 Subject: [PATCH 01/13] feat: implement authentication with JWT and user management --- backend/build.gradle.kts | 3 + .../blackjack/BlackJackGameController.java | 24 +- .../java/de/szut/casino/config/WebConfig.java | 30 ++ .../casino/deposit/DepositController.java | 2 +- .../exceptions/UserNotFoundException.java | 4 +- .../casino/lootboxes/LootBoxController.java | 4 +- .../szut/casino/security/AuthController.java | 31 ++ .../de/szut/casino/security/CorsFilter.java | 42 +++ .../szut/casino/security/SecurityConfig.java | 55 +++- .../casino/security/dto/AuthResponseDto.java | 19 ++ .../casino/security/dto/LoginRequestDto.java | 19 ++ .../security/jwt/JwtAuthenticationFilter.java | 64 +++++ .../de/szut/casino/security/jwt/JwtUtils.java | 83 ++++++ .../casino/security/service/AuthService.java | 50 ++++ .../service/UserDetailsServiceImpl.java | 48 ++++ .../de/szut/casino/slots/SlotController.java | 4 +- .../de/szut/casino/user/UserController.java | 44 ++- .../java/de/szut/casino/user/UserEntity.java | 14 +- .../szut/casino/user/UserMappingService.java | 9 +- .../de/szut/casino/user/UserRepository.java | 12 +- .../java/de/szut/casino/user/UserService.java | 111 ++++---- .../szut/casino/user/dto/CreateUserDto.java | 14 +- .../de/szut/casino/user/dto/GetUserDto.java | 3 +- .../transaction/GetTransactionService.java | 2 +- .../src/main/resources/application.properties | 28 +- frontend/src/app/app.routes.ts | 9 +- .../app/feature/auth/login/login.component.ts | 113 ++++++++ .../auth/register/register.component.ts | 144 ++++++++++ .../game/blackjack/blackjack.component.ts | 16 +- .../feature/landing/landing.component.html | 11 +- .../app/feature/landing/landing.component.ts | 3 +- frontend/src/app/model/User.ts | 3 +- frontend/src/app/model/auth/AuthResponse.ts | 4 + frontend/src/app/model/auth/LoginRequest.ts | 4 + .../src/app/model/auth/RegisterRequest.ts | 5 + frontend/src/app/service/auth.service.ts | 268 +++++------------- frontend/src/app/service/user.service.ts | 35 +-- .../components/navbar/navbar.component.html | 6 +- .../components/navbar/navbar.component.ts | 14 +- .../shared/interceptor/http.interceptor.ts | 24 +- frontend/src/environments/environment.ts | 4 +- frontend/src/proxy.conf.json | 4 +- 42 files changed, 989 insertions(+), 397 deletions(-) create mode 100644 backend/src/main/java/de/szut/casino/config/WebConfig.java create mode 100644 backend/src/main/java/de/szut/casino/security/AuthController.java create mode 100644 backend/src/main/java/de/szut/casino/security/CorsFilter.java create mode 100644 backend/src/main/java/de/szut/casino/security/dto/AuthResponseDto.java create mode 100644 backend/src/main/java/de/szut/casino/security/dto/LoginRequestDto.java create mode 100644 backend/src/main/java/de/szut/casino/security/jwt/JwtAuthenticationFilter.java create mode 100644 backend/src/main/java/de/szut/casino/security/jwt/JwtUtils.java create mode 100644 backend/src/main/java/de/szut/casino/security/service/AuthService.java create mode 100644 backend/src/main/java/de/szut/casino/security/service/UserDetailsServiceImpl.java create mode 100644 frontend/src/app/feature/auth/login/login.component.ts create mode 100644 frontend/src/app/feature/auth/register/register.component.ts create mode 100644 frontend/src/app/model/auth/AuthResponse.ts create mode 100644 frontend/src/app/model/auth/LoginRequest.ts create mode 100644 frontend/src/app/model/auth/RegisterRequest.ts diff --git a/backend/build.gradle.kts b/backend/build.gradle.kts index 51e3cd6..a0b6899 100644 --- a/backend/build.gradle.kts +++ b/backend/build.gradle.kts @@ -51,6 +51,9 @@ dependencies { implementation("org.springframework.boot:spring-boot-starter-oauth2-client:3.4.5") runtimeOnly("org.postgresql:postgresql") implementation("org.springdoc:springdoc-openapi-starter-webmvc-ui:2.8.8") + implementation("io.jsonwebtoken:jjwt-api:0.11.5") + runtimeOnly("io.jsonwebtoken:jjwt-impl:0.11.5") + runtimeOnly("io.jsonwebtoken:jjwt-jackson:0.11.5") } tasks.withType { diff --git a/backend/src/main/java/de/szut/casino/blackjack/BlackJackGameController.java b/backend/src/main/java/de/szut/casino/blackjack/BlackJackGameController.java index 6813236..0e77237 100644 --- a/backend/src/main/java/de/szut/casino/blackjack/BlackJackGameController.java +++ b/backend/src/main/java/de/szut/casino/blackjack/BlackJackGameController.java @@ -30,10 +30,10 @@ public class BlackJackGameController { @GetMapping("/blackjack/{id}") public ResponseEntity getGame(@PathVariable Long id, @RequestHeader("Authorization") String token) { - Optional optionalUser = userService.getCurrentUser(token); + Optional optionalUser = userService.getCurrentUser(); if (optionalUser.isEmpty()) { - throw new UserNotFoundException(); + throw new UserNotFoundException("User not found"); } UserEntity user = optionalUser.get(); @@ -47,10 +47,10 @@ public class BlackJackGameController { @PostMapping("/blackjack/{id}/hit") public ResponseEntity hit(@PathVariable Long id, @RequestHeader("Authorization") String token) { - Optional optionalUser = userService.getCurrentUser(token); + Optional optionalUser = userService.getCurrentUser(); if (optionalUser.isEmpty()) { - throw new UserNotFoundException(); + throw new UserNotFoundException("User not found"); } UserEntity user = optionalUser.get(); @@ -64,10 +64,10 @@ public class BlackJackGameController { @PostMapping("/blackjack/{id}/stand") public ResponseEntity stand(@PathVariable Long id, @RequestHeader("Authorization") String token) { - Optional optionalUser = userService.getCurrentUser(token); + Optional optionalUser = userService.getCurrentUser(); if (optionalUser.isEmpty()) { - throw new UserNotFoundException(); + throw new UserNotFoundException("User not found"); } UserEntity user = optionalUser.get(); @@ -81,10 +81,10 @@ public class BlackJackGameController { @PostMapping("/blackjack/{id}/doubleDown") public ResponseEntity doubleDown(@PathVariable Long id, @RequestHeader("Authorization") String token) { - Optional optionalUser = userService.getCurrentUser(token); + Optional optionalUser = userService.getCurrentUser(); if (optionalUser.isEmpty()) { - throw new UserNotFoundException(); + throw new UserNotFoundException("User not found"); } UserEntity user = optionalUser.get(); @@ -98,10 +98,10 @@ public class BlackJackGameController { @PostMapping("/blackjack/{id}/split") public ResponseEntity split(@PathVariable Long id, @RequestHeader("Authorization") String token) { - Optional optionalUser = userService.getCurrentUser(token); + Optional optionalUser = userService.getCurrentUser(); if (optionalUser.isEmpty()) { - throw new UserNotFoundException(); + throw new UserNotFoundException("User not found"); } UserEntity user = optionalUser.get(); @@ -115,10 +115,10 @@ public class BlackJackGameController { @PostMapping("/blackjack/start") public ResponseEntity createBlackJackGame(@RequestBody @Valid BetDto betDto, @RequestHeader("Authorization") String token) { - Optional optionalUser = userService.getCurrentUser(token); + Optional optionalUser = userService.getCurrentUser(); if (optionalUser.isEmpty()) { - throw new UserNotFoundException(); + throw new UserNotFoundException("User not found"); } UserEntity user = optionalUser.get(); diff --git a/backend/src/main/java/de/szut/casino/config/WebConfig.java b/backend/src/main/java/de/szut/casino/config/WebConfig.java new file mode 100644 index 0000000..9258349 --- /dev/null +++ b/backend/src/main/java/de/szut/casino/config/WebConfig.java @@ -0,0 +1,30 @@ +package de.szut.casino.config; + +import org.springframework.beans.factory.annotation.Value; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.web.servlet.config.annotation.CorsRegistry; +import org.springframework.web.servlet.config.annotation.WebMvcConfigurer; + +@Configuration +public class WebConfig { + + @Value("${app.frontend-host}") + private String frontendHost; + + @Bean + public WebMvcConfigurer corsConfigurer() { + return new WebMvcConfigurer() { + @Override + public void addCorsMappings(CorsRegistry registry) { + registry.addMapping("/**") + .allowedOrigins(frontendHost) + .allowedMethods("GET", "POST", "PUT", "DELETE", "OPTIONS") + .allowedHeaders("*") + .exposedHeaders("*") + .allowCredentials(true) + .maxAge(3600); + } + }; + } +} \ No newline at end of file diff --git a/backend/src/main/java/de/szut/casino/deposit/DepositController.java b/backend/src/main/java/de/szut/casino/deposit/DepositController.java index dbfe449..a81d486 100644 --- a/backend/src/main/java/de/szut/casino/deposit/DepositController.java +++ b/backend/src/main/java/de/szut/casino/deposit/DepositController.java @@ -50,7 +50,7 @@ public class DepositController { Stripe.apiKey = stripeKey; KeycloakUserDto userData = getAuthentikUserInfo(token); - Optional optionalUserEntity = this.userRepository.findOneByAuthentikId(userData.getSub()); + Optional optionalUserEntity = this.userRepository.findByEmail(userData.getSub()); SessionCreateParams params = SessionCreateParams.builder() .addLineItem(SessionCreateParams.LineItem.builder() diff --git a/backend/src/main/java/de/szut/casino/exceptionHandling/exceptions/UserNotFoundException.java b/backend/src/main/java/de/szut/casino/exceptionHandling/exceptions/UserNotFoundException.java index 6916a66..6e281db 100644 --- a/backend/src/main/java/de/szut/casino/exceptionHandling/exceptions/UserNotFoundException.java +++ b/backend/src/main/java/de/szut/casino/exceptionHandling/exceptions/UserNotFoundException.java @@ -5,7 +5,7 @@ import org.springframework.web.bind.annotation.ResponseStatus; @ResponseStatus(value = HttpStatus.NOT_FOUND) public class UserNotFoundException extends RuntimeException { - public UserNotFoundException() { - super("user not found"); + public UserNotFoundException(String message) { + super(message); } } diff --git a/backend/src/main/java/de/szut/casino/lootboxes/LootBoxController.java b/backend/src/main/java/de/szut/casino/lootboxes/LootBoxController.java index 4ef8247..1b58056 100644 --- a/backend/src/main/java/de/szut/casino/lootboxes/LootBoxController.java +++ b/backend/src/main/java/de/szut/casino/lootboxes/LootBoxController.java @@ -37,9 +37,9 @@ public class LootBoxController { LootBoxEntity lootBox = optionalLootBox.get(); - Optional optionalUser = userService.getCurrentUser(token); + Optional optionalUser = userService.getCurrentUser(); if (optionalUser.isEmpty()) { - throw new UserNotFoundException(); + throw new UserNotFoundException("User not found"); } UserEntity user = optionalUser.get(); diff --git a/backend/src/main/java/de/szut/casino/security/AuthController.java b/backend/src/main/java/de/szut/casino/security/AuthController.java new file mode 100644 index 0000000..d78fbca --- /dev/null +++ b/backend/src/main/java/de/szut/casino/security/AuthController.java @@ -0,0 +1,31 @@ +package de.szut.casino.security; + +import de.szut.casino.security.dto.AuthResponseDto; +import de.szut.casino.security.dto.LoginRequestDto; +import de.szut.casino.security.service.AuthService; +import de.szut.casino.user.dto.CreateUserDto; +import de.szut.casino.user.dto.GetUserDto; +import jakarta.validation.Valid; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.*; + +@RestController +@RequestMapping("/api/auth") +public class AuthController { + + @Autowired + private AuthService authService; + + @PostMapping("/login") + public ResponseEntity authenticateUser(@Valid @RequestBody LoginRequestDto loginRequest) { + AuthResponseDto response = authService.login(loginRequest); + return ResponseEntity.ok(response); + } + + @PostMapping("/register") + public ResponseEntity registerUser(@Valid @RequestBody CreateUserDto signUpRequest) { + GetUserDto response = authService.register(signUpRequest); + return ResponseEntity.ok(response); + } +} \ No newline at end of file diff --git a/backend/src/main/java/de/szut/casino/security/CorsFilter.java b/backend/src/main/java/de/szut/casino/security/CorsFilter.java new file mode 100644 index 0000000..cf0a368 --- /dev/null +++ b/backend/src/main/java/de/szut/casino/security/CorsFilter.java @@ -0,0 +1,42 @@ +package de.szut.casino.security; + +import jakarta.servlet.*; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.core.Ordered; +import org.springframework.core.annotation.Order; +import org.springframework.stereotype.Component; + +import java.io.IOException; + +@Component +@Order(Ordered.HIGHEST_PRECEDENCE) +public class CorsFilter implements Filter { + + @Value("${app.frontend-host}") + private String frontendHost; + + @Override + public void doFilter(ServletRequest req, ServletResponse res, FilterChain chain) + throws IOException, ServletException { + + HttpServletResponse response = (HttpServletResponse) res; + HttpServletRequest request = (HttpServletRequest) req; + + // Allow requests from the frontend + response.setHeader("Access-Control-Allow-Origin", frontendHost); + response.setHeader("Access-Control-Allow-Methods", "GET, POST, PUT, PATCH, DELETE, OPTIONS"); + response.setHeader("Access-Control-Allow-Headers", "*"); + response.setHeader("Access-Control-Expose-Headers", "*"); + response.setHeader("Access-Control-Allow-Credentials", "true"); + response.setHeader("Access-Control-Max-Age", "3600"); + + if ("OPTIONS".equalsIgnoreCase(request.getMethod())) { + response.setStatus(HttpServletResponse.SC_OK); + return; + } + + chain.doFilter(req, res); + } +} \ No newline at end of file diff --git a/backend/src/main/java/de/szut/casino/security/SecurityConfig.java b/backend/src/main/java/de/szut/casino/security/SecurityConfig.java index 65d5b2c..725563d 100644 --- a/backend/src/main/java/de/szut/casino/security/SecurityConfig.java +++ b/backend/src/main/java/de/szut/casino/security/SecurityConfig.java @@ -1,12 +1,22 @@ package de.szut.casino.security; +import de.szut.casino.security.jwt.JwtAuthenticationFilter; +import org.springframework.beans.factory.annotation.Autowired; import org.springframework.beans.factory.annotation.Value; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; -import org.springframework.security.config.Customizer; +import org.springframework.security.authentication.AuthenticationManager; +import org.springframework.security.authentication.dao.DaoAuthenticationProvider; +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.http.SessionCreationPolicy; +import org.springframework.security.core.userdetails.UserDetailsService; +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; @@ -16,23 +26,51 @@ import java.util.List; @Configuration @EnableWebSecurity +@EnableMethodSecurity public class SecurityConfig { @Value("${app.frontend-host}") private String frontendHost; + @Autowired + private UserDetailsService userDetailsService; + + @Autowired + private JwtAuthenticationFilter jwtAuthenticationFilter; + + @Bean + public DaoAuthenticationProvider authenticationProvider() { + DaoAuthenticationProvider authProvider = new DaoAuthenticationProvider(); + + authProvider.setUserDetailsService(userDetailsService); + authProvider.setPasswordEncoder(passwordEncoder()); + + return authProvider; + } + + @Bean + public AuthenticationManager authenticationManager(AuthenticationConfiguration authConfig) throws Exception { + return authConfig.getAuthenticationManager(); + } + + @Bean + public PasswordEncoder passwordEncoder() { + return new BCryptPasswordEncoder(); + } + @Bean public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception { http - .cors(Customizer.withDefaults()) + .cors(cors -> cors.configurationSource(corsConfigurationSource())) .csrf(csrf -> csrf.disable()) + .sessionManagement(session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS)) .authorizeHttpRequests(auth -> { - auth.requestMatchers("/webhook", "/swagger/**", "/swagger-ui/**", "/health").permitAll() + auth.requestMatchers("/api/auth/**", "/webhook", "/swagger/**", "/swagger-ui/**", "/health", "/error").permitAll() + .requestMatchers(org.springframework.http.HttpMethod.OPTIONS, "/**").permitAll() .anyRequest().authenticated(); }) - .oauth2ResourceServer(oauth2 -> oauth2.jwt(jwt -> - jwt.jwtAuthenticationConverter(new CustomJwtAuthenticationConverter()) - )); + .authenticationProvider(authenticationProvider()) + .addFilterBefore(jwtAuthenticationFilter, UsernamePasswordAuthenticationFilter.class); return http.build(); } @@ -42,9 +80,10 @@ public class SecurityConfig { CorsConfiguration configuration = new CorsConfiguration(); configuration.setAllowedOrigins(List.of(this.frontendHost)); configuration.setAllowedMethods(Arrays.asList("GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS")); - configuration.setAllowedHeaders(Arrays.asList("authorization", "content-type", "x-auth-token", "Access-Control-Allow-Origin")); - configuration.setExposedHeaders(List.of("x-auth-token")); + configuration.setAllowedHeaders(Arrays.asList("Authorization", "Content-Type", "Accept", "Origin", "X-Requested-With", "Access-Control-Request-Method", "Access-Control-Request-Headers", "x-auth-token")); + configuration.setExposedHeaders(Arrays.asList("Authorization", "Content-Type", "x-auth-token", "Access-Control-Allow-Origin", "Access-Control-Allow-Methods", "Access-Control-Allow-Headers")); configuration.setAllowCredentials(true); + configuration.setMaxAge(3600L); UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource(); source.registerCorsConfiguration("/**", configuration); return source; diff --git a/backend/src/main/java/de/szut/casino/security/dto/AuthResponseDto.java b/backend/src/main/java/de/szut/casino/security/dto/AuthResponseDto.java new file mode 100644 index 0000000..bf2c51c --- /dev/null +++ b/backend/src/main/java/de/szut/casino/security/dto/AuthResponseDto.java @@ -0,0 +1,19 @@ +package de.szut.casino.security.dto; + +import lombok.AllArgsConstructor; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; + +@Getter +@Setter +@NoArgsConstructor +@AllArgsConstructor +public class AuthResponseDto { + private String token; + private String tokenType = "Bearer"; + + public AuthResponseDto(String token) { + this.token = token; + } +} \ No newline at end of file diff --git a/backend/src/main/java/de/szut/casino/security/dto/LoginRequestDto.java b/backend/src/main/java/de/szut/casino/security/dto/LoginRequestDto.java new file mode 100644 index 0000000..f07396c --- /dev/null +++ b/backend/src/main/java/de/szut/casino/security/dto/LoginRequestDto.java @@ -0,0 +1,19 @@ +package de.szut.casino.security.dto; + +import jakarta.validation.constraints.NotBlank; +import lombok.AllArgsConstructor; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; + +@Getter +@Setter +@NoArgsConstructor +@AllArgsConstructor +public class LoginRequestDto { + @NotBlank(message = "Username or email is required") + private String usernameOrEmail; + + @NotBlank(message = "Password is required") + private String password; +} \ No newline at end of file diff --git a/backend/src/main/java/de/szut/casino/security/jwt/JwtAuthenticationFilter.java b/backend/src/main/java/de/szut/casino/security/jwt/JwtAuthenticationFilter.java new file mode 100644 index 0000000..a729b36 --- /dev/null +++ b/backend/src/main/java/de/szut/casino/security/jwt/JwtAuthenticationFilter.java @@ -0,0 +1,64 @@ +package de.szut.casino.security.jwt; + +import jakarta.servlet.FilterChain; +import jakarta.servlet.ServletException; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.security.core.userdetails.UserDetails; +import org.springframework.security.core.userdetails.UserDetailsService; +import org.springframework.security.web.authentication.WebAuthenticationDetailsSource; +import org.springframework.stereotype.Component; +import org.springframework.util.StringUtils; +import org.springframework.web.filter.OncePerRequestFilter; + +import java.io.IOException; + +@Component +public class JwtAuthenticationFilter extends OncePerRequestFilter { + + @Autowired + private JwtUtils jwtUtils; + + @Autowired + private UserDetailsService userDetailsService; + + @Override + protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) + throws ServletException, IOException { + try { + String jwt = parseJwt(request); + if (jwt != null) { + String username = jwtUtils.extractUsername(jwt); + + if (username != null && SecurityContextHolder.getContext().getAuthentication() == null) { + UserDetails userDetails = userDetailsService.loadUserByUsername(username); + + if (jwtUtils.validateToken(jwt, userDetails)) { + UsernamePasswordAuthenticationToken authToken = new UsernamePasswordAuthenticationToken( + userDetails, null, userDetails.getAuthorities()); + + authToken.setDetails(new WebAuthenticationDetailsSource().buildDetails(request)); + SecurityContextHolder.getContext().setAuthentication(authToken); + } + } + } + } catch (Exception e) { + logger.error("Cannot set user authentication: {}", e); + } + + filterChain.doFilter(request, response); + } + + private String parseJwt(HttpServletRequest request) { + String headerAuth = request.getHeader("Authorization"); + + if (StringUtils.hasText(headerAuth) && headerAuth.startsWith("Bearer ")) { + return headerAuth.substring(7); + } + + return null; + } +} \ No newline at end of file diff --git a/backend/src/main/java/de/szut/casino/security/jwt/JwtUtils.java b/backend/src/main/java/de/szut/casino/security/jwt/JwtUtils.java new file mode 100644 index 0000000..fac25a1 --- /dev/null +++ b/backend/src/main/java/de/szut/casino/security/jwt/JwtUtils.java @@ -0,0 +1,83 @@ +package de.szut.casino.security.jwt; + +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.Authentication; +import org.springframework.security.core.userdetails.UserDetails; +import org.springframework.stereotype.Component; + +import java.security.Key; +import java.util.Date; +import java.util.HashMap; +import java.util.Map; +import java.util.function.Function; + +@Component +public class JwtUtils { + + @Value("${jwt.secret}") + private String jwtSecret; + + @Value("${jwt.expiration.ms}") + private int jwtExpirationMs; + + private Key getSigningKey() { + return Keys.hmacShaKeyFor(jwtSecret.getBytes()); + } + + public String generateToken(Authentication authentication) { + UserDetails userDetails = (UserDetails) authentication.getPrincipal(); + return generateToken(userDetails.getUsername()); + } + + public String generateToken(String username) { + Map claims = new HashMap<>(); + return createToken(claims, username); + } + + private String createToken(Map claims, String subject) { + Date now = new Date(); + Date expiryDate = new Date(now.getTime() + jwtExpirationMs); + + return Jwts.builder() + .setClaims(claims) + .setSubject(subject) + .setIssuedAt(now) + .setExpiration(expiryDate) + .signWith(getSigningKey(), SignatureAlgorithm.HS256) + .compact(); + } + + public String extractUsername(String token) { + return extractClaim(token, Claims::getSubject); + } + + public Date extractExpiration(String token) { + return extractClaim(token, Claims::getExpiration); + } + + public T extractClaim(String token, Function claimsResolver) { + final Claims claims = extractAllClaims(token); + return claimsResolver.apply(claims); + } + + private Claims extractAllClaims(String token) { + return Jwts.parserBuilder() + .setSigningKey(getSigningKey()) + .build() + .parseClaimsJws(token) + .getBody(); + } + + private Boolean isTokenExpired(String token) { + return extractExpiration(token).before(new Date()); + } + + public Boolean validateToken(String token, UserDetails userDetails) { + final String username = extractUsername(token); + return (username.equals(userDetails.getUsername()) && !isTokenExpired(token)); + } +} \ No newline at end of file diff --git a/backend/src/main/java/de/szut/casino/security/service/AuthService.java b/backend/src/main/java/de/szut/casino/security/service/AuthService.java new file mode 100644 index 0000000..d25c953 --- /dev/null +++ b/backend/src/main/java/de/szut/casino/security/service/AuthService.java @@ -0,0 +1,50 @@ +package de.szut.casino.security.service; + +import de.szut.casino.security.dto.AuthResponseDto; +import de.szut.casino.security.dto.LoginRequestDto; +import de.szut.casino.security.jwt.JwtUtils; +import de.szut.casino.user.UserEntity; +import de.szut.casino.user.UserService; +import de.szut.casino.user.dto.CreateUserDto; +import de.szut.casino.user.dto.GetUserDto; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.security.authentication.AuthenticationManager; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.stereotype.Service; + +@Service +public class AuthService { + + @Autowired + private AuthenticationManager authenticationManager; + + @Autowired + private JwtUtils jwtUtils; + + @Autowired + private UserService userService; + + public AuthResponseDto login(LoginRequestDto loginRequest) { + Authentication authentication = authenticationManager.authenticate( + new UsernamePasswordAuthenticationToken( + loginRequest.getUsernameOrEmail(), + loginRequest.getPassword())); + + SecurityContextHolder.getContext().setAuthentication(authentication); + String jwt = jwtUtils.generateToken(authentication); + + return new AuthResponseDto(jwt); + } + + public GetUserDto register(CreateUserDto signUpRequest) { + UserEntity user = userService.createUser(signUpRequest); + return new GetUserDto( + user.getId(), + user.getEmail(), + user.getUsername(), + user.getBalance() + ); + } +} \ No newline at end of file diff --git a/backend/src/main/java/de/szut/casino/security/service/UserDetailsServiceImpl.java b/backend/src/main/java/de/szut/casino/security/service/UserDetailsServiceImpl.java new file mode 100644 index 0000000..bae4adf --- /dev/null +++ b/backend/src/main/java/de/szut/casino/security/service/UserDetailsServiceImpl.java @@ -0,0 +1,48 @@ +package de.szut.casino.security.service; + +import de.szut.casino.exceptionHandling.exceptions.UserNotFoundException; +import de.szut.casino.user.UserEntity; +import de.szut.casino.user.UserRepository; +import org.springframework.beans.factory.annotation.Autowired; +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; + +import java.util.ArrayList; +import java.util.Optional; + +@Service +public class UserDetailsServiceImpl implements UserDetailsService { + + @Autowired + private UserRepository userRepository; + + @Override + public UserDetails loadUserByUsername(String usernameOrEmail) throws UsernameNotFoundException { + Optional user = userRepository.findByUsername(usernameOrEmail); + + if (user.isEmpty()) { + user = userRepository.findByEmail(usernameOrEmail); + } + + UserEntity userEntity = user.orElseThrow(() -> + new UsernameNotFoundException("User not found with username or email: " + usernameOrEmail)); + + return new org.springframework.security.core.userdetails.User( + userEntity.getUsername(), + userEntity.getPassword(), + new ArrayList<>()); + } + + public UserEntity getUserByUsernameOrEmail(String usernameOrEmail) { + Optional user = userRepository.findByUsername(usernameOrEmail); + + if (user.isEmpty()) { + user = userRepository.findByEmail(usernameOrEmail); + } + + return user.orElseThrow(() -> + new UserNotFoundException("User not found with username or email: " + usernameOrEmail)); + } +} \ No newline at end of file diff --git a/backend/src/main/java/de/szut/casino/slots/SlotController.java b/backend/src/main/java/de/szut/casino/slots/SlotController.java index 5e27b52..030e9d8 100644 --- a/backend/src/main/java/de/szut/casino/slots/SlotController.java +++ b/backend/src/main/java/de/szut/casino/slots/SlotController.java @@ -30,10 +30,10 @@ public class SlotController { @PostMapping("/slots/spin") public ResponseEntity spinSlots(@RequestBody @Valid BetDto betDto, @RequestHeader("Authorization") String token) { - Optional optionalUser = userService.getCurrentUser(token); + Optional optionalUser = userService.getCurrentUser(); if (optionalUser.isEmpty()) { - throw new UserNotFoundException(); + throw new UserNotFoundException("User not found"); } UserEntity user = optionalUser.get(); diff --git a/backend/src/main/java/de/szut/casino/user/UserController.java b/backend/src/main/java/de/szut/casino/user/UserController.java index a5cae8c..7dae2d6 100644 --- a/backend/src/main/java/de/szut/casino/user/UserController.java +++ b/backend/src/main/java/de/szut/casino/user/UserController.java @@ -1,43 +1,41 @@ package de.szut.casino.user; import de.szut.casino.exceptionHandling.exceptions.UserNotFoundException; -import de.szut.casino.user.dto.CreateUserDto; import de.szut.casino.user.dto.GetUserDto; -import jakarta.validation.Valid; import lombok.extern.slf4j.Slf4j; import org.springframework.beans.factory.annotation.Autowired; -import org.springframework.http.HttpHeaders; -import org.springframework.http.HttpStatus; import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.*; @Slf4j @RestController +@CrossOrigin +@RequestMapping("/api/users") public class UserController { @Autowired private UserService userService; - @PostMapping("/user") - public ResponseEntity createUser(@RequestBody @Valid CreateUserDto userData) { - if (userService.exists(userData.getAuthentikId())) { - HttpHeaders headers = new HttpHeaders(); - headers.add("Location", "/user"); + @Autowired + private UserMappingService userMappingService; - return new ResponseEntity<>(headers, HttpStatus.FOUND); - } - - return ResponseEntity.ok(userService.createUser(userData)); + @GetMapping("/me") + public ResponseEntity getCurrentUser() { + return ResponseEntity.ok(userMappingService.mapToGetUserDto(userService.getCurrentUser().orElseThrow())); } - - @GetMapping("/user") - public ResponseEntity getCurrentUser(@RequestHeader("Authorization") String token) { - GetUserDto userData = userService.getCurrentUserAsDto(token); - - if (userData == null) { - throw new UserNotFoundException(); - } - - return ResponseEntity.ok(userData); + + @GetMapping("/current") + public ResponseEntity getCurrentUserAlternative() { + return ResponseEntity.ok(userMappingService.mapToGetUserDto(userService.getCurrentUser().orElseThrow())); + } + + @GetMapping("/{id}") + public ResponseEntity getUserById(@PathVariable Long id) { + return ResponseEntity.ok(userService.getUserById(id)); + } + + @GetMapping("/username/{username}") + public ResponseEntity getUserByUsername(@PathVariable String username) { + return ResponseEntity.ok(userService.getUserByUsername(username)); } } diff --git a/backend/src/main/java/de/szut/casino/user/UserEntity.java b/backend/src/main/java/de/szut/casino/user/UserEntity.java index 03f4a34..913e69d 100644 --- a/backend/src/main/java/de/szut/casino/user/UserEntity.java +++ b/backend/src/main/java/de/szut/casino/user/UserEntity.java @@ -18,16 +18,22 @@ public class UserEntity { @Id @GeneratedValue private Long id; + + @Column(unique = true) + private String email; + @Column(unique = true) - private String authentikId; private String username; - + + private String password; + @Column(precision = 19, scale = 2) private BigDecimal balance; - public UserEntity(String authentikId, String username, BigDecimal balance) { - this.authentikId = authentikId; + public UserEntity(String email, String username, String password, BigDecimal balance) { + this.email = email; this.username = username; + this.password = password; this.balance = balance; } diff --git a/backend/src/main/java/de/szut/casino/user/UserMappingService.java b/backend/src/main/java/de/szut/casino/user/UserMappingService.java index 86a1331..1e3e093 100644 --- a/backend/src/main/java/de/szut/casino/user/UserMappingService.java +++ b/backend/src/main/java/de/szut/casino/user/UserMappingService.java @@ -2,18 +2,17 @@ package de.szut.casino.user; import de.szut.casino.user.dto.CreateUserDto; import de.szut.casino.user.dto.GetUserDto; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.security.crypto.password.PasswordEncoder; import org.springframework.stereotype.Service; import java.math.BigDecimal; @Service public class UserMappingService { + public GetUserDto mapToGetUserDto(UserEntity user) { - return new GetUserDto(user.getAuthentikId(), user.getUsername(), user.getBalance()); - } - - public UserEntity mapToUserEntity(CreateUserDto createUserDto) { - return new UserEntity(createUserDto.getAuthentikId(), createUserDto.getUsername(), BigDecimal.ZERO); + return new GetUserDto(user.getId(), user.getEmail(), user.getUsername(), user.getBalance()); } } diff --git a/backend/src/main/java/de/szut/casino/user/UserRepository.java b/backend/src/main/java/de/szut/casino/user/UserRepository.java index 1f8d64e..863e744 100644 --- a/backend/src/main/java/de/szut/casino/user/UserRepository.java +++ b/backend/src/main/java/de/szut/casino/user/UserRepository.java @@ -1,15 +1,17 @@ package de.szut.casino.user; import org.springframework.data.jpa.repository.JpaRepository; -import org.springframework.data.jpa.repository.Query; import org.springframework.stereotype.Service; import java.util.Optional; @Service public interface UserRepository extends JpaRepository { - @Query("SELECT u FROM UserEntity u WHERE u.authentikId = ?1") - Optional findOneByAuthentikId(String authentikId); - - boolean existsByAuthentikId(String authentikId); + Optional findByUsername(String username); + + Optional findByEmail(String email); + + boolean existsByUsername(String username); + + boolean existsByEmail(String email); } diff --git a/backend/src/main/java/de/szut/casino/user/UserService.java b/backend/src/main/java/de/szut/casino/user/UserService.java index d5ce222..cfbd7dd 100644 --- a/backend/src/main/java/de/szut/casino/user/UserService.java +++ b/backend/src/main/java/de/szut/casino/user/UserService.java @@ -1,16 +1,14 @@ package de.szut.casino.user; +import de.szut.casino.exceptionHandling.exceptions.UserNotFoundException; import de.szut.casino.user.dto.CreateUserDto; import de.szut.casino.user.dto.GetUserDto; -import de.szut.casino.user.dto.KeycloakUserDto; import org.springframework.beans.factory.annotation.Autowired; -import org.springframework.http.HttpEntity; -import org.springframework.http.HttpHeaders; -import org.springframework.http.HttpMethod; -import org.springframework.http.ResponseEntity; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.security.crypto.password.PasswordEncoder; import org.springframework.stereotype.Service; -import org.springframework.web.client.RestTemplate; +import java.math.BigDecimal; import java.util.Optional; @Service @@ -18,64 +16,69 @@ public class UserService { @Autowired private UserRepository userRepository; - @Autowired - private RestTemplate http; - @Autowired private UserMappingService mappingService; + + @Autowired + private PasswordEncoder passwordEncoder; public UserEntity createUser(CreateUserDto createUserDto) { - UserEntity user = mappingService.mapToUserEntity(createUserDto); - userRepository.save(user); - - return user; - } - - public GetUserDto getUser(String authentikId) { - Optional user = this.userRepository.findOneByAuthentikId(authentikId); - - return user.map(userEntity -> mappingService.mapToGetUserDto(userEntity)).orElse(null); - } - - public GetUserDto getCurrentUserAsDto(String token) { - KeycloakUserDto userData = getAuthentikUserInfo(token); - - if (userData == null) { - return null; + if (userRepository.existsByUsername(createUserDto.getUsername())) { + throw new IllegalArgumentException("Username is already taken"); } - Optional user = this.userRepository.findOneByAuthentikId(userData.getSub()); - return user.map(userEntity -> mappingService.mapToGetUserDto(userEntity)).orElse(null); - } - - public Optional getCurrentUser(String token) { - KeycloakUserDto userData = getAuthentikUserInfo(token); - - if (userData == null) { - return Optional.empty(); + if (userRepository.existsByEmail(createUserDto.getEmail())) { + throw new IllegalArgumentException("Email is already in use"); } - return this.userRepository.findOneByAuthentikId(userData.getSub()); + + UserEntity user = new UserEntity( + createUserDto.getEmail(), + createUserDto.getUsername(), + passwordEncoder.encode(createUserDto.getPassword()), + BigDecimal.valueOf(1000) // Starting balance + ); + + return userRepository.save(user); } - private KeycloakUserDto getAuthentikUserInfo(String token) { - try { - HttpHeaders headers = new HttpHeaders(); - headers.set("Authorization", token); - ResponseEntity response = this.http.exchange( - "https://oauth.simonis.lol/application/o/userinfo/", - HttpMethod.GET, - new HttpEntity<>(headers), - KeycloakUserDto.class - ); - - return response.getBody(); - } catch (Exception e) { - System.err.println("Error fetching user info from Authentik: " + e.getMessage()); - return null; - } + public GetUserDto getUserById(Long id) { + UserEntity user = userRepository.findById(id) + .orElseThrow(() -> new UserNotFoundException("User not found with id: " + id)); + + return mappingService.mapToGetUserDto(user); + } + + public GetUserDto getUserByUsername(String username) { + UserEntity user = userRepository.findByUsername(username) + .orElseThrow(() -> new UserNotFoundException("User not found with username: " + username)); + + return mappingService.mapToGetUserDto(user); + } + + public GetUserDto getUserByEmail(String email) { + UserEntity user = userRepository.findByEmail(email) + .orElseThrow(() -> new UserNotFoundException("User not found with email: " + email)); + + return mappingService.mapToGetUserDto(user); } - public boolean exists(String authentikId) { - return userRepository.existsByAuthentikId(authentikId); + public Optional getCurrentUser() { + String username = SecurityContextHolder.getContext().getAuthentication().getName(); + + return userRepository.findByUsername(username); + } + + public UserEntity getCurrentUserEntity() { + String username = SecurityContextHolder.getContext().getAuthentication().getName(); + return userRepository.findByUsername(username) + .orElseThrow(() -> new UserNotFoundException("User not found with username: " + username)); + } + + public boolean existsByUsername(String username) { + return userRepository.existsByUsername(username); + } + + public boolean existsByEmail(String email) { + return userRepository.existsByEmail(email); } } diff --git a/backend/src/main/java/de/szut/casino/user/dto/CreateUserDto.java b/backend/src/main/java/de/szut/casino/user/dto/CreateUserDto.java index f983b2d..f9969cc 100644 --- a/backend/src/main/java/de/szut/casino/user/dto/CreateUserDto.java +++ b/backend/src/main/java/de/szut/casino/user/dto/CreateUserDto.java @@ -1,5 +1,8 @@ package de.szut.casino.user.dto; +import jakarta.validation.constraints.Email; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.Size; import lombok.AllArgsConstructor; import lombok.Getter; import lombok.NoArgsConstructor; @@ -10,6 +13,15 @@ import lombok.Setter; @AllArgsConstructor @NoArgsConstructor public class CreateUserDto { - private String authentikId; + @NotBlank(message = "Email is required") + @Email(message = "Email should be valid") + private String email; + + @NotBlank(message = "Username is required") + @Size(min = 3, max = 20, message = "Username must be between 3 and 20 characters") private String username; + + @NotBlank(message = "Password is required") + @Size(min = 6, message = "Password must be at least 6 characters") + private String password; } diff --git a/backend/src/main/java/de/szut/casino/user/dto/GetUserDto.java b/backend/src/main/java/de/szut/casino/user/dto/GetUserDto.java index 7a7d561..2c41f0d 100644 --- a/backend/src/main/java/de/szut/casino/user/dto/GetUserDto.java +++ b/backend/src/main/java/de/szut/casino/user/dto/GetUserDto.java @@ -12,7 +12,8 @@ import java.math.BigDecimal; @AllArgsConstructor @NoArgsConstructor public class GetUserDto { - private String authentikId; + private Long id; + private String email; private String username; private BigDecimal balance; } diff --git a/backend/src/main/java/de/szut/casino/user/transaction/GetTransactionService.java b/backend/src/main/java/de/szut/casino/user/transaction/GetTransactionService.java index 831c438..2a8350f 100644 --- a/backend/src/main/java/de/szut/casino/user/transaction/GetTransactionService.java +++ b/backend/src/main/java/de/szut/casino/user/transaction/GetTransactionService.java @@ -22,7 +22,7 @@ public class GetTransactionService { private TransactionRepository transactionRepository; public UserTransactionsDto getUserTransactionsDto(String authToken, Integer limit, Integer offset) { - Optional user = this.userService.getCurrentUser(authToken); + Optional user = this.userService.getCurrentUser(); if (user.isPresent()) { List transactionEntities = this.transactionRepository.findByUserIdWithLimit(user.get(), limit, offset); Boolean hasMore = this.transactionRepository.hasMore(user.get(), limit, offset); diff --git a/backend/src/main/resources/application.properties b/backend/src/main/resources/application.properties index bb8a4be..0d18cfd 100644 --- a/backend/src/main/resources/application.properties +++ b/backend/src/main/resources/application.properties @@ -8,33 +8,15 @@ stripe.webhook.secret=${STRIPE_WEBHOOK_SECRET:whsec_746b6a488665f6057118bdb4a2b3 app.frontend-host=${FE_URL:http://localhost:4200} spring.application.name=casino -#client registration configuration -spring.security.oauth2.client.registration.authentik.client-id=${AUTH_CLIENT_ID:MDqjm1kcWKuZfqHJXjxwAV20i44aT7m4VhhTL3Nm} -spring.security.oauth2.client.registration.authentik.client-secret=${AUTH_CLIENT_SECRET:GY2F8te6iAVYt1TNAUVLzWZEXb6JoMNp6chbjqaXNq4gS5xTDL54HqBiAlV1jFKarN28LQ7FUsYX4SbwjfEhZhgeoKuBnZKjR9eiu7RawnGgxIK9ffvUfMkjRxnmiGI5} -spring.security.oauth2.client.registration.authentik.provider=authentik -spring.security.oauth2.client.registration.authentik.client-name=Authentik -spring.security.oauth2.client.registration.authentik.scope=openid,email,profile -spring.security.oauth2.client.registration.authentik.client-authentication-method=client_secret_basic -spring.security.oauth2.client.registration.authentik.authorization-grant-type=authorization_code -spring.security.oauth2.client.registration.authentik.redirect-uri={baseUrl}/login/oauth2/code/{registrationId} +# JWT Configuration +jwt.secret=${JWT_SECRET:5367566B59703373367639792F423F4528482B4D6251655468576D5A71347437} +jwt.expiration.ms=${JWT_EXPIRATION_MS:86400000} -# Provider settings -spring.security.oauth2.client.provider.authentik.issuer-uri=${AUTH_PROVIDER_ISSUER:https://oauth.simonis.lol/application/o/casino-dev/} -spring.security.oauth2.client.provider.authentik.authorization-uri=${AUTH_PROVIDER_AUTHORIZE_URI:https://oauth.simonis.lol/application/o/authorize/} -spring.security.oauth2.client.provider.authentik.token-uri=${AUTH_PROVIDER_TOKEN_URI:https://oauth.simonis.lol/application/o/token/} -spring.security.oauth2.client.provider.authentik.user-info-uri=${AUTH_PROVIDER_USERINFO_URI:https://oauth.simonis.lol/application/o/userinfo/} -spring.security.oauth2.client.provider.authentik.jwk-set-uri=${AUTH_PROVIDER_JWKS_URI:https://oauth.simonis.lol/application/o/casino-dev/jwks/} -spring.security.oauth2.client.provider.authentik.user-name-attribute=${AUTH_PROVIDER_NAME_ATTR:preferred_username} - -# Resource server config -spring.security.oauth2.resourceserver.jwt.issuer-uri=${AUTH_JWT_ISSUER_URI:https://oauth.simonis.lol/application/o/casino-dev}/ -spring.security.oauth2.resourceserver.jwt.jwk-set-uri=${AUTH_JWT_JWT_SET_URI:https://oauth.simonis.lol/application/o/casino-dev/jwks/} - -#OIDC provider configuration: +# Logging logging.level.org.springframework.security=DEBUG -#validating JWT token against our Authentik server +# Swagger springdoc.swagger-ui.path=swagger springdoc.swagger-ui.try-it-out-enabled=true diff --git a/frontend/src/app/app.routes.ts b/frontend/src/app/app.routes.ts index a207b91..8874f98 100644 --- a/frontend/src/app/app.routes.ts +++ b/frontend/src/app/app.routes.ts @@ -8,8 +8,12 @@ export const routes: Routes = [ component: LandingComponent, }, { - path: 'auth/callback', - loadComponent: () => import('./feature/login-success/login-success.component'), + path: 'login', + loadComponent: () => import('./feature/auth/login/login.component').then(m => m.LoginComponent), + }, + { + path: 'register', + loadComponent: () => import('./feature/auth/register/register.component').then(m => m.RegisterComponent), }, { path: 'home', @@ -24,7 +28,6 @@ export const routes: Routes = [ { path: 'game/slots', loadComponent: () => import('./feature/game/slots/slots.component'), - canActivate: [authGuard], }, { path: 'game/lootboxes', diff --git a/frontend/src/app/feature/auth/login/login.component.ts b/frontend/src/app/feature/auth/login/login.component.ts new file mode 100644 index 0000000..694d91d --- /dev/null +++ b/frontend/src/app/feature/auth/login/login.component.ts @@ -0,0 +1,113 @@ +import { Component } from '@angular/core'; +import { FormBuilder, FormGroup, ReactiveFormsModule, Validators } from '@angular/forms'; +import { Router, RouterLink } from '@angular/router'; +import { LoginRequest } from '../../../model/auth/LoginRequest'; +import { AuthService } from '../../../service/auth.service'; +import { CommonModule } from '@angular/common'; + +@Component({ + selector: 'app-login', + standalone: true, + imports: [CommonModule, ReactiveFormsModule, RouterLink], + template: ` +
+
+

Login to Casino

+ +
+ {{ errorMessage }} +
+ +
+
+ + + +
+ Username or email is required +
+
+ +
+ + + +
+ Password is required +
+
+ +
+ +
+
+ +
+

+ Don't have an account? + Register +

+
+
+
+ ` +}) +export class LoginComponent { + loginForm: FormGroup; + errorMessage = ''; + isLoading = false; + + constructor( + private fb: FormBuilder, + private authService: AuthService, + private router: Router + ) { + this.loginForm = this.fb.group({ + usernameOrEmail: ['', [Validators.required]], + password: ['', [Validators.required]] + }); + } + + get form() { + return this.loginForm.controls; + } + + onSubmit(): void { + if (this.loginForm.invalid) { + return; + } + + this.isLoading = true; + this.errorMessage = ''; + + const loginRequest: LoginRequest = { + usernameOrEmail: this.form['usernameOrEmail'].value, + password: this.form['password'].value + }; + + this.authService.login(loginRequest).subscribe({ + next: () => { + this.router.navigate(['/home']); + }, + error: err => { + this.isLoading = false; + this.errorMessage = err.error?.message || 'Failed to login. Please check your credentials.'; + } + }); + } +} diff --git a/frontend/src/app/feature/auth/register/register.component.ts b/frontend/src/app/feature/auth/register/register.component.ts new file mode 100644 index 0000000..b997f04 --- /dev/null +++ b/frontend/src/app/feature/auth/register/register.component.ts @@ -0,0 +1,144 @@ +import { Component } from '@angular/core'; +import { FormBuilder, FormGroup, ReactiveFormsModule, Validators } from '@angular/forms'; +import { Router, RouterLink } from '@angular/router'; +import { RegisterRequest } from '../../../model/auth/RegisterRequest'; +import { AuthService } from '../../../service/auth.service'; +import { CommonModule } from '@angular/common'; + +@Component({ + selector: 'app-register', + standalone: true, + imports: [CommonModule, ReactiveFormsModule, RouterLink], + template: ` +
+
+

Create Account

+ +
+ {{ errorMessage }} +
+ +
+
+ + + +
+ Email is required + Please enter a valid email address +
+
+ +
+ + + +
+ Username is required + Username must be at least 3 characters +
+
+ +
+ + + +
+ Password is required + Password must be at least 6 characters +
+
+ +
+ +
+
+ +
+

+ Already have an account? + Login +

+
+
+
+ ` +}) +export class RegisterComponent { + registerForm: FormGroup; + errorMessage = ''; + isLoading = false; + + constructor( + private fb: FormBuilder, + private authService: AuthService, + private router: Router + ) { + this.registerForm = this.fb.group({ + email: ['', [Validators.required, Validators.email]], + username: ['', [Validators.required, Validators.minLength(3)]], + password: ['', [Validators.required, Validators.minLength(6)]] + }); + } + + get form() { + return this.registerForm.controls; + } + + onSubmit(): void { + if (this.registerForm.invalid) { + return; + } + + this.isLoading = true; + this.errorMessage = ''; + + const registerRequest: RegisterRequest = { + email: this.form['email'].value, + username: this.form['username'].value, + password: this.form['password'].value + }; + + this.authService.register(registerRequest).subscribe({ + next: () => { + // After registration, log in the user + this.authService.login({ + usernameOrEmail: registerRequest.email, + password: registerRequest.password + }).subscribe({ + next: () => { + this.router.navigate(['/home']); + }, + error: err => { + this.isLoading = false; + this.errorMessage = 'Registration successful but failed to login automatically. Please log in manually.'; + } + }); + }, + error: err => { + this.isLoading = false; + this.errorMessage = err.error?.message || 'Failed to register. Please try again.'; + } + }); + } +} diff --git a/frontend/src/app/feature/game/blackjack/blackjack.component.ts b/frontend/src/app/feature/game/blackjack/blackjack.component.ts index a2e760b..e261260 100644 --- a/frontend/src/app/feature/game/blackjack/blackjack.component.ts +++ b/frontend/src/app/feature/game/blackjack/blackjack.component.ts @@ -51,7 +51,7 @@ export default class BlackjackComponent implements OnInit { debtAmount = signal(0); ngOnInit(): void { - this.userService.currentUser$.subscribe((user) => { + this.userService.getCurrentUser().subscribe((user) => { if (user) { this.balance.set(user.balance); } @@ -83,7 +83,7 @@ export default class BlackjackComponent implements OnInit { if (isGameOver) { console.log('Game is over, state:', game.state); - this.userService.refreshCurrentUser(); + // this.userService.refreshCurrentUser(); timer(1500).subscribe(() => { this.showGameResult.set(true); console.log('Game result dialog shown after delay'); @@ -97,7 +97,7 @@ export default class BlackjackComponent implements OnInit { this.blackjackService.startGame(bet).subscribe({ next: (game) => { this.updateGameState(game); - this.userService.refreshCurrentUser(); + // this.userService.refreshCurrentUser(); this.isActionInProgress.set(false); }, error: (error) => { @@ -116,7 +116,7 @@ export default class BlackjackComponent implements OnInit { next: (game) => { this.updateGameState(game); if (game.state !== 'IN_PROGRESS') { - this.userService.refreshCurrentUser(); + // this.userService.refreshCurrentUser(); } this.isActionInProgress.set(false); }, @@ -141,7 +141,7 @@ export default class BlackjackComponent implements OnInit { this.blackjackService.stand(this.currentGameId()!).subscribe({ next: (game) => { this.updateGameState(game); - this.userService.refreshCurrentUser(); + // this.userService.refreshCurrentUser(); this.isActionInProgress.set(false); }, error: (error) => { @@ -184,7 +184,7 @@ export default class BlackjackComponent implements OnInit { onCloseGameResult(): void { console.log('Closing game result dialog'); this.showGameResult.set(false); - this.userService.refreshCurrentUser(); + // this.userService.refreshCurrentUser(); } onCloseDebtDialog(): void { @@ -195,11 +195,11 @@ export default class BlackjackComponent implements OnInit { if (error instanceof HttpErrorResponse) { if (error.status === 400 && error.error?.error === 'Invalid state') { this.gameInProgress.set(false); - this.userService.refreshCurrentUser(); + // this.userService.refreshCurrentUser(); } else if (error.status === 500) { console.log('Server error occurred. The game may have been updated in another session.'); this.gameInProgress.set(false); - this.userService.refreshCurrentUser(); + // this.userService.refreshCurrentUser(); if (this.currentGameId()) { this.refreshGameState(this.currentGameId()!); } diff --git a/frontend/src/app/feature/landing/landing.component.html b/frontend/src/app/feature/landing/landing.component.html index bffa9ad..379fa31 100644 --- a/frontend/src/app/feature/landing/landing.component.html +++ b/frontend/src/app/feature/landing/landing.component.html @@ -10,9 +10,14 @@
200% bis zu 500€

+ 200 Freispiele

- +
diff --git a/frontend/src/app/feature/landing/landing.component.ts b/frontend/src/app/feature/landing/landing.component.ts index 915547f..d1437d3 100644 --- a/frontend/src/app/feature/landing/landing.component.ts +++ b/frontend/src/app/feature/landing/landing.component.ts @@ -1,11 +1,12 @@ import { ChangeDetectionStrategy, Component, OnDestroy, OnInit } from '@angular/core'; import { NgFor } from '@angular/common'; import { NavbarComponent } from '@shared/components/navbar/navbar.component'; +import { RouterLink } from '@angular/router'; @Component({ selector: 'app-landing-page', standalone: true, - imports: [NavbarComponent, NgFor], + imports: [NavbarComponent, NgFor, RouterLink], templateUrl: './landing.component.html', changeDetection: ChangeDetectionStrategy.OnPush, }) diff --git a/frontend/src/app/model/User.ts b/frontend/src/app/model/User.ts index 03480a9..3c983b8 100644 --- a/frontend/src/app/model/User.ts +++ b/frontend/src/app/model/User.ts @@ -1,5 +1,6 @@ export interface User { - authentikId: string; + id: number; + email: string; username: string; balance: number; } diff --git a/frontend/src/app/model/auth/AuthResponse.ts b/frontend/src/app/model/auth/AuthResponse.ts new file mode 100644 index 0000000..bd8c3f5 --- /dev/null +++ b/frontend/src/app/model/auth/AuthResponse.ts @@ -0,0 +1,4 @@ +export interface AuthResponse { + token: string; + tokenType: string; +} \ No newline at end of file diff --git a/frontend/src/app/model/auth/LoginRequest.ts b/frontend/src/app/model/auth/LoginRequest.ts new file mode 100644 index 0000000..c940095 --- /dev/null +++ b/frontend/src/app/model/auth/LoginRequest.ts @@ -0,0 +1,4 @@ +export interface LoginRequest { + usernameOrEmail: string; + password: string; +} \ No newline at end of file diff --git a/frontend/src/app/model/auth/RegisterRequest.ts b/frontend/src/app/model/auth/RegisterRequest.ts new file mode 100644 index 0000000..c3cd340 --- /dev/null +++ b/frontend/src/app/model/auth/RegisterRequest.ts @@ -0,0 +1,5 @@ +export interface RegisterRequest { + email: string; + username: string; + password: string; +} \ No newline at end of file diff --git a/frontend/src/app/service/auth.service.ts b/frontend/src/app/service/auth.service.ts index 7300a25..fa74844 100644 --- a/frontend/src/app/service/auth.service.ts +++ b/frontend/src/app/service/auth.service.ts @@ -1,208 +1,96 @@ -import { inject, Injectable } from '@angular/core'; -import { AuthConfig, OAuthEvent, OAuthService } from 'angular-oauth2-oidc'; -import { UserService } from './user.service'; -import { User } from '../model/User'; +import { Injectable } from '@angular/core'; +import { HttpClient } from '@angular/common/http'; +import { BehaviorSubject, Observable, tap } from 'rxjs'; import { Router } from '@angular/router'; +import { LoginRequest } from '../model/auth/LoginRequest'; +import { RegisterRequest } from '../model/auth/RegisterRequest'; +import { AuthResponse } from '../model/auth/AuthResponse'; +import { User } from '../model/User'; import { environment } from '../../environments/environment'; -import { catchError, from, of } from 'rxjs'; + +const TOKEN_KEY = 'auth-token'; +const USER_KEY = 'auth-user'; @Injectable({ providedIn: 'root', }) export class AuthService { - private readonly authConfig: AuthConfig = { - issuer: 'https://oauth.simonis.lol/application/o/casino-dev/', - clientId: environment.OAUTH_CLIENT_ID, - dummyClientSecret: environment.OAUTH_CLIENT_SECRET, - scope: `openid email profile ${environment.OAUTH_CLIENT_ID}`, - responseType: 'code', - redirectUri: window.location.origin + '/auth/callback', - postLogoutRedirectUri: '', - redirectUriAsPostLogoutRedirectUriFallback: false, - oidc: true, - requestAccessToken: true, - tokenEndpoint: 'https://oauth.simonis.lol/application/o/token/', - userinfoEndpoint: 'https://oauth.simonis.lol/application/o/userinfo/', - strictDiscoveryDocumentValidation: false, - skipIssuerCheck: true, - disableAtHashCheck: true, - requireHttps: false, - showDebugInformation: false, - sessionChecksEnabled: false, - }; - - private userService: UserService = inject(UserService); - private oauthService: OAuthService = inject(OAuthService); - private router: Router = inject(Router); - - private user: User | null = null; - - constructor() { - this.oauthService.configure(this.authConfig); - this.setupEventHandling(); - - const hasAuthParams = - window.location.search.includes('code=') || - window.location.search.includes('token=') || - window.location.search.includes('id_token='); - - if (hasAuthParams) { - this.processCodeFlow(); - } else { - this.checkExistingSession(); + private authUrl = `${environment.apiUrl}/api/auth`; + private userUrl = `${environment.apiUrl}/api/users`; + + private currentUserSubject: BehaviorSubject; + public currentUser: Observable; + + constructor(private http: HttpClient, private router: Router) { + this.currentUserSubject = new BehaviorSubject(this.getUserFromStorage()); + this.currentUser = this.currentUserSubject.asObservable(); + + // Check if token exists and load user data + if (this.getToken()) { + this.loadCurrentUser(); } } - - private processCodeFlow() { - this.oauthService - .tryLogin({ - onTokenReceived: () => { - this.handleSuccessfulLogin(); - }, - }) - .catch((err) => { - console.error('Error processing code flow:', err); - }); + + public get currentUserValue(): User | null { + return this.currentUserSubject.value; } - - private checkExistingSession() { - this.oauthService - .loadDiscoveryDocumentAndTryLogin() - .then((isLoggedIn) => { - if (isLoggedIn && !this.user) { - this.handleSuccessfulLogin(); - } - }) - .catch((err) => { - console.error('Error during initial login attempt:', err); - }); - } - - private setupEventHandling() { - this.oauthService.events.subscribe((event: OAuthEvent) => { - if (event.type === 'token_received') { - this.handleSuccessfulLogin(); - } - }); - } - - private handleSuccessfulLogin() { - const claims = this.oauthService.getIdentityClaims(); - - if (claims && (claims['sub'] || claims['email'])) { - this.processUserProfile(claims); - return; - } - - try { - from(this.oauthService.loadUserProfile()) - .pipe( - catchError((error) => { - console.error('Error loading user profile:', error); - if (this.oauthService.hasValidAccessToken()) { - this.oauthService.getAccessToken(); - const minimalProfile = { - sub: 'user-' + Math.random().toString(36).substring(2, 10), - preferred_username: 'user' + Date.now(), - }; - return of({ info: minimalProfile }); - } - return of(null); - }) - ) - .subscribe((profile) => { - if (profile) { - this.processUserProfile(profile); - } else { - this.router.navigate(['/']); - } - }); - } catch (err) { - console.error('Exception in handleSuccessfulLogin:', err); - if (this.oauthService.hasValidAccessToken()) { - this.router.navigate(['/home']); - } else { - this.router.navigate(['/']); - } - } - } - - private processUserProfile(profile: unknown) { - this.fromUserProfile(profile as Record).subscribe({ - next: (user) => { - this.user = user; - this.router.navigate(['home']); - }, - error: (err) => { - console.error('Error creating/retrieving user:', err); - if (this.oauthService.hasValidAccessToken()) { - this.router.navigate(['/home']); - } else { - this.router.navigate(['/']); - } - }, - }); - } - - login() { - try { - this.oauthService - .loadDiscoveryDocument() - .then(() => { - this.oauthService.initLoginFlow(); + + login(loginRequest: LoginRequest): Observable { + return this.http.post(`${this.authUrl}/login`, loginRequest) + .pipe( + tap(response => { + this.setToken(response.token); + this.loadCurrentUser(); }) - .catch((err) => { - console.error('Error loading discovery document:', err); - this.oauthService.initLoginFlow(); - }); - } catch (err) { - console.error('Exception in login:', err); - const redirectUri = this.authConfig.redirectUri || window.location.origin + '/auth/callback'; - const scope = this.authConfig.scope || 'openid email profile'; - const authUrl = `${this.authConfig.issuer}authorize?client_id=${this.authConfig.clientId}&redirect_uri=${encodeURIComponent(redirectUri)}&response_type=code&scope=${encodeURIComponent(scope)}`; - window.location.href = authUrl; - } + ); } - - logout() { - try { - this.user = null; - - this.oauthService.logOut(true); - - if (window.location.href.includes('id_token') || window.location.href.includes('logout')) { - window.location.href = window.location.origin; - } - - localStorage.removeItem('access_token'); - localStorage.removeItem('id_token'); - localStorage.removeItem('refresh_token'); - sessionStorage.removeItem('access_token'); - sessionStorage.removeItem('id_token'); - sessionStorage.removeItem('refresh_token'); - - this.router.navigate(['/']); - } catch (err) { - console.error('Exception in logout:', err); - localStorage.clear(); - sessionStorage.clear(); - this.router.navigate(['/']); - } + + register(registerRequest: RegisterRequest): Observable { + return this.http.post(`${this.authUrl}/register`, registerRequest); } - - isLoggedIn() { - return this.oauthService.hasValidAccessToken(); + + logout(): void { + localStorage.removeItem(TOKEN_KEY); + localStorage.removeItem(USER_KEY); + this.currentUserSubject.next(null); + this.router.navigate(['/']); } - - private fromUserProfile(profile: Record) { - return this.userService.getOrCreateUser(profile); + + isLoggedIn(): boolean { + return !!this.getToken(); } - - getAccessToken() { - return this.oauthService.getAccessToken(); + + getToken(): string | null { + return localStorage.getItem(TOKEN_KEY); } - - getUser() { - return this.user; + + private setToken(token: string): void { + localStorage.setItem(TOKEN_KEY, token); + } + + private setUser(user: User): void { + localStorage.setItem(USER_KEY, JSON.stringify(user)); + this.currentUserSubject.next(user); + } + + private getUserFromStorage(): User | null { + const user = localStorage.getItem(USER_KEY); + return user ? JSON.parse(user) : null; + } + + private loadCurrentUser(): void { + this.http.get(`${this.userUrl}/me`) + .subscribe({ + next: (user) => { + this.setUser(user); + }, + error: () => { + this.logout(); + } + }); + } + + getUser(): User | null { + return this.currentUserValue; } } diff --git a/frontend/src/app/service/user.service.ts b/frontend/src/app/service/user.service.ts index a76e7b4..b58861f 100644 --- a/frontend/src/app/service/user.service.ts +++ b/frontend/src/app/service/user.service.ts @@ -1,25 +1,24 @@ -import { inject, Injectable } from '@angular/core'; +import { Injectable } from '@angular/core'; import { HttpClient } from '@angular/common/http'; import { BehaviorSubject, catchError, EMPTY, Observable, tap } from 'rxjs'; import { User } from '../model/User'; +import { environment } from '@environments/environment'; @Injectable({ providedIn: 'root', }) export class UserService { - private http: HttpClient = inject(HttpClient); + private apiUrl = `${environment.apiUrl}/api/users`; private currentUserSubject = new BehaviorSubject(null); - public currentUser$ = this.currentUserSubject.asObservable(); - constructor() { - this.getCurrentUser().subscribe(); + constructor(private http: HttpClient) {} + + public getUserById(id: number): Observable { + return this.http.get(`${this.apiUrl}/${id}`); } - public getUser(id: string): Observable { - return this.http.get(`/backend/user/${id}`).pipe( - catchError(() => EMPTY), - tap((user) => this.currentUserSubject.next(user)) - ); + public getUserByUsername(username: string): Observable { + return this.http.get(`${this.apiUrl}/username/${username}`); } public getCurrentUser(): Observable { @@ -52,20 +51,4 @@ export class UserService { }) .pipe(tap((user) => this.currentUserSubject.next(user))); } - - public getOrCreateUser(profile: Record): Observable { - const info = profile['info'] as Record | undefined; - const id = (info?.['sub'] as string) || (profile['sub'] as string); - const username = - (info?.['preferred_username'] as string) || - (profile['preferred_username'] as string) || - (profile['email'] as string) || - (profile['name'] as string); - - if (!id || !username) { - throw new Error('Invalid user profile data'); - } - - return this.createUser(id, username); - } } diff --git a/frontend/src/app/shared/components/navbar/navbar.component.html b/frontend/src/app/shared/components/navbar/navbar.component.html index e9f8e91..110063c 100644 --- a/frontend/src/app/shared/components/navbar/navbar.component.html +++ b/frontend/src/app/shared/components/navbar/navbar.component.html @@ -12,7 +12,8 @@ @@ -77,12 +96,14 @@ import { CommonModule } from '@angular/common';

Already have an account? - Login + Login

- ` + `, }) export class RegisterComponent { registerForm: FormGroup; @@ -97,7 +118,7 @@ export class RegisterComponent { this.registerForm = this.fb.group({ email: ['', [Validators.required, Validators.email]], username: ['', [Validators.required, Validators.minLength(3)]], - password: ['', [Validators.required, Validators.minLength(6)]] + password: ['', [Validators.required, Validators.minLength(6)]], }); } @@ -116,29 +137,32 @@ export class RegisterComponent { const registerRequest: RegisterRequest = { email: this.form['email'].value, username: this.form['username'].value, - password: this.form['password'].value + password: this.form['password'].value, }; this.authService.register(registerRequest).subscribe({ next: () => { // After registration, log in the user - this.authService.login({ - usernameOrEmail: registerRequest.email, - password: registerRequest.password - }).subscribe({ - next: () => { - this.router.navigate(['/home']); - }, - error: err => { - this.isLoading = false; - this.errorMessage = 'Registration successful but failed to login automatically. Please log in manually.'; - } - }); + this.authService + .login({ + usernameOrEmail: registerRequest.email, + password: registerRequest.password, + }) + .subscribe({ + next: () => { + this.router.navigate(['/home']); + }, + error: (err) => { + this.isLoading = false; + this.errorMessage = + 'Registration successful but failed to login automatically. Please log in manually.'; + }, + }); }, - error: err => { + error: (err) => { this.isLoading = false; this.errorMessage = err.error?.message || 'Failed to register. Please try again.'; - } + }, }); } } diff --git a/frontend/src/app/feature/landing/landing.component.html b/frontend/src/app/feature/landing/landing.component.html index 379fa31..b0c1ccf 100644 --- a/frontend/src/app/feature/landing/landing.component.html +++ b/frontend/src/app/feature/landing/landing.component.html @@ -11,10 +11,16 @@

+ 200 Freispiele

diff --git a/frontend/src/app/model/auth/AuthResponse.ts b/frontend/src/app/model/auth/AuthResponse.ts index bd8c3f5..495d0cd 100644 --- a/frontend/src/app/model/auth/AuthResponse.ts +++ b/frontend/src/app/model/auth/AuthResponse.ts @@ -1,4 +1,4 @@ export interface AuthResponse { token: string; tokenType: string; -} \ No newline at end of file +} diff --git a/frontend/src/app/model/auth/LoginRequest.ts b/frontend/src/app/model/auth/LoginRequest.ts index c940095..98c1b0e 100644 --- a/frontend/src/app/model/auth/LoginRequest.ts +++ b/frontend/src/app/model/auth/LoginRequest.ts @@ -1,4 +1,4 @@ export interface LoginRequest { usernameOrEmail: string; password: string; -} \ No newline at end of file +} diff --git a/frontend/src/app/model/auth/RegisterRequest.ts b/frontend/src/app/model/auth/RegisterRequest.ts index c3cd340..2b07d41 100644 --- a/frontend/src/app/model/auth/RegisterRequest.ts +++ b/frontend/src/app/model/auth/RegisterRequest.ts @@ -2,4 +2,4 @@ export interface RegisterRequest { email: string; username: string; password: string; -} \ No newline at end of file +} diff --git a/frontend/src/app/service/auth.service.ts b/frontend/src/app/service/auth.service.ts index fa74844..6cd41a7 100644 --- a/frontend/src/app/service/auth.service.ts +++ b/frontend/src/app/service/auth.service.ts @@ -17,79 +17,80 @@ const USER_KEY = 'auth-user'; export class AuthService { private authUrl = `${environment.apiUrl}/api/auth`; private userUrl = `${environment.apiUrl}/api/users`; - + private currentUserSubject: BehaviorSubject; public currentUser: Observable; - - constructor(private http: HttpClient, private router: Router) { + + constructor( + private http: HttpClient, + private router: Router + ) { this.currentUserSubject = new BehaviorSubject(this.getUserFromStorage()); this.currentUser = this.currentUserSubject.asObservable(); - + // Check if token exists and load user data if (this.getToken()) { this.loadCurrentUser(); } } - + public get currentUserValue(): User | null { return this.currentUserSubject.value; } - + login(loginRequest: LoginRequest): Observable { - return this.http.post(`${this.authUrl}/login`, loginRequest) - .pipe( - tap(response => { - this.setToken(response.token); - this.loadCurrentUser(); - }) - ); + return this.http.post(`${this.authUrl}/login`, loginRequest).pipe( + tap((response) => { + this.setToken(response.token); + this.loadCurrentUser(); + }) + ); } - + register(registerRequest: RegisterRequest): Observable { return this.http.post(`${this.authUrl}/register`, registerRequest); } - + logout(): void { localStorage.removeItem(TOKEN_KEY); localStorage.removeItem(USER_KEY); this.currentUserSubject.next(null); this.router.navigate(['/']); } - + isLoggedIn(): boolean { return !!this.getToken(); } - + getToken(): string | null { return localStorage.getItem(TOKEN_KEY); } - + private setToken(token: string): void { localStorage.setItem(TOKEN_KEY, token); } - + private setUser(user: User): void { localStorage.setItem(USER_KEY, JSON.stringify(user)); this.currentUserSubject.next(user); } - + private getUserFromStorage(): User | null { const user = localStorage.getItem(USER_KEY); return user ? JSON.parse(user) : null; } - + private loadCurrentUser(): void { - this.http.get(`${this.userUrl}/me`) - .subscribe({ - next: (user) => { - this.setUser(user); - }, - error: () => { - this.logout(); - } - }); + this.http.get(`${this.userUrl}/me`).subscribe({ + next: (user) => { + this.setUser(user); + }, + error: () => { + this.logout(); + }, + }); } - + getUser(): User | null { return this.currentUserValue; } diff --git a/frontend/src/app/service/user.service.ts b/frontend/src/app/service/user.service.ts index b58861f..78fbb75 100644 --- a/frontend/src/app/service/user.service.ts +++ b/frontend/src/app/service/user.service.ts @@ -42,13 +42,4 @@ export class UserService { this.currentUserSubject.next(updatedUser); } } - - public createUser(id: string, username: string): Observable { - return this.http - .post('/backend/user', { - authentikId: id, - username: username, - }) - .pipe(tap((user) => this.currentUserSubject.next(user))); - } } diff --git a/frontend/src/app/shared/components/navbar/navbar.component.html b/frontend/src/app/shared/components/navbar/navbar.component.html index 110063c..90b63eb 100644 --- a/frontend/src/app/shared/components/navbar/navbar.component.html +++ b/frontend/src/app/shared/components/navbar/navbar.component.html @@ -13,7 +13,11 @@