diff --git a/README.md b/README.md index add506a..5d95517 100644 --- a/README.md +++ b/README.md @@ -19,12 +19,13 @@ this project is inspired by the structure of Kanban boards and tools like Trello ## ✨ Key Features * **Visual Kanban Workflow –** Organize your learning in BACKLOG, IN PROGRESS, and MASTERED. +* **Identity & Account Management (New!) –** Secure user registration, authentication, and personalized profile management. * **Intuitive Skill Tracking –** Easily add new topics and document your journey towards mastery. * **Smart Filtering & Pagination –** Quickly find what you need with real-time filtering and dynamic "Load More" functionality. * **Resource Management –** Keep essential documentation, source links and notes attached to every skill for quick reference. ## 🛠️ Tech Stack -* **Backend –** Java 25 & Spring Boot 4.0 +* **Backend –** Java 25, Spring Boot 4.0, and Spring Security (Authentication & BCrypt Hashing) * **Frontend –** Thymeleaf, Vanilla JavaScript, Modern CSS * **Database –** PostgreSQL * **Data Handling –** Spring Data JPA with Pagination support @@ -36,11 +37,13 @@ this project is inspired by the structure of Kanban boards and tools like Trello ## 🗺️ Upcoming Features: * **Drag & Drop –** Move skill cards between columns for a more dynamic and interactive experience. -* **User Authentication –** Personal accounts to secure your data and enable private roadmaps. ## 🚀 Quick Start 1. Clone the repository. 2. Run `docker-compose up`. 3. Open `http://localhost:8080` in your browser. -4. **Explore –** The app comes pre-loaded with sample data to help you get started right away! +4. **Log in or Sign up –** You can create a new account instantly, or use the pre-loaded developer profile: + * **Username:** `test` + * **Password:** `secret` +5. Let your journey begin! diff --git a/pom.xml b/pom.xml index 73a7401..ae6fd6c 100644 --- a/pom.xml +++ b/pom.xml @@ -111,6 +111,19 @@ h2 test + + org.springframework.boot + spring-boot-starter-security + + + org.springframework.security + spring-security-test + test + + + org.thymeleaf.extras + thymeleaf-extras-springsecurity6 + diff --git a/screenshots/app-preview.png b/screenshots/app-preview.png index 7401eaf..e939748 100644 Binary files a/screenshots/app-preview.png and b/screenshots/app-preview.png differ diff --git a/src/main/java/org/example/devroadmapskilltracker/config/DataInitializer.java b/src/main/java/org/example/devroadmapskilltracker/config/DataInitializer.java new file mode 100644 index 0000000..9dbf061 --- /dev/null +++ b/src/main/java/org/example/devroadmapskilltracker/config/DataInitializer.java @@ -0,0 +1,63 @@ +package org.example.devroadmapskilltracker.config; + +import org.example.devroadmapskilltracker.skill.Skill; +import org.example.devroadmapskilltracker.skill.SkillRepository; +import org.example.devroadmapskilltracker.skill.SkillStatus; +import org.example.devroadmapskilltracker.user.User; +import org.example.devroadmapskilltracker.user.UserRepository; +import org.springframework.boot.CommandLineRunner; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.slf4j.LoggerFactory; +import org.slf4j.Logger; +import org.springframework.context.annotation.Profile; +import org.springframework.security.crypto.password.PasswordEncoder; + +import java.util.List; + +@Configuration +public class DataInitializer { + + private static final Logger logger = LoggerFactory.getLogger(DataInitializer.class); + private final PasswordEncoder passwordEncoder; + + public DataInitializer(PasswordEncoder passwordEncoder) { + this.passwordEncoder = passwordEncoder; + } + + @Bean + @Profile("!prod") + CommandLineRunner initDatabase(SkillRepository skillRepository, UserRepository userRepository) { + return args -> { + + if (userRepository.count() == 0) { + logger.info("No skill found. Generating test user and skills..."); + + User testUser = new User(); + testUser.setFullName("Test Developer"); + testUser.setUsername("test"); + testUser.setPassword(passwordEncoder.encode("secret")); + + User savedUser = userRepository.save(testUser); + + skillRepository.saveAll(List.of( + // BACKLOG + new Skill("Docker", "Learn containerization and how to manage images.","DevOps", SkillStatus.BACKLOG, savedUser), + new Skill("TypeScript", "Strongly typed JavaScript for better scaling.","Frontend", SkillStatus.BACKLOG,savedUser), + + // IN PROGRESS + new Skill("Spring Boot", "Building robust backend services with Java.","Backend", SkillStatus.IN_PROGRESS,savedUser), + new Skill("Thymeleaf", "Server-side template engine for modern web apps.","Web", SkillStatus.IN_PROGRESS, savedUser), + new Skill("CSS Grid", "Mastering complex layouts with grid systems.","Design", SkillStatus.IN_PROGRESS, savedUser), + + // MASTERED + new Skill("Java Fundamentals", "Core syntax, OOP, and collections.", "Backend", SkillStatus.MASTERED, savedUser), + new Skill("REST APIs", "Designing and implementing scalable endpoints." ,"Backend", SkillStatus.MASTERED,savedUser) + + )); + + logger.info("🚀Test data has been loaded!"); + } + }; + } +} diff --git a/src/main/java/org/example/devroadmapskilltracker/config/SecurityConfig.java b/src/main/java/org/example/devroadmapskilltracker/config/SecurityConfig.java new file mode 100644 index 0000000..7b7d49d --- /dev/null +++ b/src/main/java/org/example/devroadmapskilltracker/config/SecurityConfig.java @@ -0,0 +1,62 @@ +package org.example.devroadmapskilltracker.config; + +import org.example.devroadmapskilltracker.user.UserRepository; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.security.config.annotation.web.builders.HttpSecurity; +import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity; +import org.springframework.security.core.userdetails.User; +import org.springframework.security.core.userdetails.UserDetailsService; +import org.springframework.security.core.userdetails.UsernameNotFoundException; +import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder; +import org.springframework.security.crypto.password.PasswordEncoder; +import org.springframework.security.web.SecurityFilterChain; + +@Configuration +@EnableWebSecurity +public class SecurityConfig { + + private final UserRepository userRepository; + + public SecurityConfig(UserRepository userRepository) { + this.userRepository = userRepository; + } + + @Bean + public SecurityFilterChain securityFilterChain(HttpSecurity http) { + http.csrf(csrf -> csrf.disable()) + .authorizeHttpRequests(auth -> auth + .requestMatchers("/login", "/static/**", "/css/**", "/assets/**").permitAll() + .requestMatchers("/signup","/createAccount").permitAll() + .anyRequest().authenticated()) + + .formLogin(form -> form + .loginPage("/login") + .defaultSuccessUrl("/skills") + .permitAll()) + .logout(logout -> logout + .logoutUrl("/logout") + .logoutSuccessUrl("/login?logout") + .permitAll()); + + + return http.build(); + } + + @Bean + public UserDetailsService userDetailsService() { + return username -> userRepository.findByUsername(username) + .map(user -> User + .withUsername(user.getUsername()) + .password(user.getPassword()) + .roles("USER") + .build()) + .orElseThrow(() -> new UsernameNotFoundException("User not found: " + username)); + } + + + @Bean + public PasswordEncoder passwordEncoder() { + return new BCryptPasswordEncoder(); + } +} diff --git a/src/main/java/org/example/devroadmapskilltracker/skill/DataInitializer.java b/src/main/java/org/example/devroadmapskilltracker/skill/DataInitializer.java deleted file mode 100644 index 433d66f..0000000 --- a/src/main/java/org/example/devroadmapskilltracker/skill/DataInitializer.java +++ /dev/null @@ -1,44 +0,0 @@ -package org.example.devroadmapskilltracker.skill; - -import org.springframework.boot.CommandLineRunner; -import org.springframework.context.annotation.Bean; -import org.springframework.context.annotation.Configuration; -import org.slf4j.LoggerFactory; -import org.slf4j.Logger; -import org.springframework.context.annotation.Profile; - -import java.util.List; - -@Configuration -public class DataInitializer { - - private static final Logger logger = LoggerFactory.getLogger(DataInitializer.class); - - @Bean - @Profile("!prod") - CommandLineRunner initDatabase(SkillRepository repository) { - return args -> { - - if (repository.count() == 0) { - - repository.saveAll(List.of( - // BACKLOG - new Skill("Docker", "Learn containerization and how to manage images.","DevOps", SkillStatus.BACKLOG), - new Skill("TypeScript", "Strongly typed JavaScript for better scaling.","Frontend", SkillStatus.BACKLOG), - - // IN PROGRESS - new Skill("Spring Boot", "Building robust backend services with Java.","Backend", SkillStatus.IN_PROGRESS), - new Skill("Thymeleaf", "Server-side template engine for modern web apps.","Web", SkillStatus.IN_PROGRESS), - new Skill("CSS Grid", "Mastering complex layouts with grid systems.","Design", SkillStatus.IN_PROGRESS), - - // MASTERED - new Skill("Java Fundamentals", "Core syntax, OOP, and collections.", "Backend", SkillStatus.MASTERED), - new Skill("REST APIs", "Designing and implementing scalable endpoints." ,"Backend", SkillStatus.MASTERED) - - )); - - logger.info("🚀Test data has been loaded!"); - } - }; - } -} diff --git a/src/main/java/org/example/devroadmapskilltracker/skill/Skill.java b/src/main/java/org/example/devroadmapskilltracker/skill/Skill.java index 0c37e39..7c7652c 100644 --- a/src/main/java/org/example/devroadmapskilltracker/skill/Skill.java +++ b/src/main/java/org/example/devroadmapskilltracker/skill/Skill.java @@ -2,6 +2,7 @@ import jakarta.persistence.*; import jakarta.validation.constraints.*; +import org.example.devroadmapskilltracker.user.User; import org.hibernate.validator.constraints.URL; import org.springframework.data.annotation.CreatedDate; import org.springframework.data.annotation.LastModifiedDate; @@ -22,7 +23,7 @@ public class Skill { @NotBlank(message = "A title is required") private String title; - @NotNull(message = "Status is required") @Enumerated(EnumType.STRING) // Sparar texten (ex. "BACKLOG") istället för en siffra i databasen + @NotNull(message = "Status is required") @Enumerated(EnumType.STRING) private SkillStatus status; @NotBlank(message = "A description is required") @Column(columnDefinition = "TEXT") @@ -42,16 +43,21 @@ public class Skill { @Column(name = "completed_at") private LocalDateTime completedAt; - @NotBlank(message = "A tag is required") private String tag; // Ex: "Databas", "Testning", "Ramverk" + @NotBlank(message = "A tag is required") private String tag; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "user_id", nullable = false) + private User user; public Skill() {} - public Skill(String title, String description, String tag, SkillStatus status) { + public Skill(String title, String description, String tag, SkillStatus status, User user) { this.title = title; this.description = description; this.tag = tag; this.status = status; + this.user = user; } // Constructor used for tests @@ -135,6 +141,14 @@ public void setCompletedAt(LocalDateTime completedAt) { this.completedAt = completedAt; } + public User getUser() { + return user; + } + + public void setUser(User user) { + this.user = user; + } + @Override public boolean equals(Object o) { if (!(o instanceof Skill skill)) return false; @@ -158,6 +172,7 @@ public String toString() { ", updatedAt=" + updatedAt + ", completedAt=" + completedAt + ", tag='" + tag + '\'' + + ", user=" + user + '}'; } } diff --git a/src/main/java/org/example/devroadmapskilltracker/skill/SkillRepository.java b/src/main/java/org/example/devroadmapskilltracker/skill/SkillRepository.java index 2df1489..b16ae4d 100644 --- a/src/main/java/org/example/devroadmapskilltracker/skill/SkillRepository.java +++ b/src/main/java/org/example/devroadmapskilltracker/skill/SkillRepository.java @@ -2,30 +2,30 @@ import org.springframework.data.domain.Page; import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Modifying; import org.springframework.stereotype.Repository; import org.springframework.data.domain.Pageable; +import org.springframework.transaction.annotation.Transactional; import java.util.Optional; @Repository public interface SkillRepository extends JpaRepository { - // Standardmetoden --> Finns i JpaRepository, deklarerad för tydlighet - Page findAll(Pageable pageable); + @Modifying + @Transactional + void deleteByUserId(Long userId); - boolean existsByTitle(String title); + Page findAllByUserId(Long userid,Pageable pageable); - // Kombinerad sökning --> Title + Tag - Page findByTitleContainingIgnoreCaseOrTagIgnoreCase(String title, String tag,Pageable pageable); + boolean existsByTitleAndUserId(String title, Long userId); - // Filtrera på Status - Page findByStatus(SkillStatus status, Pageable pageable); + Page findByTitleContainingIgnoreCaseAndUserId(String title, Long userId, Pageable pageable); - // Specifik tagg-sökning -> För framtida behov? - Page findByTagContainingIgnoreCase(String tag,Pageable pageable); + Page findByTitleContainingIgnoreCaseOrTagIgnoreCaseAndUserId(String title, String tag, Long userId, Pageable pageable); + + Optional findByTitleIgnoreCaseAndUserId(String title, Long userId); - Page findByTitleContainingIgnoreCase(String title, Pageable pageable); - Optional findByTitleIgnoreCase(String title); } diff --git a/src/main/java/org/example/devroadmapskilltracker/skill/service/SkillService.java b/src/main/java/org/example/devroadmapskilltracker/skill/service/SkillService.java index d2d541a..43678bb 100644 --- a/src/main/java/org/example/devroadmapskilltracker/skill/service/SkillService.java +++ b/src/main/java/org/example/devroadmapskilltracker/skill/service/SkillService.java @@ -7,8 +7,11 @@ import org.example.devroadmapskilltracker.skill.dto.SkillDTO; import org.example.devroadmapskilltracker.skill.dto.UpdateSkillDTO; import org.example.devroadmapskilltracker.skill.exception.ResourceNotFoundException; +import org.example.devroadmapskilltracker.user.User; +import org.example.devroadmapskilltracker.user.UserRepository; import org.springframework.data.domain.Page; import org.springframework.data.domain.Pageable; +import org.springframework.security.core.context.SecurityContextHolder; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; @@ -21,30 +24,36 @@ public class SkillService { private final SkillRepository skillRepository; private final SkillMapper skillMapper; + private final UserRepository userRepository; - public SkillService(SkillRepository skillRepository, SkillMapper skillMapper) { + public SkillService(SkillRepository skillRepository, SkillMapper skillMapper, UserRepository userRepository) { this.skillRepository = skillRepository; this.skillMapper = skillMapper; + this.userRepository = userRepository; } public SkillDTO getSkillById(Long id) { - return skillRepository.findById(id) - .map(skillMapper::toDTO) - .orElseThrow(() -> new ResourceNotFoundException(NOT_FOUND_MESSAGE + id)); + User currentUser = getCurrentUser(); + Skill skill = skillRepository.findById(id) + .orElseThrow(() -> new ResourceNotFoundException(NOT_FOUND_MESSAGE + id)); + + if (!skill.getUser().getId().equals(currentUser.getId())) { + throw new org.springframework.security.access.AccessDeniedException("Not authorized to view this skill"); + } + return skillMapper.toDTO(skill); } public Page getSkills(String title, String tag, Pageable pageable) { + User currentUser = getCurrentUser(); Page result; if ((title == null || title.isBlank()) && (tag == null || tag.isBlank())) { - result = skillRepository.findAll(pageable); - + result = skillRepository.findAllByUserId(currentUser.getId(), pageable); } else if (tag == null || tag.isBlank()) { - result = skillRepository.findByTitleContainingIgnoreCase(title, pageable); - + result = skillRepository.findByTitleContainingIgnoreCaseAndUserId(title, currentUser.getId(), pageable); } else { - result = skillRepository.findByTitleContainingIgnoreCaseOrTagIgnoreCase(title, tag, pageable); + result = skillRepository.findByTitleContainingIgnoreCaseOrTagIgnoreCaseAndUserId(title, tag, currentUser.getId(), pageable); } return result.map(skillMapper::toDTO); @@ -52,12 +61,14 @@ public Page getSkills(String title, String tag, Pageable pageable) { @Transactional public SkillDTO createSkill(CreateSkillDTO dto) { + User currentUser = getCurrentUser(); - if (skillRepository.existsByTitle(dto.title())) { + if (skillRepository.existsByTitleAndUserId(dto.title(), currentUser.getId())) { throw new IllegalArgumentException("A skill with title: " + dto.title() + " already exists."); } Skill skillEntity = skillMapper.toEntity(dto); + skillEntity.setUser(currentUser); if (skillEntity.getStatus() == SkillStatus.MASTERED) { skillEntity.setCompletedAt(LocalDateTime.now()); @@ -72,18 +83,22 @@ public SkillDTO createSkill(CreateSkillDTO dto) { @Transactional public SkillDTO updateSkill(Long id, UpdateSkillDTO dto) { + User currentUser = getCurrentUser(); Skill existingSkill = skillRepository.findById(id) .orElseThrow(() -> new ResourceNotFoundException(NOT_FOUND_MESSAGE + id)); - skillRepository.findByTitleIgnoreCase(dto.title()).ifPresent(foundSkill -> { + if (!existingSkill.getUser().getId().equals(currentUser.getId())) { + throw new org.springframework.security.access.AccessDeniedException("Not authorized to update this skill"); + } + + skillRepository.findByTitleIgnoreCaseAndUserId(dto.title(), currentUser.getId()).ifPresent(foundSkill -> { if (!foundSkill.getId().equals(id)) { throw new IllegalArgumentException("A skill with title: " + dto.title() + " already exists."); } }); SkillStatus oldStatus = existingSkill.getStatus(); - skillMapper.updateEntityFromDTO(dto, existingSkill); if (oldStatus != SkillStatus.MASTERED && existingSkill.getStatus() == SkillStatus.MASTERED) { @@ -94,17 +109,27 @@ else if (oldStatus == SkillStatus.MASTERED && existingSkill.getStatus() != Skill existingSkill.setCompletedAt(null); } - Skill updatedSkill = skillRepository.save(existingSkill); - - return skillMapper.toDTO(updatedSkill); + return skillMapper.toDTO(skillRepository.save(existingSkill)); } @Transactional public void deleteSkill(Long id) { + User currentUser = getCurrentUser(); Skill existingSkill = skillRepository.findById(id) .orElseThrow(() -> new ResourceNotFoundException(NOT_FOUND_MESSAGE + id)); + if (!existingSkill.getUser().getId().equals(currentUser.getId())) { + throw new org.springframework.security.access.AccessDeniedException("Not authorized to delete this skill"); + } + skillRepository.delete(existingSkill); } + + private User getCurrentUser() { + String username = SecurityContextHolder.getContext().getAuthentication().getName(); + return userRepository.findByUsername(username) + .orElseThrow(() -> new ResourceNotFoundException("User not found: " + username)); + + } } diff --git a/src/main/java/org/example/devroadmapskilltracker/user/User.java b/src/main/java/org/example/devroadmapskilltracker/user/User.java new file mode 100644 index 0000000..923952e --- /dev/null +++ b/src/main/java/org/example/devroadmapskilltracker/user/User.java @@ -0,0 +1,22 @@ +package org.example.devroadmapskilltracker.user; + +import jakarta.persistence.*; +import lombok.Getter; +import lombok.Setter; + +@Entity +@Table(name = "users") +@Getter +@Setter +public class User { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + private String fullName; + + @Column(unique = true, nullable = false) + private String username; + + String password; +} diff --git a/src/main/java/org/example/devroadmapskilltracker/user/UserMapper.java b/src/main/java/org/example/devroadmapskilltracker/user/UserMapper.java new file mode 100644 index 0000000..ff3945e --- /dev/null +++ b/src/main/java/org/example/devroadmapskilltracker/user/UserMapper.java @@ -0,0 +1,36 @@ +package org.example.devroadmapskilltracker.user; + +import org.example.devroadmapskilltracker.user.dto.CreateUserDTO; +import org.example.devroadmapskilltracker.user.dto.UpdateUserDTO; +import org.example.devroadmapskilltracker.user.dto.UserDTO; +import org.springframework.stereotype.Component; + +@Component +public class UserMapper { + + // Entity --> DTO + public UserDTO toDTO(User user) { + if (user == null) return null; + + return new UserDTO( + user.getId(), + user.getFullName(), + user.getUsername() + ); + } + + public User toEntity(CreateUserDTO dto) { + if (dto == null) return null; + + User user = new User(); + user.setFullName(dto.fullName()); + user.setUsername(dto.username()); + user.setPassword(dto.password()); + return user; + } + + public void updateEntityFromDTO(UpdateUserDTO dto, User user) { + user.setFullName(dto.fullName()); + user.setUsername(dto.username()); + } +} diff --git a/src/main/java/org/example/devroadmapskilltracker/user/UserRepository.java b/src/main/java/org/example/devroadmapskilltracker/user/UserRepository.java new file mode 100644 index 0000000..86076b8 --- /dev/null +++ b/src/main/java/org/example/devroadmapskilltracker/user/UserRepository.java @@ -0,0 +1,15 @@ +package org.example.devroadmapskilltracker.user; + +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; + +import java.util.Optional; + +@Repository +public interface UserRepository extends JpaRepository { + + Optional findByUsername(String username); + + boolean existsByUsername(String username); + +} diff --git a/src/main/java/org/example/devroadmapskilltracker/user/controller/AuthController.java b/src/main/java/org/example/devroadmapskilltracker/user/controller/AuthController.java new file mode 100644 index 0000000..f1405cc --- /dev/null +++ b/src/main/java/org/example/devroadmapskilltracker/user/controller/AuthController.java @@ -0,0 +1,52 @@ +package org.example.devroadmapskilltracker.user.controller; + +import jakarta.validation.Valid; +import org.example.devroadmapskilltracker.user.dto.CreateUserDTO; +import org.example.devroadmapskilltracker.user.service.UserService; +import org.springframework.stereotype.Controller; +import org.springframework.ui.Model; +import org.springframework.validation.BindingResult; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.ModelAttribute; +import org.springframework.web.bind.annotation.PostMapping; + +@Controller +public class AuthController { + + private final UserService userService; + + public AuthController(UserService userService) { + this.userService = userService; + } + + @GetMapping("/login") + public String loginPage() { + return "users/login"; + } + + + @GetMapping("/signup") + public String showSignupPage(Model model) { + model.addAttribute("user", new CreateUserDTO("", "", "")); + return "users/signup"; + } + + @PostMapping("/createAccount") + public String createAccount(@Valid @ModelAttribute("user") CreateUserDTO dto, BindingResult bindingResult, Model model) { + if (bindingResult.hasErrors()) { + return "users/signup"; + } + + try { + userService.createUserAccount(dto); + } catch (IllegalArgumentException e) { + bindingResult.rejectValue("username", "error.user", e.getMessage()); + return "users/signup"; + } catch (Exception e) { + bindingResult.rejectValue("username", "error.user", "An unknown error has occurred."); + return "users/signup"; + } + + return "redirect:/login"; + } +} diff --git a/src/main/java/org/example/devroadmapskilltracker/user/controller/UserController.java b/src/main/java/org/example/devroadmapskilltracker/user/controller/UserController.java new file mode 100644 index 0000000..0fb7371 --- /dev/null +++ b/src/main/java/org/example/devroadmapskilltracker/user/controller/UserController.java @@ -0,0 +1,72 @@ +package org.example.devroadmapskilltracker.user.controller; + +import jakarta.servlet.ServletException; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.validation.Valid; +import org.example.devroadmapskilltracker.user.dto.UpdateUserDTO; +import org.example.devroadmapskilltracker.user.dto.UserDTO; +import org.example.devroadmapskilltracker.user.service.UserService; +import org.springframework.stereotype.Controller; +import org.springframework.ui.Model; +import org.springframework.validation.BindingResult; +import org.springframework.web.bind.annotation.*; + +import java.security.Principal; + +@Controller +@RequestMapping("/account") +public class UserController { + + private final UserService userService; + + public UserController(UserService userService) { + this.userService = userService; + } + + @GetMapping() + public String showAccountPage(Model model, Principal principal) { + UserDTO user = userService.getLoggedInUser(principal.getName()); + + UpdateUserDTO updateUserDTO = new UpdateUserDTO( + user.id(), + user.fullName(), + user.username(), + "" + ); + + model.addAttribute("user", updateUserDTO); + return "users/account"; + } + + @PostMapping("/update") + public String updateAccount(@Valid @ModelAttribute("user")UpdateUserDTO dto, + BindingResult bindingResult, + Model model, + Principal principal) { + + if (bindingResult.hasErrors()) { + return "users/account"; + } + + try { + UserDTO currentUser = userService.getLoggedInUser(principal.getName()); + userService.updateUserAccount(currentUser.id(), dto); + } catch (Exception ex) { + bindingResult.rejectValue("username", "error.user", "Could not update account."); + return "users/account"; + } + + return "redirect:/account?success"; + } + + + @DeleteMapping("/delete") + public String deleteAccount( HttpServletRequest request, Principal principal) throws ServletException { + + UserDTO currentUser = userService.getLoggedInUser(principal.getName()); + userService.deleteUserAccount(currentUser.id()); + + request.logout(); + return "redirect:/login?deleted"; + } +} diff --git a/src/main/java/org/example/devroadmapskilltracker/user/dto/CreateUserDTO.java b/src/main/java/org/example/devroadmapskilltracker/user/dto/CreateUserDTO.java new file mode 100644 index 0000000..c2aa813 --- /dev/null +++ b/src/main/java/org/example/devroadmapskilltracker/user/dto/CreateUserDTO.java @@ -0,0 +1,10 @@ +package org.example.devroadmapskilltracker.user.dto; + +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.Size; + +public record CreateUserDTO( + @NotBlank(message = "A name is required") String fullName, + @NotBlank(message = "A username is required") String username, + @Size(min = 8, message = "Password must be at least 8 characters") String password){ +} diff --git a/src/main/java/org/example/devroadmapskilltracker/user/dto/UpdateUserDTO.java b/src/main/java/org/example/devroadmapskilltracker/user/dto/UpdateUserDTO.java new file mode 100644 index 0000000..0e0bdbb --- /dev/null +++ b/src/main/java/org/example/devroadmapskilltracker/user/dto/UpdateUserDTO.java @@ -0,0 +1,11 @@ +package org.example.devroadmapskilltracker.user.dto; + +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.Size; + +public record UpdateUserDTO( + Long id, + @NotBlank(message = "A name is required") String fullName, + @NotBlank(message = "A username is required") String username, + @Size(min = 8, message = "Password must be at least 8 characters") String password ) { +} diff --git a/src/main/java/org/example/devroadmapskilltracker/user/dto/UserDTO.java b/src/main/java/org/example/devroadmapskilltracker/user/dto/UserDTO.java new file mode 100644 index 0000000..450be67 --- /dev/null +++ b/src/main/java/org/example/devroadmapskilltracker/user/dto/UserDTO.java @@ -0,0 +1,7 @@ +package org.example.devroadmapskilltracker.user.dto; + +public record UserDTO( + Long id, + String fullName, + String username) { +} diff --git a/src/main/java/org/example/devroadmapskilltracker/user/service/UserService.java b/src/main/java/org/example/devroadmapskilltracker/user/service/UserService.java new file mode 100644 index 0000000..41d8833 --- /dev/null +++ b/src/main/java/org/example/devroadmapskilltracker/user/service/UserService.java @@ -0,0 +1,87 @@ +package org.example.devroadmapskilltracker.user.service; + +import jakarta.persistence.EntityNotFoundException; +import lombok.extern.slf4j.Slf4j; +import org.example.devroadmapskilltracker.skill.SkillRepository; +import org.example.devroadmapskilltracker.user.User; +import org.example.devroadmapskilltracker.user.UserMapper; +import org.example.devroadmapskilltracker.user.UserRepository; +import org.example.devroadmapskilltracker.user.dto.CreateUserDTO; +import org.example.devroadmapskilltracker.user.dto.UpdateUserDTO; +import org.example.devroadmapskilltracker.user.dto.UserDTO; +import org.springframework.security.crypto.password.PasswordEncoder; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +@Slf4j +public class UserService { + + private final UserRepository userRepository; + private final SkillRepository skillRepository; + private final UserMapper userMapper; + private final PasswordEncoder passwordEncoder; + + + private static final String USER_NOT_FOUND = "User not found"; + + public UserService(UserRepository userRepository, SkillRepository skillRepository, UserMapper userMapper, PasswordEncoder passwordEncoder) { + this.userRepository = userRepository; + this.skillRepository = skillRepository; + this.userMapper = userMapper; + this.passwordEncoder = passwordEncoder; + } + + @Transactional + public UserDTO createUserAccount(CreateUserDTO dto) { + + // Check to see if a username is already occupied + if (userRepository.existsByUsername(dto.username())) { + throw new IllegalArgumentException("A user with username: " + dto.username() + " already exists."); + } + + User newUser = userMapper.toEntity(dto); + newUser.setPassword(passwordEncoder.encode(dto.password())); + + User savedUser = userRepository.save(newUser); + log.info("Created user account for user with id {}", savedUser.getId()); + return userMapper.toDTO(savedUser); + } + + @Transactional + public UserDTO updateUserAccount(Long id, UpdateUserDTO dto) { + + User existingUser = userRepository.findById(id) + .orElseThrow(() -> new EntityNotFoundException(USER_NOT_FOUND)); + + if (!existingUser.getUsername().equals(dto.username()) && userRepository.existsByUsername(dto.username())) { + throw new IllegalArgumentException("A user with username: " + dto.username() + " already exists."); + } + + userMapper.updateEntityFromDTO(dto, existingUser); + + if (dto.password() != null && !dto.password().isBlank()) { + existingUser.setPassword(passwordEncoder.encode(dto.password())); + } + + User updatedUser = userRepository.save(existingUser); + return userMapper.toDTO(updatedUser); + } + + @Transactional + public void deleteUserAccount(Long userId) { + User existingUser = userRepository.findById(userId) + .orElseThrow(() -> new EntityNotFoundException(USER_NOT_FOUND)); + + skillRepository.deleteByUserId(userId); + + userRepository.delete(existingUser); + log.info("Deleted user with id {} and all their associated skills", userId); + } + + public UserDTO getLoggedInUser(String username) { + User user = userRepository.findByUsername(username) + .orElseThrow(() -> new EntityNotFoundException(USER_NOT_FOUND + username)); + return userMapper.toDTO(user); + } +} diff --git a/src/main/resources/application.properties b/src/main/resources/application.properties index d7803d8..2e51c9b 100644 --- a/src/main/resources/application.properties +++ b/src/main/resources/application.properties @@ -12,3 +12,5 @@ spring.jpa.show-sql=true spring.jpa.properties.hibernate.format_sql=true spring.jpa.database-platform=org.hibernate.dialect.PostgreSQLDialect spring.flyway.enabled=false + +logging.level.org.springframework.security=debug diff --git a/src/main/resources/static/css/assets/close-icon.svg b/src/main/resources/static/assets/close-icon.svg similarity index 100% rename from src/main/resources/static/css/assets/close-icon.svg rename to src/main/resources/static/assets/close-icon.svg diff --git a/src/main/resources/static/css/assets/load-icon.svg b/src/main/resources/static/assets/load-icon.svg similarity index 100% rename from src/main/resources/static/css/assets/load-icon.svg rename to src/main/resources/static/assets/load-icon.svg diff --git a/src/main/resources/static/css/assets/rocket-icon.svg b/src/main/resources/static/assets/rocket-icon.svg similarity index 100% rename from src/main/resources/static/css/assets/rocket-icon.svg rename to src/main/resources/static/assets/rocket-icon.svg diff --git a/src/main/resources/static/css/assets/search-icon.svg b/src/main/resources/static/assets/search-icon.svg similarity index 100% rename from src/main/resources/static/css/assets/search-icon.svg rename to src/main/resources/static/assets/search-icon.svg diff --git a/src/main/resources/static/css/create-update.css b/src/main/resources/static/css/create-update.css index 6ba7095..109090d 100644 --- a/src/main/resources/static/css/create-update.css +++ b/src/main/resources/static/css/create-update.css @@ -16,7 +16,7 @@ body { padding: 2rem; } -/* Container för hela kortet */ + .form-card { background: rgba(10, 20, 25, 0.8); border: 1px solid rgba(96, 214, 250, 0.2); @@ -41,11 +41,11 @@ body { margin: 0 0 0.5rem 0; letter-spacing: 0.01em; font-weight: 700; - color: rgba(96, 214, 250, 0.5); + color: #e6f7ff; } .form-header p { - color: rgba(96, 214, 250, 0.5); + color: rgba(180, 230, 250, 0.6); font-size: 1rem; margin: 0; } diff --git a/src/main/resources/static/css/error.css b/src/main/resources/static/css/error.css index f38f6a6..e011613 100644 --- a/src/main/resources/static/css/error.css +++ b/src/main/resources/static/css/error.css @@ -17,11 +17,11 @@ body { } .error-card { - background: rgba(17, 25, 28, 0.8); + background: rgba(10, 20, 25, 0.8); border: 1px solid rgba(96, 214, 250, 0.2); border-radius: 28px; padding: 2.5rem; - max-width: 550px; + max-width: 500px; width: 100%; margin-top: -50px; box-shadow: 0 12px 25px -12px rgba(0, 0, 0, 0.5); @@ -36,14 +36,40 @@ body { .error-header h1 { font-size: 2.2rem; - margin: 0 0 0.5rem 0; letter-spacing: 0.01em; font-weight: 700; - color: rgba(96, 214, 250, 0.5); + color: #e6f7ff; + margin-top: -10px; + margin-bottom: 20px; } .error-header p { color: rgba(96, 214, 250, 0.5); font-size: 1rem; - margin: 0; +} + +.btn-back { + display: inline-block; + margin-top: 0.5rem; + backdrop-filter: blur(10px); + border: 1px solid rgba(96, 214, 250, 0.12); + color: rgba(96, 214, 250, 0.55); + padding: 12px 20px; + border-radius: 12px; + cursor: pointer; + font-size: 0.90rem; + font-family: inherit; + text-decoration: none; + align-items: center; + justify-content: center; + transition: all 0.2s ease; + margin-bottom: -20px; +} + +.btn-back:hover { + background: rgba(96, 214, 250, 0.1) !important; + border-color: rgba(96, 214, 250, 0.6) !important; + color: rgb(150, 230, 255); + text-shadow: 0 0 8px rgba(96, 214, 250, 0.6); + opacity: 0.8; } diff --git a/src/main/resources/static/css/home.css b/src/main/resources/static/css/home.css index dcfeeaf..fedfc57 100644 --- a/src/main/resources/static/css/home.css +++ b/src/main/resources/static/css/home.css @@ -10,6 +10,49 @@ body { box-sizing: border-box; } +.nav-container { + position: absolute; + top: 24px; + right: 24px; + display: flex; + align-items: center; + gap: 12px; + z-index: 100; +} + + +.nav-link, .logout-form button { + background-color: rgba(10, 20, 25, 0.8) !important; + backdrop-filter: blur(10px); + border: 1px solid rgba(96, 214, 250, 0.12) !important; + + color: rgba(96, 214, 250, 0.55); + padding: 12px 20px; + border-radius: 12px; + cursor: pointer; + font-size: 0.90rem; + font-family: inherit; + text-decoration: none; + display: flex; + align-items: center; + gap: 8px; + transition: all 0.2s ease; +} + +.logout-form { + margin: 0; +} + + +.nav-link:hover, .logout-form button:hover { + background: rgba(96, 214, 250, 0.1) !important; + border-color: rgba(96, 214, 250, 0.6) !important; + color: rgb(150, 230, 255); + text-shadow: 0 0 8px rgba(96, 214, 250, 0.6); + opacity: 0.9; +} + + header { display: flex; flex-direction: column; @@ -27,6 +70,25 @@ header h1 { opacity: 0.95; } +.title-with-icon { + display: flex; + align-items: center; + justify-content: center; + gap: 12px; +} + +.header-rocket-icon { + display:inline-block; + width: 36px; + height: 36px; + background-color: rgba(96, 214, 250, 0.8); + opacity: 0.95; + flex-shrink: 0; + mask: url('../assets/rocket-icon.svg') no-repeat center; + -webkit-mask: url('../assets/rocket-icon.svg') no-repeat center; + mask-size: contain; +} + .tagline{ color: rgba(180, 230, 250, 0.7); font-size: 1.1rem; @@ -73,8 +135,6 @@ header h1 { width: 100%; outline: none; padding-left: 20px; - - } .search-input input::placeholder { @@ -89,8 +149,8 @@ header h1 { width: 17px; height: 17px; background-color: rgba(96, 214, 250, 0.6); - mask: url('../css/assets/search-icon.svg') no-repeat center; - -webkit-mask: url('../css/assets/search-icon.svg') no-repeat center; + mask: url('../assets/search-icon.svg') no-repeat center; + -webkit-mask: url('../assets/search-icon.svg') no-repeat center; mask-size: contain; filter: none; pointer-events: none; @@ -103,7 +163,6 @@ header h1 { background-color: rgba(96, 214, 250, 0.9); } - .btn-add-skill{ margin-top: 0; display: inline-flex; @@ -121,7 +180,6 @@ header h1 { white-space: nowrap; transition: all 0.3s ease; letter-spacing: 0.01em; - } .btn-add-skill:hover { @@ -130,7 +188,6 @@ header h1 { color: rgb(150, 230, 255); text-shadow: 0 0 8px rgba(96, 214, 250, 0.6); opacity: 1; - } .roadmap-container { @@ -212,8 +269,8 @@ header h1 { width: 18px; height: 18px; background-color: rgba(255, 255, 255, 0.2); - mask: url('../css/assets/close-icon.svg') no-repeat center; - -webkit-mask: url('../css/assets/close-icon.svg') no-repeat center; + mask: url('../assets/close-icon.svg') no-repeat center; + -webkit-mask: url('../assets/close-icon.svg') no-repeat center; mask-size: contain; border: none; cursor: pointer; @@ -247,8 +304,6 @@ header h1 { color: rgb(150, 230, 255); text-shadow: 0 0 8px rgba(96, 214, 250, 0.5); opacity: 0.8; - - } .skill-card h3 { @@ -325,7 +380,7 @@ header h1 { border: 1px solid rgba(96, 214, 250, 0.12) !important; color: rgba(96, 214, 250, 0.55); font-size: 0.95rem; - padding: 12px 35px; + padding: 12px 30px; border-radius: 12px; text-decoration: none; transition: all 0.3s ease; @@ -339,5 +394,4 @@ header h1 { color: rgb(150, 230, 255); text-shadow: 0 0 8px rgba(96, 214, 250, 0.6); opacity: 1; - } diff --git a/src/main/resources/static/css/login-signup.css b/src/main/resources/static/css/login-signup.css new file mode 100644 index 0000000..215e73f --- /dev/null +++ b/src/main/resources/static/css/login-signup.css @@ -0,0 +1,188 @@ +:root { + --color-bg: #060b0d; + --color-surface: rgba(10, 20, 25, 0.8); + --color-input-bg: rgba(255, 255, 255, 0.04); + --color-border: rgba(96, 214, 250, 0.12); + --color-border-focus: rgba(96, 214, 250, 0.6); + --color-text-primary: #e6f7ff; + --color-text-secondary: rgba(180, 230, 250, 0.7); + --color-text-muted: rgba(180, 230, 250, 0.5); + --color-text-dim: rgba(96, 214, 250, 0.4); + + --accent-color: rgba(96, 214, 250, 0.8); + --color-accent-hover: rgb(150, 230, 255); + --color-accent-on: #060b0d; + + --color-alert-error-bg: rgba(239, 83, 80, 0.06); + --color-alert-error-border:rgba(239, 83, 80, 0.3); + --color-alert-error-fg: #ff8a80; + + --color-alert-success-bg: rgba(102, 187, 106, 0.06); + --color-alert-success-border:rgba(102, 187, 106, 0.3); + --color-alert-success-fg: #b9f6ca; + + --font-system: sans-serif; + --radius-sm: 12px; + --radius-md: 28px; +} + +body.auth-body { + display: flex; + justify-content: center; + align-items: center; + min-height: 100vh; + background-color: var(--color-bg); + color: var(--color-text-primary); + font-family: var(--font-system); + margin: 0; +} + +.container { + background-color: var(--color-surface); + backdrop-filter: blur(10px); + padding: 40px; + border-radius: var(--radius-md); + border: 1px solid var(--color-border); + width: 100%; + max-width: 420px; + box-shadow: 0 12px 25px -12px rgba(0, 0, 0, 0.5); +} + +header { + text-align: center; + margin-bottom: 30px; +} + +header p { color: var(--color-text-dim); font-size: 14px; margin-top: -10px; } + +.form-group { margin-bottom: 25px; } + +.form-group label { + display: block; + margin-bottom: 8px; + font-size: 0.9rem; + text-transform: uppercase; + letter-spacing: 0.05em; + color: var(--accent-color); +} + +.form-group input { + width: 100%; + padding: 12px 16px; + border-radius: var(--radius-sm); + border: 1px solid var(--color-border); + background: var(--color-input-bg); + color: var(--color-text-primary); + box-sizing: border-box; + font-size: 14px; + transition: all 0.3s ease; + outline: none; + margin-bottom: 4px; +} + +.form-group input:focus { + border-color: var(--color-border-focus) !important; + box-shadow: 0 0 10px rgba(96, 214, 250, 0.15); +} + +.btn-submit { + width: 100%; + padding: 14px; + background-color: var(--accent-color); + color: var(--color-accent-on); + border: 1px solid transparent; + border-radius: var(--radius-sm); + font-weight: bold; + cursor: pointer; + font-size: 0.95rem; + letter-spacing: 0.02em; + transition: all 0.2s ease; +} + +.btn-submit:hover { + background-color: rgba(96, 214, 250, 0.1); + border-color: var(--color-border-focus); + color: var(--color-accent-hover); + text-shadow: 0 0 8px rgba(96, 214, 250, 0.6); +} + +.alert-error { + background: var(--color-alert-error-bg); + border: 1px solid var(--color-alert-error-border); + color: var(--color-alert-error-fg); + padding: 14px 18px; + border-radius: var(--radius-sm); + margin-bottom: 24px; + backdrop-filter: blur(6px); + box-shadow: 0 0 15px rgba(239, 83, 80, 0.05); + font-size: 0.9rem; + letter-spacing: 0.01em; +} + +.alert-success { + background: var(--color-alert-success-bg); + border: 1px solid var(--color-alert-success-border); + color: var(--color-alert-success-fg); + padding: 14px 18px; + border-radius: var(--radius-sm); + margin-bottom: 24px; + backdrop-filter: blur(6px); + box-shadow: 0 0 15px rgba(102, 187, 106, 0.05); + font-size: 0.9rem; + letter-spacing: 0.01em; +} + +.divider { + text-align: center; + margin-top: 20px; + color: var(--color-text-muted); + font-size: 14px; +} + +.signup-link { + text-align: center; + font-size: 14px; + margin-top: 20px; + color: var(--color-text-muted); +} + +.signup-link a { + color: var(--accent-color); + text-decoration: none; + transition: color 0.2s ease; +} + +.signup-link a:hover { + color: var(--color-accent-hover); + text-decoration: underline; +} + +.rocket-icon { + display: block; + width: 30px; + height: 30px; + margin: 25px auto -10px auto; + background-color: var(--accent-color); + opacity: 0.9; + mask: url('../assets/rocket-icon.svg') no-repeat center; + -webkit-mask: url('../assets/rocket-icon.svg') no-repeat center; + mask-size: contain; + filter: none; + pointer-events: none; + z-index: 2; +} + +.btn-submit.btn-delete-action { + background: rgba(229, 115, 115, 0.05); + color: rgba(229, 115, 115, 0.6); + border: 1px solid rgba(229, 115, 115, 0.2); +} + + +.btn-submit.btn-delete-action:hover { + background: rgba(229, 115, 115, 0.15); + color: #e57373; + border-color: #e57373; + box-shadow: 0 0 12px rgba(229, 115, 115, 0.2); + text-shadow: 0 0 8px rgba(229, 115, 115, 0.4); +} diff --git a/src/main/resources/templates/skills/error.html b/src/main/resources/templates/skills/error.html index 84f5281..11cf90f 100644 --- a/src/main/resources/templates/skills/error.html +++ b/src/main/resources/templates/skills/error.html @@ -12,12 +12,12 @@
-
-
Oops!
+
+

