diff --git a/src/backend/src/main/java/team/projectpulse/security/PasswordEncoderConfig.java b/src/backend/src/main/java/team/projectpulse/security/PasswordEncoderConfig.java new file mode 100644 index 0000000..2468eb8 --- /dev/null +++ b/src/backend/src/main/java/team/projectpulse/security/PasswordEncoderConfig.java @@ -0,0 +1,15 @@ +package team.projectpulse.security; + +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder; +import org.springframework.security.crypto.password.PasswordEncoder; + +@Configuration +public class PasswordEncoderConfig { + + @Bean + public PasswordEncoder passwordEncoder() { + return new BCryptPasswordEncoder(); + } +} diff --git a/src/backend/src/main/java/team/projectpulse/security/SecurityConfig.java b/src/backend/src/main/java/team/projectpulse/security/SecurityConfig.java index ef5556a..2b191b5 100644 --- a/src/backend/src/main/java/team/projectpulse/security/SecurityConfig.java +++ b/src/backend/src/main/java/team/projectpulse/security/SecurityConfig.java @@ -13,9 +13,7 @@ import org.springframework.security.config.annotation.web.configurers.AbstractHttpConfigurer; 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.AuthenticationEntryPoint; import org.springframework.security.web.SecurityFilterChain; import org.springframework.security.web.authentication.HttpStatusEntryPoint; import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter; @@ -31,10 +29,12 @@ public class SecurityConfig { private final UserDetailsService userDetailsService; private final JwtAuthFilter jwtAuthFilter; + private final PasswordEncoder passwordEncoder; - public SecurityConfig(UserDetailsService userDetailsService, JwtAuthFilter jwtAuthFilter) { + public SecurityConfig(UserDetailsService userDetailsService, JwtAuthFilter jwtAuthFilter, PasswordEncoder passwordEncoder) { this.userDetailsService = userDetailsService; this.jwtAuthFilter = jwtAuthFilter; + this.passwordEncoder = passwordEncoder; } @Bean @@ -45,6 +45,7 @@ public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { .sessionManagement(s -> s.sessionCreationPolicy(SessionCreationPolicy.STATELESS)) .authorizeHttpRequests(auth -> auth .requestMatchers(HttpMethod.POST, "/api/v1/users/login").permitAll() + .requestMatchers(HttpMethod.POST, "/api/v1/users/register").permitAll() .requestMatchers(HttpMethod.POST, "/api/v1/users/forget-password/**").permitAll() .requestMatchers(HttpMethod.PATCH, "/api/v1/users/reset-password").permitAll() .requestMatchers("/actuator/**").permitAll() @@ -73,7 +74,7 @@ public CorsConfigurationSource corsConfigurationSource() { @Bean public AuthenticationProvider authenticationProvider() { DaoAuthenticationProvider provider = new DaoAuthenticationProvider(userDetailsService); - provider.setPasswordEncoder(passwordEncoder()); + provider.setPasswordEncoder(passwordEncoder); return provider; } @@ -81,9 +82,4 @@ public AuthenticationProvider authenticationProvider() { public AuthenticationManager authenticationManager(AuthenticationConfiguration config) throws Exception { return config.getAuthenticationManager(); } - - @Bean - public PasswordEncoder passwordEncoder() { - return new BCryptPasswordEncoder(); - } } diff --git a/src/backend/src/main/java/team/projectpulse/user/RegisterRequest.java b/src/backend/src/main/java/team/projectpulse/user/RegisterRequest.java new file mode 100644 index 0000000..f40e27e --- /dev/null +++ b/src/backend/src/main/java/team/projectpulse/user/RegisterRequest.java @@ -0,0 +1,3 @@ +package team.projectpulse.user; + +public record RegisterRequest(String firstName, String lastName, String email, String password) {} diff --git a/src/backend/src/main/java/team/projectpulse/user/UserAlreadyExistsException.java b/src/backend/src/main/java/team/projectpulse/user/UserAlreadyExistsException.java new file mode 100644 index 0000000..a6492d8 --- /dev/null +++ b/src/backend/src/main/java/team/projectpulse/user/UserAlreadyExistsException.java @@ -0,0 +1,7 @@ +package team.projectpulse.user; + +public class UserAlreadyExistsException extends RuntimeException { + public UserAlreadyExistsException(String email) { + super("An account with the email \"" + email + "\" already exists."); + } +} diff --git a/src/backend/src/main/java/team/projectpulse/user/UserController.java b/src/backend/src/main/java/team/projectpulse/user/UserController.java index f640824..e43a3f5 100644 --- a/src/backend/src/main/java/team/projectpulse/user/UserController.java +++ b/src/backend/src/main/java/team/projectpulse/user/UserController.java @@ -18,10 +18,12 @@ public class UserController { private final AuthenticationManager authenticationManager; private final JwtUtils jwtUtils; + private final UserService userService; - public UserController(AuthenticationManager authenticationManager, JwtUtils jwtUtils) { + public UserController(AuthenticationManager authenticationManager, JwtUtils jwtUtils, UserService userService) { this.authenticationManager = authenticationManager; this.jwtUtils = jwtUtils; + this.userService = userService; } @PostMapping("/login") @@ -47,4 +49,14 @@ public Result> login(HttpServletRequest request) { String token = jwtUtils.generateToken(user); return Result.success("Login successful.", Map.of("token", token, "userInfo", user)); } + + /** + * UC-25: Student sets up a student account via the invitation link. + * POST /api/v1/users/register + */ + @PostMapping("/register") + public Result register(@RequestBody RegisterRequest request) { + userService.registerStudent(request); + return Result.success("Account created. Please log in.", null); + } } diff --git a/src/backend/src/main/java/team/projectpulse/user/UserExceptionHandler.java b/src/backend/src/main/java/team/projectpulse/user/UserExceptionHandler.java new file mode 100644 index 0000000..435883f --- /dev/null +++ b/src/backend/src/main/java/team/projectpulse/user/UserExceptionHandler.java @@ -0,0 +1,17 @@ +package team.projectpulse.user; + +import org.springframework.http.HttpStatus; +import org.springframework.web.bind.annotation.ExceptionHandler; +import org.springframework.web.bind.annotation.ResponseStatus; +import org.springframework.web.bind.annotation.RestControllerAdvice; +import team.projectpulse.common.Result; + +@RestControllerAdvice +public class UserExceptionHandler { + + @ExceptionHandler(UserAlreadyExistsException.class) + @ResponseStatus(HttpStatus.CONFLICT) + Result handleAlreadyExists(UserAlreadyExistsException ex) { + return Result.conflict(ex.getMessage()); + } +} diff --git a/src/backend/src/main/java/team/projectpulse/user/UserRepository.java b/src/backend/src/main/java/team/projectpulse/user/UserRepository.java index 10f8156..cfe921d 100644 --- a/src/backend/src/main/java/team/projectpulse/user/UserRepository.java +++ b/src/backend/src/main/java/team/projectpulse/user/UserRepository.java @@ -6,4 +6,5 @@ public interface UserRepository extends JpaRepository { Optional findByEmail(String email); + boolean existsByEmailIgnoreCase(String email); } diff --git a/src/backend/src/main/java/team/projectpulse/user/UserService.java b/src/backend/src/main/java/team/projectpulse/user/UserService.java index 2896b22..1b31040 100644 --- a/src/backend/src/main/java/team/projectpulse/user/UserService.java +++ b/src/backend/src/main/java/team/projectpulse/user/UserService.java @@ -3,15 +3,18 @@ import org.springframework.security.core.userdetails.UserDetails; import org.springframework.security.core.userdetails.UserDetailsService; import org.springframework.security.core.userdetails.UsernameNotFoundException; +import org.springframework.security.crypto.password.PasswordEncoder; import org.springframework.stereotype.Service; @Service public class UserService implements UserDetailsService { private final UserRepository userRepository; + private final PasswordEncoder passwordEncoder; - public UserService(UserRepository userRepository) { + public UserService(UserRepository userRepository, PasswordEncoder passwordEncoder) { this.userRepository = userRepository; + this.passwordEncoder = passwordEncoder; } @Override @@ -19,4 +22,22 @@ public UserDetails loadUserByUsername(String email) throws UsernameNotFoundExcep return userRepository.findByEmail(email) .orElseThrow(() -> new UsernameNotFoundException("No account found for: " + email)); } + + /** + * UC-25: Register a new student account. + * Throws UserAlreadyExistsException if the email is already registered. + */ + public User registerStudent(RegisterRequest req) { + if (userRepository.existsByEmailIgnoreCase(req.email().trim())) { + throw new UserAlreadyExistsException(req.email().trim()); + } + User user = new User(); + user.setFirstName(req.firstName().trim()); + user.setLastName(req.lastName().trim()); + user.setEmail(req.email().trim().toLowerCase()); + user.setPassword(passwordEncoder.encode(req.password())); + user.setRoles("student"); + user.setEnabled(true); + return userRepository.save(user); + } } diff --git a/src/frontend/src/apis/user/index.ts b/src/frontend/src/apis/user/index.ts index fa33933..7ec1b16 100644 --- a/src/frontend/src/apis/user/index.ts +++ b/src/frontend/src/apis/user/index.ts @@ -1,7 +1,9 @@ import request from '@/utils/request' -import type { ChangePasswordRequest, ChangePasswordResponse } from './types' +import type { RegisterRequest, RegisterResponse, ChangePasswordRequest, ChangePasswordResponse } from './types' enum API { USERS = '/users' } +export const registerUser = (payload: RegisterRequest) => + request.post(`${API.USERS}/register`, payload) export const changePassword = (payload: ChangePasswordRequest) => request.patch(`${API.USERS}/change-password`, payload) diff --git a/src/frontend/src/apis/user/types.ts b/src/frontend/src/apis/user/types.ts index 5b1e361..ac9e6d9 100644 --- a/src/frontend/src/apis/user/types.ts +++ b/src/frontend/src/apis/user/types.ts @@ -1,3 +1,16 @@ +export interface RegisterRequest { + firstName: string + lastName: string + email: string + password: string +} + +export interface RegisterResponse { + flag: boolean + code: number + message: string +} + export interface ChangePasswordRequest { currentPassword: string newPassword: string diff --git a/src/frontend/src/pages/UserRegistration.vue b/src/frontend/src/pages/UserRegistration.vue index d7a0606..11d2ae8 100644 --- a/src/frontend/src/pages/UserRegistration.vue +++ b/src/frontend/src/pages/UserRegistration.vue @@ -71,6 +71,7 @@ import { ref, onMounted } from 'vue' import { useRoute, useRouter } from 'vue-router' import { useNotifyStore } from '@/stores/notify' +import { registerUser } from '@/apis/user' const route = useRoute() const router = useRouter() @@ -90,7 +91,13 @@ const formData = ref({ onMounted(() => { const email = route.query.email as string + // Extension 2a: if already registered, redirect to login + const alreadyRegistered = route.query.registered as string if (email) formData.value.email = email + if (alreadyRegistered === 'true') { + notifyStore.info('You already have an account. Please log in.') + router.push({ name: 'login' }) + } }) async function register() { @@ -98,11 +105,21 @@ async function register() { if (!valid) return submitting.value = true try { - // UC-25 / UC-30: registration API call goes here + await registerUser({ + firstName: formData.value.firstName, + lastName: formData.value.lastName, + email: formData.value.email, + password: formData.value.password + }) notifyStore.success('Account created! Please log in.') router.push({ name: 'login' }) - } catch { - // handled by interceptor + } catch (err: any) { + // Extension 2a: email already registered (409) + if (err?.response?.status === 409) { + notifyStore.warning('An account with this email already exists. Redirecting to login.') + router.push({ name: 'login' }) + } + // other errors handled by interceptor } finally { submitting.value = false }