Oops!

Something went wrong

An unexpected error occurred...

- Back to RoadMap + Go Back To RoadMap
diff --git a/src/main/resources/templates/skills/home.html b/src/main/resources/templates/skills/home.html index 079342c..275b2ba 100644 --- a/src/main/resources/templates/skills/home.html +++ b/src/main/resources/templates/skills/home.html @@ -3,14 +3,30 @@ DevRoadmap | Home - - + + +
+ +
-

🚀 💻 The Dev RoadMap

+

+ + The Dev RoadMap +

+

A visual tool to track and deepen programming knowledge as a developer.

diff --git a/src/main/resources/templates/skills/update.html b/src/main/resources/templates/skills/update.html index 1943bcb..d55e513 100644 --- a/src/main/resources/templates/skills/update.html +++ b/src/main/resources/templates/skills/update.html @@ -2,7 +2,7 @@ - DevRoadmap | Add New Skill + DevRoadmap | Update Skill diff --git a/src/main/resources/templates/users/account.html b/src/main/resources/templates/users/account.html new file mode 100644 index 0000000..c21c841 --- /dev/null +++ b/src/main/resources/templates/users/account.html @@ -0,0 +1,71 @@ + + + + + DevRoadmap | My Account + + + + + +
+
+ +
+
+

My Account

+

Manage your profile settings

+
+ +
+ Your profile has been updated successfully! +
+ +
+ +
+ + +
+
+ +
+ + +
+
+ +
+ + +
+
+ + +
+ +
+ +
+

+ Warning: This action cannot be undone. +

+ +
+ + +
+
+ + + + +
+ + + diff --git a/src/main/resources/templates/users/login.html b/src/main/resources/templates/users/login.html new file mode 100644 index 0000000..5173e72 --- /dev/null +++ b/src/main/resources/templates/users/login.html @@ -0,0 +1,53 @@ + + + + + DevRoadmap | Sign In + + + + + + +
+ +
+ +
+
+

Welcome Back!

+

Pick up right where you left off.

+ +
+ +
+ Invalid username or password. +
+ +
+ You have been signed out. +
+ +
+
+ + +
+ +
+ + +
+ + + + +
+ + +
+ + + diff --git a/src/main/resources/templates/users/signup.html b/src/main/resources/templates/users/signup.html new file mode 100644 index 0000000..28c8104 --- /dev/null +++ b/src/main/resources/templates/users/signup.html @@ -0,0 +1,54 @@ + + + + + DevRoadmap | Sign Up + + + + + + +
+ +
+ +
+
+

Create an account

+

Your journey starts here.

+ +
+ +
+
+ + +
+
+ +
+ + +
+
+ +
+ + +
+
+ + + + +
+ + + +
+ + + diff --git a/src/test/java/org/example/devroadmapskilltracker/skill/SkillServiceIntegrationTest.java b/src/test/java/org/example/devroadmapskilltracker/skill/SkillServiceIntegrationTest.java index 8f9410d..7b38831 100644 --- a/src/test/java/org/example/devroadmapskilltracker/skill/SkillServiceIntegrationTest.java +++ b/src/test/java/org/example/devroadmapskilltracker/skill/SkillServiceIntegrationTest.java @@ -5,12 +5,15 @@ import org.example.devroadmapskilltracker.skill.dto.UpdateSkillDTO; import org.example.devroadmapskilltracker.skill.exception.ResourceNotFoundException; import org.example.devroadmapskilltracker.skill.service.SkillService; +import org.example.devroadmapskilltracker.user.User; +import org.example.devroadmapskilltracker.user.UserRepository; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.test.context.SpringBootTest; import org.springframework.data.domain.Page; import org.springframework.data.domain.Pageable; +import org.springframework.security.test.context.support.WithMockUser; import org.springframework.test.context.ActiveProfiles; import org.springframework.transaction.annotation.Transactional; @@ -22,16 +25,26 @@ @SpringBootTest @Transactional @ActiveProfiles("test") +@WithMockUser(username = "testuser", roles = "USER") class SkillServiceIntegrationTest { @Autowired private SkillService skillService; @Autowired private SkillRepository skillRepository; + @Autowired + private UserRepository userRepository; @BeforeEach void setUp() { skillRepository.deleteAll(); + userRepository.deleteAll(); + + User mockUser = new User(); + mockUser.setUsername("testuser"); + mockUser.setFullName("Test User"); + mockUser.setPassword("password"); + userRepository.save(mockUser); } @Test