diff --git a/backend/src/main/java/online/wesal/wesal/config/SecurityConfig.java b/backend/src/main/java/online/wesal/wesal/config/SecurityConfig.java index 7ccb541..5fd7df9 100644 --- a/backend/src/main/java/online/wesal/wesal/config/SecurityConfig.java +++ b/backend/src/main/java/online/wesal/wesal/config/SecurityConfig.java @@ -55,7 +55,7 @@ public class SecurityConfig { .csrf(csrf -> csrf.disable()) .sessionManagement(session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS)) .authorizeHttpRequests(auth -> auth - .requestMatchers("/", "/login", "/swagger-ui/**", "/v3/api-docs/**", "/docs/**", "/docs").permitAll() + .requestMatchers("/", "/login", "/checkInvitation", "/register", "/swagger-ui/**", "/v3/api-docs/**", "/docs/**", "/docs").permitAll() .requestMatchers("/admin/**").hasRole("ADMIN") .anyRequest().authenticated() ) diff --git a/backend/src/main/java/online/wesal/wesal/controller/AuthController.java b/backend/src/main/java/online/wesal/wesal/controller/AuthController.java index 071fc4b..ee7d983 100644 --- a/backend/src/main/java/online/wesal/wesal/controller/AuthController.java +++ b/backend/src/main/java/online/wesal/wesal/controller/AuthController.java @@ -8,9 +8,15 @@ import online.wesal.wesal.dto.LoginRequest; import online.wesal.wesal.dto.LoginResponse; import online.wesal.wesal.dto.UpdateUserRequest; import online.wesal.wesal.dto.UsernameSelectionRequest; +import online.wesal.wesal.dto.CheckInvitationRequest; +import online.wesal.wesal.dto.RegisterRequest; +import online.wesal.wesal.dto.RegisterResponse; import online.wesal.wesal.entity.User; +import online.wesal.wesal.entity.InvitationCode; import online.wesal.wesal.service.AuthService; import online.wesal.wesal.service.UserService; +import online.wesal.wesal.service.InvitationCodeService; +import online.wesal.wesal.config.JwtUtil; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.*; @@ -29,6 +35,12 @@ public class AuthController { @Autowired private UserService userService; + @Autowired + private InvitationCodeService invitationCodeService; + + @Autowired + private JwtUtil jwtUtil; + @GetMapping("/") @Operation(summary = "Health check", description = "Returns API status") public ResponseEntity> healthCheck() { @@ -36,10 +48,10 @@ public class AuthController { } @PostMapping("/login") - @Operation(summary = "User login", description = "Authenticate user with email or username and return JWT token") + @Operation(summary = "User login", description = "Authenticate user with phone number or username and return JWT token") public ResponseEntity login(@Valid @RequestBody LoginRequest loginRequest) { try { - String token = authService.authenticate(loginRequest.getEmailOrUsername(), loginRequest.getPassword()); + String token = authService.authenticate(loginRequest.getPhoneNumberOrUsername(), loginRequest.getPassword()); return ResponseEntity.ok(new LoginResponse(token)); } catch (RuntimeException e) { return ResponseEntity.badRequest().build(); @@ -84,7 +96,7 @@ public class AuthController { // Create user data without password Map userData = Map.of( "id", user.getId(), - "email", user.getEmail(), + "phoneNumber", user.getPhoneNumber(), "username", user.getUsername() != null ? user.getUsername() : "", "displayName", user.getDisplayName(), "avatar", user.getAvatar() != null ? user.getAvatar() : "", @@ -107,14 +119,105 @@ public class AuthController { } } + @PostMapping("/checkInvitation") + @Operation(summary = "Check invitation code", description = "Validates if an invitation code is valid and not expired") + public ResponseEntity> checkInvitation(@Valid @RequestBody CheckInvitationRequest request) { + try { + boolean isValid = invitationCodeService.isValidCode(request.getCode()); + if (isValid) { + return ResponseEntity.ok(Map.of( + "status", true, + "message", "Invitation code is valid", + "data", Map.of("code", request.getCode(), "valid", true) + )); + } else { + return ResponseEntity.ok(Map.of( + "status", false, + "message", "Invalid or expired invitation code", + "data", Map.of("code", request.getCode(), "valid", false) + )); + } + } catch (Exception e) { + return ResponseEntity.ok(Map.of( + "status", false, + "message", "Error validating invitation code" + )); + } + } + + @PostMapping("/register") + @Operation(summary = "Register new user", description = "Register a new user with invitation code, phone number, and password") + public ResponseEntity> register(@Valid @RequestBody RegisterRequest request) { + try { + User user = invitationCodeService.registerUserWithCode( + request.getCode(), + request.getPhoneNumber(), + request.getPassword() + ); + + // Auto-login: generate JWT token + String token = jwtUtil.generateToken(user.getPhoneNumber()); + + RegisterResponse registerResponse = new RegisterResponse(user, token); + + return ResponseEntity.ok(Map.of( + "status", true, + "message", "User registered successfully", + "data", registerResponse + )); + + } catch (RuntimeException e) { + String message; + if (e.getMessage().contains("already exists")) { + message = "A user with this phone number already exists"; + } else if (e.getMessage().contains("Invalid or expired")) { + message = "Invalid or expired invitation code"; + } else { + message = "Registration failed. Please try again"; + } + + return ResponseEntity.ok(Map.of( + "status", false, + "message", message + )); + } + } + @PostMapping("/admin/createUser") @Operation(summary = "Create new user (Admin only)", description = "Creates a new user - requires admin privileges") public ResponseEntity createUser(@Valid @RequestBody CreateUserRequest request) { try { - User user = userService.createUser(request.getEmail(), request.getPassword(), request.getDisplayName()); + User user = userService.createUser(request.getPhoneNumber(), request.getPassword(), request.getDisplayName()); return ResponseEntity.ok(user); } catch (RuntimeException e) { return ResponseEntity.badRequest().body(Map.of("error", e.getMessage())); } } + + @PostMapping("/admin/createInvitationCode") + @Operation(summary = "Create invitation code (Admin only)", description = "Generates a new invitation code - requires admin privileges") + public ResponseEntity> createInvitationCode() { + try { + InvitationCode invitationCode = invitationCodeService.generateInvitationCode(); + + return ResponseEntity.ok(Map.of( + "status", true, + "message", "Invitation code created successfully", + "data", invitationCode + )); + + } catch (RuntimeException e) { + String message; + if (e.getMessage().contains("Unable to generate unique")) { + message = "Failed to generate unique invitation code. Please try again."; + } else { + message = "Failed to create invitation code. Please try again."; + } + + return ResponseEntity.ok(Map.of( + "status", false, + "message", message + )); + } + } } \ No newline at end of file diff --git a/backend/src/main/java/online/wesal/wesal/controller/InvitationController.java b/backend/src/main/java/online/wesal/wesal/controller/InvitationController.java index 425fcfc..c329365 100644 --- a/backend/src/main/java/online/wesal/wesal/controller/InvitationController.java +++ b/backend/src/main/java/online/wesal/wesal/controller/InvitationController.java @@ -51,8 +51,8 @@ public class InvitationController { } try { - String userEmail = authentication.getName(); - InvitationResponse response = invitationService.createInvitation(request, userEmail); + String userPhoneNumber = authentication.getName(); + InvitationResponse response = invitationService.createInvitation(request, userPhoneNumber); return ResponseEntity.ok(ApiResponse.success(response)); } catch (RuntimeException e) { String message; @@ -87,8 +87,8 @@ public class InvitationController { @Operation(summary = "Get all categorized invitations", description = "Get invitations in three categories: created, accepted, and available") public ResponseEntity> getAllCategorizedInvitations(Authentication authentication) { try { - String userEmail = authentication.getName(); - CategorizedInvitationsResponse response = invitationService.getAllCategorizedInvitations(userEmail); + String userPhoneNumber = authentication.getName(); + CategorizedInvitationsResponse response = invitationService.getAllCategorizedInvitations(userPhoneNumber); return ResponseEntity.ok(ApiResponse.success(response)); } catch (RuntimeException e) { return ResponseEntity.badRequest().body(ApiResponse.error(e.getMessage())); @@ -104,8 +104,8 @@ public class InvitationController { Authentication authentication) { try { - String userEmail = authentication.getName(); - invitationService.acceptInvitation(request.getId(), userEmail); + String userPhoneNumber = authentication.getName(); + invitationService.acceptInvitation(request.getId(), userPhoneNumber); return ResponseEntity.ok(new ApiResponse<>(true)); } catch (RuntimeException e) { return ResponseEntity.badRequest().body(ApiResponse.error(e.getMessage())); @@ -121,8 +121,8 @@ public class InvitationController { Authentication authentication) { try { - String userEmail = authentication.getName(); - invitationService.cancelInvitation(request.getId(), userEmail); + String userPhoneNumber = authentication.getName(); + invitationService.cancelInvitation(request.getId(), userPhoneNumber); return ResponseEntity.ok(new ApiResponse<>(true)); } catch (RuntimeException e) { return ResponseEntity.badRequest().body(ApiResponse.error(e.getMessage())); diff --git a/backend/src/main/java/online/wesal/wesal/controller/PostController.java b/backend/src/main/java/online/wesal/wesal/controller/PostController.java index 14f686a..2d3c19b 100644 --- a/backend/src/main/java/online/wesal/wesal/controller/PostController.java +++ b/backend/src/main/java/online/wesal/wesal/controller/PostController.java @@ -89,7 +89,7 @@ public class PostController { Long targetUserId; if (id == null) { // Use authenticated user's ID when no id is provided - String userEmail = authentication.getName(); + String userPhoneNumber = authentication.getName(); targetUserId = userService.getCurrentUser().getId(); } else { if (id <= 0) { diff --git a/backend/src/main/java/online/wesal/wesal/dto/CheckInvitationRequest.java b/backend/src/main/java/online/wesal/wesal/dto/CheckInvitationRequest.java new file mode 100644 index 0000000..9fe59c1 --- /dev/null +++ b/backend/src/main/java/online/wesal/wesal/dto/CheckInvitationRequest.java @@ -0,0 +1,23 @@ +package online.wesal.wesal.dto; + +import jakarta.validation.constraints.NotBlank; + +public class CheckInvitationRequest { + + @NotBlank + private String code; + + public CheckInvitationRequest() {} + + public CheckInvitationRequest(String code) { + this.code = code; + } + + public String getCode() { + return code; + } + + public void setCode(String code) { + this.code = code; + } +} \ No newline at end of file diff --git a/backend/src/main/java/online/wesal/wesal/dto/CreateUserRequest.java b/backend/src/main/java/online/wesal/wesal/dto/CreateUserRequest.java index eada67c..e487661 100644 --- a/backend/src/main/java/online/wesal/wesal/dto/CreateUserRequest.java +++ b/backend/src/main/java/online/wesal/wesal/dto/CreateUserRequest.java @@ -1,14 +1,14 @@ package online.wesal.wesal.dto; -import jakarta.validation.constraints.Email; import jakarta.validation.constraints.NotBlank; import jakarta.validation.constraints.Size; +import jakarta.validation.constraints.Pattern; public class CreateUserRequest { - @Email @NotBlank - private String email; + @Pattern(regexp = "^\\+[1-9]\\d{1,14}$", message = "Phone number must be in international format starting with + and country code") + private String phoneNumber; @NotBlank @Size(min = 6) @@ -19,18 +19,18 @@ public class CreateUserRequest { public CreateUserRequest() {} - public CreateUserRequest(String email, String password, String displayName) { - this.email = email; + public CreateUserRequest(String phoneNumber, String password, String displayName) { + this.phoneNumber = phoneNumber; this.password = password; this.displayName = displayName; } - public String getEmail() { - return email; + public String getPhoneNumber() { + return phoneNumber; } - public void setEmail(String email) { - this.email = email; + public void setPhoneNumber(String phoneNumber) { + this.phoneNumber = phoneNumber; } public String getPassword() { diff --git a/backend/src/main/java/online/wesal/wesal/dto/LoginRequest.java b/backend/src/main/java/online/wesal/wesal/dto/LoginRequest.java index be8c25e..a086e04 100644 --- a/backend/src/main/java/online/wesal/wesal/dto/LoginRequest.java +++ b/backend/src/main/java/online/wesal/wesal/dto/LoginRequest.java @@ -5,24 +5,24 @@ import jakarta.validation.constraints.NotBlank; public class LoginRequest { @NotBlank - private String emailOrUsername; + private String phoneNumberOrUsername; @NotBlank private String password; public LoginRequest() {} - public LoginRequest(String emailOrUsername, String password) { - this.emailOrUsername = emailOrUsername; + public LoginRequest(String phoneNumberOrUsername, String password) { + this.phoneNumberOrUsername = phoneNumberOrUsername; this.password = password; } - public String getEmailOrUsername() { - return emailOrUsername; + public String getPhoneNumberOrUsername() { + return phoneNumberOrUsername; } - public void setEmailOrUsername(String emailOrUsername) { - this.emailOrUsername = emailOrUsername; + public void setPhoneNumberOrUsername(String phoneNumberOrUsername) { + this.phoneNumberOrUsername = phoneNumberOrUsername; } public String getPassword() { diff --git a/backend/src/main/java/online/wesal/wesal/dto/RegisterRequest.java b/backend/src/main/java/online/wesal/wesal/dto/RegisterRequest.java new file mode 100644 index 0000000..deb8f8a --- /dev/null +++ b/backend/src/main/java/online/wesal/wesal/dto/RegisterRequest.java @@ -0,0 +1,51 @@ +package online.wesal.wesal.dto; + +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.Pattern; +import jakarta.validation.constraints.Size; + +public class RegisterRequest { + + @NotBlank + private String code; + + @NotBlank + @Pattern(regexp = "^\\+[1-9]\\d{1,14}$", message = "Phone number must be in international format starting with + and country code") + private String phoneNumber; + + @NotBlank + @Size(min = 6) + private String password; + + public RegisterRequest() {} + + public RegisterRequest(String code, String phoneNumber, String password) { + this.code = code; + this.phoneNumber = phoneNumber; + this.password = password; + } + + public String getCode() { + return code; + } + + public void setCode(String code) { + this.code = code; + } + + public String getPhoneNumber() { + return phoneNumber; + } + + public void setPhoneNumber(String phoneNumber) { + this.phoneNumber = phoneNumber; + } + + public String getPassword() { + return password; + } + + public void setPassword(String password) { + this.password = password; + } +} \ No newline at end of file diff --git a/backend/src/main/java/online/wesal/wesal/dto/RegisterResponse.java b/backend/src/main/java/online/wesal/wesal/dto/RegisterResponse.java new file mode 100644 index 0000000..efecbb8 --- /dev/null +++ b/backend/src/main/java/online/wesal/wesal/dto/RegisterResponse.java @@ -0,0 +1,32 @@ +package online.wesal.wesal.dto; + +import online.wesal.wesal.entity.User; + +public class RegisterResponse { + + private User user; + private String token; + + public RegisterResponse() {} + + public RegisterResponse(User user, String token) { + this.user = user; + this.token = token; + } + + public User getUser() { + return user; + } + + public void setUser(User user) { + this.user = user; + } + + public String getToken() { + return token; + } + + public void setToken(String token) { + this.token = token; + } +} \ No newline at end of file diff --git a/backend/src/main/java/online/wesal/wesal/entity/InvitationCode.java b/backend/src/main/java/online/wesal/wesal/entity/InvitationCode.java new file mode 100644 index 0000000..0520d49 --- /dev/null +++ b/backend/src/main/java/online/wesal/wesal/entity/InvitationCode.java @@ -0,0 +1,72 @@ +package online.wesal.wesal.entity; + +import jakarta.persistence.*; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.Size; +import java.time.LocalDateTime; + +@Entity +@Table(name = "invitation_codes") +public class InvitationCode { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(unique = true, nullable = false) + @NotBlank + @Size(max = 200) + private String code; + + @Column(nullable = false) + private LocalDateTime expirationDate; + + @Column(nullable = false) + private LocalDateTime createdDate; + + public InvitationCode() { + this.createdDate = LocalDateTime.now(); + this.expirationDate = LocalDateTime.now().plusDays(30); + } + + public InvitationCode(String code) { + this(); + this.code = code; + } + + public Long getId() { + return id; + } + + public void setId(Long id) { + this.id = id; + } + + public String getCode() { + return code; + } + + public void setCode(String code) { + this.code = code; + } + + public LocalDateTime getExpirationDate() { + return expirationDate; + } + + public void setExpirationDate(LocalDateTime expirationDate) { + this.expirationDate = expirationDate; + } + + public LocalDateTime getCreatedDate() { + return createdDate; + } + + public void setCreatedDate(LocalDateTime createdDate) { + this.createdDate = createdDate; + } + + public boolean isExpired() { + return LocalDateTime.now().isAfter(this.expirationDate); + } +} \ No newline at end of file diff --git a/backend/src/main/java/online/wesal/wesal/entity/User.java b/backend/src/main/java/online/wesal/wesal/entity/User.java index 3740136..c1059fc 100644 --- a/backend/src/main/java/online/wesal/wesal/entity/User.java +++ b/backend/src/main/java/online/wesal/wesal/entity/User.java @@ -1,9 +1,9 @@ package online.wesal.wesal.entity; import jakarta.persistence.*; -import jakarta.validation.constraints.Email; import jakarta.validation.constraints.NotBlank; import jakarta.validation.constraints.Size; +import jakarta.validation.constraints.Pattern; import com.fasterxml.jackson.annotation.JsonProperty; import com.fasterxml.jackson.core.type.TypeReference; import com.fasterxml.jackson.databind.ObjectMapper; @@ -19,9 +19,9 @@ public class User { private Long id; @Column(unique = true, nullable = false) - @Email @NotBlank - private String email; + @Pattern(regexp = "^\\+[1-9]\\d{1,14}$", message = "Phone number must be in international format starting with + and country code") + private String phoneNumber; @Column(unique = true) private String username; @@ -46,12 +46,12 @@ public class User { private String role = "USER"; @Column(columnDefinition = "TEXT") - private String subscriptions = "[\"newinvites\",\"invitesfollowup\",\"appnews\"]"; + private String subscriptions = "[\"postcomments\",\"newinvites\",\"invitesfollowup\",\"appnews\"]"; public User() {} - public User(String email, String password, String displayName) { - this.email = email; + public User(String phoneNumber, String password, String displayName) { + this.phoneNumber = phoneNumber; this.password = password; this.displayName = displayName; } @@ -64,12 +64,12 @@ public class User { this.id = id; } - public String getEmail() { - return email; + public String getPhoneNumber() { + return phoneNumber; } - public void setEmail(String email) { - this.email = email; + public void setPhoneNumber(String phoneNumber) { + this.phoneNumber = phoneNumber; } public String getUsername() { @@ -130,13 +130,13 @@ public class User { public List getSubscriptions() { if (subscriptions == null || subscriptions.isEmpty()) { - return Arrays.asList("newinvites", "invitesfollowup", "appnews"); + return Arrays.asList("postcomments", "newinvites", "invitesfollowup", "appnews"); } try { ObjectMapper mapper = new ObjectMapper(); return mapper.readValue(subscriptions, new TypeReference>() {}); } catch (Exception e) { - return Arrays.asList("newinvites", "invitesfollowup", "appnews"); + return Arrays.asList("postcomments", "newinvites", "invitesfollowup", "appnews"); } } @@ -145,7 +145,7 @@ public class User { ObjectMapper mapper = new ObjectMapper(); this.subscriptions = mapper.writeValueAsString(subscriptions); } catch (Exception e) { - this.subscriptions = "[\"newinvites\",\"invitesfollowup\",\"appnews\"]"; + this.subscriptions = "[\"postcomments\",\"newinvites\",\"invitesfollowup\",\"appnews\"]"; } } } \ No newline at end of file diff --git a/backend/src/main/java/online/wesal/wesal/repository/InvitationCodeRepository.java b/backend/src/main/java/online/wesal/wesal/repository/InvitationCodeRepository.java new file mode 100644 index 0000000..9f1d00e --- /dev/null +++ b/backend/src/main/java/online/wesal/wesal/repository/InvitationCodeRepository.java @@ -0,0 +1,19 @@ +package online.wesal.wesal.repository; + +import online.wesal.wesal.entity.InvitationCode; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; +import org.springframework.stereotype.Repository; + +import java.time.LocalDateTime; +import java.util.Optional; + +@Repository +public interface InvitationCodeRepository extends JpaRepository { + Optional findByCode(String code); + boolean existsByCode(String code); + + @Query("SELECT ic FROM InvitationCode ic WHERE ic.code = :code AND ic.expirationDate > :currentTime") + Optional findValidCodeByCode(@Param("code") String code, @Param("currentTime") LocalDateTime currentTime); +} \ No newline at end of file diff --git a/backend/src/main/java/online/wesal/wesal/repository/UserRepository.java b/backend/src/main/java/online/wesal/wesal/repository/UserRepository.java index 308181e..69c1737 100644 --- a/backend/src/main/java/online/wesal/wesal/repository/UserRepository.java +++ b/backend/src/main/java/online/wesal/wesal/repository/UserRepository.java @@ -11,9 +11,9 @@ import java.util.Optional; @Repository public interface UserRepository extends JpaRepository { - Optional findByEmail(String email); + Optional findByPhoneNumber(String phoneNumber); Optional findByUsername(String username); - boolean existsByEmail(String email); + boolean existsByPhoneNumber(String phoneNumber); boolean existsByUsername(String username); @Query("SELECT u FROM User u WHERE u.subscriptions LIKE %:subscription% AND u.fcmToken IS NOT NULL AND u.fcmToken != ''") diff --git a/backend/src/main/java/online/wesal/wesal/service/AuthService.java b/backend/src/main/java/online/wesal/wesal/service/AuthService.java index 3a0dabc..bfc49a1 100644 --- a/backend/src/main/java/online/wesal/wesal/service/AuthService.java +++ b/backend/src/main/java/online/wesal/wesal/service/AuthService.java @@ -21,23 +21,23 @@ public class AuthService { @Autowired private JwtUtil jwtUtil; - public String authenticate(String emailOrUsername, String password) { - Optional userOpt = userRepository.findByEmail(emailOrUsername); + public String authenticate(String phoneNumberOrUsername, String password) { + Optional userOpt = userRepository.findByPhoneNumber(phoneNumberOrUsername); if (userOpt.isEmpty()) { - userOpt = userRepository.findByUsername(emailOrUsername); + userOpt = userRepository.findByUsername(phoneNumberOrUsername); } if (userOpt.isPresent()) { User user = userOpt.get(); - System.out.println("Authenticating user: " + user.getEmail()); + System.out.println("Authenticating user: " + user.getPhoneNumber()); if (passwordEncoder.matches(password, user.getPassword())) { System.out.println("Password matches!"); if (!user.isActivated()) { user.setActivated(true); userRepository.save(user); } - return jwtUtil.generateToken(user.getEmail()); + return jwtUtil.generateToken(user.getPhoneNumber()); } } diff --git a/backend/src/main/java/online/wesal/wesal/service/InvitationCodeService.java b/backend/src/main/java/online/wesal/wesal/service/InvitationCodeService.java new file mode 100644 index 0000000..69f8d1e --- /dev/null +++ b/backend/src/main/java/online/wesal/wesal/service/InvitationCodeService.java @@ -0,0 +1,100 @@ +package online.wesal.wesal.service; + +import online.wesal.wesal.entity.InvitationCode; +import online.wesal.wesal.entity.User; +import online.wesal.wesal.repository.InvitationCodeRepository; +import online.wesal.wesal.repository.UserRepository; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.security.crypto.password.PasswordEncoder; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.time.LocalDateTime; +import java.util.Arrays; +import java.util.Optional; +import java.util.Random; + +@Service +public class InvitationCodeService { + + @Autowired + private InvitationCodeRepository invitationCodeRepository; + + @Autowired + private UserRepository userRepository; + + @Autowired + private PasswordEncoder passwordEncoder; + + public boolean isValidCode(String code) { + Optional invitationCodeOpt = invitationCodeRepository.findValidCodeByCode(code, LocalDateTime.now()); + return invitationCodeOpt.isPresent(); + } + + public Optional findValidCode(String code) { + return invitationCodeRepository.findValidCodeByCode(code, LocalDateTime.now()); + } + + @Transactional + public User registerUserWithCode(String code, String phoneNumber, String password) { + // Check if phone number already exists + if (userRepository.existsByPhoneNumber(phoneNumber)) { + throw new RuntimeException("User with this phone number already exists"); + } + + // Validate invitation code + Optional invitationCodeOpt = findValidCode(code); + if (invitationCodeOpt.isEmpty()) { + throw new RuntimeException("Invalid or expired invitation code"); + } + + InvitationCode invitationCode = invitationCodeOpt.get(); + + // Create new user + User user = new User(); + user.setPhoneNumber(phoneNumber); + user.setPassword(passwordEncoder.encode(password)); + user.setDisplayName("Guest"); + user.setRole("USER"); + user.setActivated(false); + user.setSubscriptions(Arrays.asList("postcomments", "newinvites", "invitesfollowup", "appnews")); + + // Save user + User savedUser = userRepository.save(user); + + // Remove (delete) the invitation code so it can't be used again + invitationCodeRepository.delete(invitationCode); + + return savedUser; + } + + public InvitationCode generateInvitationCode() { + String code; + int attempts = 0; + int maxAttempts = 100; + + // Generate a unique 6-character code + do { + code = generateRandomCode(); + attempts++; + if (attempts > maxAttempts) { + throw new RuntimeException("Unable to generate unique invitation code after " + maxAttempts + " attempts"); + } + } while (invitationCodeRepository.existsByCode(code)); + + InvitationCode invitationCode = new InvitationCode(code); + return invitationCodeRepository.save(invitationCode); + } + + private String generateRandomCode() { + String characters = "ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"; + Random random = new Random(); + StringBuilder code = new StringBuilder(); + + for (int i = 0; i < 6; i++) { + code.append(characters.charAt(random.nextInt(characters.length()))); + } + + return code.toString(); + } +} \ No newline at end of file diff --git a/backend/src/main/java/online/wesal/wesal/service/InvitationService.java b/backend/src/main/java/online/wesal/wesal/service/InvitationService.java index 3617d8b..ce749d7 100644 --- a/backend/src/main/java/online/wesal/wesal/service/InvitationService.java +++ b/backend/src/main/java/online/wesal/wesal/service/InvitationService.java @@ -42,8 +42,8 @@ public class InvitationService { private SubscriptionNotificationService subscriptionNotificationService; @Transactional - public InvitationResponse createInvitation(CreateInvitationRequest request, String userEmail) { - User creator = userRepository.findByEmail(userEmail) + public InvitationResponse createInvitation(CreateInvitationRequest request, String userPhoneNumber) { + User creator = userRepository.findByPhoneNumber(userPhoneNumber) .orElseThrow(() -> new RuntimeException("User not found")); Tag tag = tagRepository.findById(request.getTagId()) @@ -85,8 +85,8 @@ public class InvitationService { .collect(Collectors.toList()); } - public CategorizedInvitationsResponse getAllCategorizedInvitations(String userEmail) { - User user = userRepository.findByEmail(userEmail) + public CategorizedInvitationsResponse getAllCategorizedInvitations(String userPhoneNumber) { + User user = userRepository.findByPhoneNumber(userPhoneNumber) .orElseThrow(() -> new RuntimeException("Authentication error. Please log in again")); List created = invitationRepository.findByCreatorIdOrderByCreationDateDesc(user.getId()) @@ -110,8 +110,8 @@ public class InvitationService { } @Transactional - public void acceptInvitation(Long invitationId, String userEmail) { - User user = userRepository.findByEmail(userEmail) + public void acceptInvitation(Long invitationId, String userPhoneNumber) { + User user = userRepository.findByPhoneNumber(userPhoneNumber) .orElseThrow(() -> new RuntimeException("Authentication error. Please log in again")); Invitation invitation = invitationRepository.findById(invitationId) @@ -171,8 +171,8 @@ public class InvitationService { } @Transactional - public void cancelInvitation(Long invitationId, String userEmail) { - User user = userRepository.findByEmail(userEmail) + public void cancelInvitation(Long invitationId, String userPhoneNumber) { + User user = userRepository.findByPhoneNumber(userPhoneNumber) .orElseThrow(() -> new RuntimeException("Authentication error. Please log in again")); Invitation invitation = invitationRepository.findById(invitationId) diff --git a/backend/src/main/java/online/wesal/wesal/service/UserDetailsServiceImpl.java b/backend/src/main/java/online/wesal/wesal/service/UserDetailsServiceImpl.java index ce92452..372d18a 100644 --- a/backend/src/main/java/online/wesal/wesal/service/UserDetailsServiceImpl.java +++ b/backend/src/main/java/online/wesal/wesal/service/UserDetailsServiceImpl.java @@ -20,15 +20,15 @@ public class UserDetailsServiceImpl implements UserDetailsService { private UserRepository userRepository; @Override - public UserDetails loadUserByUsername(String email) throws UsernameNotFoundException { - User user = userRepository.findByEmail(email) - .orElseThrow(() -> new UsernameNotFoundException("User not found: " + email)); + public UserDetails loadUserByUsername(String phoneNumber) throws UsernameNotFoundException { + User user = userRepository.findByPhoneNumber(phoneNumber) + .orElseThrow(() -> new UsernameNotFoundException("User not found: " + phoneNumber)); Collection authorities = new ArrayList<>(); authorities.add(new SimpleGrantedAuthority("ROLE_" + user.getRole())); return new org.springframework.security.core.userdetails.User( - user.getEmail(), + user.getPhoneNumber(), user.getPassword(), authorities ); diff --git a/backend/src/main/java/online/wesal/wesal/service/UserService.java b/backend/src/main/java/online/wesal/wesal/service/UserService.java index d9c5be2..e16dbd7 100644 --- a/backend/src/main/java/online/wesal/wesal/service/UserService.java +++ b/backend/src/main/java/online/wesal/wesal/service/UserService.java @@ -20,8 +20,8 @@ public class UserService { public User getCurrentUser() { Authentication authentication = SecurityContextHolder.getContext().getAuthentication(); - String email = authentication.getName(); - return userRepository.findByEmail(email) + String phoneNumber = authentication.getName(); + return userRepository.findByPhoneNumber(phoneNumber) .orElseThrow(() -> new RuntimeException("User not found")); } @@ -44,16 +44,16 @@ public class UserService { return "ADMIN".equals(user.getRole()); } - public User createUser(String email, String password, String displayName) { + public User createUser(String phoneNumber, String password, String displayName) { if (!isCurrentUserAdmin()) { throw new RuntimeException("Access denied: Admin privileges required"); } - if (userRepository.existsByEmail(email)) { - throw new RuntimeException("User with this email already exists"); + if (userRepository.existsByPhoneNumber(phoneNumber)) { + throw new RuntimeException("User with this phone number already exists"); } - User user = new User(email, passwordEncoder.encode(password), displayName); + User user = new User(phoneNumber, passwordEncoder.encode(password), displayName); return userRepository.save(user); } diff --git a/frontend/lib/constants/api_constants.dart b/frontend/lib/constants/api_constants.dart index cdb6ebd..5c73bc2 100644 --- a/frontend/lib/constants/api_constants.dart +++ b/frontend/lib/constants/api_constants.dart @@ -3,6 +3,8 @@ class ApiConstants { // Auth endpoints static const String loginEndpoint = '/login'; + static const String registerEndpoint = '/register'; + static const String checkInvitationEndpoint = '/checkInvitation'; // User endpoints static const String getUserEndpoint = '/getUser'; diff --git a/frontend/lib/main.dart b/frontend/lib/main.dart index 9785e5d..614ed4f 100644 --- a/frontend/lib/main.dart +++ b/frontend/lib/main.dart @@ -7,6 +7,8 @@ import 'screens/edit_profile_screen.dart'; import 'services/auth_service.dart'; import 'services/user_service.dart'; import 'services/app_lifecycle_service.dart'; +import 'services/http_service.dart'; +import 'dart:convert'; final GlobalKey navigatorKey = GlobalKey(); @@ -260,10 +262,14 @@ class SignInPage extends StatefulWidget { class _SignInPageState extends State { final _formKey = GlobalKey(); - final _emailController = TextEditingController(); + final _phoneController = TextEditingController(); final _passwordController = TextEditingController(); + final _invitationCodeController = TextEditingController(); + final _countryCodeController = TextEditingController(text: '+966'); bool _isPasswordVisible = false; bool _isLoading = false; + bool _isRegistering = false; + int _registrationStep = 1; // 1 = invitation code, 2 = phone & password void _showHelpBottomSheet() { showModalBottomSheet( @@ -297,7 +303,7 @@ class _SignInPageState extends State { ), SizedBox(height: 16), Text( - 'For account creation or password reset, please contact ERP Management Group from your Aramco email address.', + 'Registration is by invitation only. If you don\'t have an invitation code, please contact COD/DPSD.', textAlign: TextAlign.center, style: TextStyle( fontSize: 16, @@ -331,10 +337,51 @@ class _SignInPageState extends State { ); } + Future _checkInvitationCode() async { + if (_invitationCodeController.text.trim().isEmpty) { + _showErrorAlert('Please enter your invitation code'); + return; + } + + setState(() { + _isLoading = true; + }); + + try { + final response = await HttpService.post('/checkInvitation', { + 'code': _invitationCodeController.text.trim().toUpperCase(), + }); + + final data = json.decode(response.body); + + setState(() { + _isLoading = false; + }); + + if (data['status'] == true && + data['data'] != null && + data['data']['valid'] == true) { + // Invitation code is valid, move to step 2 + setState(() { + _registrationStep = 2; + }); + } else { + _showErrorAlert(data['message'] ?? 'Invalid invitation code'); + } + } catch (e) { + setState(() { + _isLoading = false; + }); + _showErrorAlert('Network error. Please try again.'); + } + } + @override void dispose() { - _emailController.dispose(); + _phoneController.dispose(); _passwordController.dispose(); + _invitationCodeController.dispose(); + _countryCodeController.dispose(); super.dispose(); } @@ -344,8 +391,10 @@ class _SignInPageState extends State { _isLoading = true; }); + final phoneNumber = + _countryCodeController.text.trim() + _phoneController.text.trim(); final result = await AuthService.login( - _emailController.text.trim(), + phoneNumber, _passwordController.text, ); @@ -386,11 +435,83 @@ class _SignInPageState extends State { } } + void _handleRegister() async { + if (_formKey.currentState!.validate()) { + setState(() { + _isLoading = true; + }); + + try { + final phoneNumber = + _countryCodeController.text.trim() + _phoneController.text.trim(); + + final response = await HttpService.post('/register', { + 'code': _invitationCodeController.text.trim().toUpperCase(), + 'phoneNumber': phoneNumber, + 'password': _passwordController.text, + }); + + final data = json.decode(response.body); + + setState(() { + _isLoading = false; + }); + + if (data['status'] == true && data['data'] != null) { + // Registration successful, save token and navigate + final token = data['data']['token']; + if (token != null) { + await AuthService.saveToken(token); + + final userResult = await UserService.getCurrentUser( + forceRefresh: true, + ); + if (userResult['success'] == true) { + final userData = userResult['data']; + + // Check if user needs onboarding (activated = false) + if (userData['activated'] == false) { + Navigator.of(context).pushReplacement( + MaterialPageRoute( + builder: (context) => EditProfileScreen( + userData: userData, + isOnboarding: true, + ), + ), + ); + } else { + AppLifecycleService.startAllPolling(); + Navigator.of(context).pushReplacement( + MaterialPageRoute( + builder: (context) => NotificationPermissionScreen(), + ), + ); + } + } else { + _showErrorAlert( + 'Registration successful but failed to load user data', + ); + } + } else { + _showErrorAlert('Registration successful but no token received'); + } + } else { + _showErrorAlert(data['message'] ?? 'Registration failed'); + } + } catch (e) { + setState(() { + _isLoading = false; + }); + _showErrorAlert('Network error. Please try again.'); + } + } + } + void _showErrorAlert(String message) { showDialog( context: context, builder: (context) => AlertDialog( - title: Text('Login Failed'), + title: Text(_isRegistering ? 'Registration' : 'Login Failed'), content: Text(message), actions: [ TextButton( @@ -430,7 +551,7 @@ class _SignInPageState extends State { icon: Icon(Icons.arrow_back, color: Colors.white), ), Text( - 'Sign In', + _isRegistering ? 'Register' : 'Sign In', style: TextStyle( fontSize: 20, fontWeight: FontWeight.w600, @@ -482,7 +603,9 @@ class _SignInPageState extends State { // Welcome text Text( - 'Welcome Back!', + _isRegistering + ? 'Create Account' + : 'Welcome Back!', style: TextStyle( fontSize: 24, fontWeight: FontWeight.bold, @@ -494,7 +617,9 @@ class _SignInPageState extends State { SizedBox(height: 8), Text( - 'Sign in to socialize with your colleagues\nand transform your social life!', + _isRegistering + ? 'Join with an invitation code to connect\nwith your colleagues!' + : 'Sign in to socialize with your colleagues\nand transform your social life!', style: TextStyle( fontSize: 16, color: Colors.grey[600], @@ -504,100 +629,201 @@ class _SignInPageState extends State { SizedBox(height: 40), - // Email field - TextFormField( - controller: _emailController, - keyboardType: TextInputType.emailAddress, - decoration: InputDecoration( - labelText: 'Email', - prefixIcon: Icon(Icons.email_outlined), - border: OutlineInputBorder( - borderRadius: BorderRadius.circular(12), - ), - focusedBorder: OutlineInputBorder( - borderRadius: BorderRadius.circular(12), - borderSide: BorderSide( - color: Color(0xFF6A4C93), + // Registration Step 1: Invitation Code + if (_isRegistering && _registrationStep == 1) ...[ + TextFormField( + controller: _invitationCodeController, + decoration: InputDecoration( + labelText: 'Invitation Code', + prefixIcon: Icon( + Icons.confirmation_number_outlined, ), + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(12), + ), + focusedBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(12), + borderSide: BorderSide( + color: Color(0xFF6A4C93), + ), + ), + helperText: + '6-character code with letters and numbers', ), + maxLength: 6, + textCapitalization: + TextCapitalization.characters, + validator: (value) { + if (value == null || value.isEmpty) { + return 'Please enter your invitation code'; + } + if (value.length != 6) { + return 'Invitation code must be 6 characters'; + } + if (!RegExp( + r'^[A-Z0-9]{6}$', + ).hasMatch(value)) { + return 'Code must contain only letters and numbers'; + } + return null; + }, ), - validator: (value) { - if (value == null || value.isEmpty) { - return 'Please enter your email'; - } - if (!value.contains('@')) { - return 'Please enter a valid email'; - } - return null; - }, - ), + ], - SizedBox(height: 20), - - // Password field - TextFormField( - controller: _passwordController, - obscureText: !_isPasswordVisible, - decoration: InputDecoration( - labelText: 'Password', - prefixIcon: Icon(Icons.lock_outline), - suffixIcon: IconButton( - icon: Icon( - _isPasswordVisible - ? Icons.visibility_off - : Icons.visibility, + // Login or Registration Step 2: Phone & Password + if (!_isRegistering || _registrationStep == 2) ...[ + // Phone number field with country code input + Row( + children: [ + SizedBox( + width: 100, + child: TextFormField( + controller: _countryCodeController, + keyboardType: TextInputType.phone, + decoration: InputDecoration( + labelText: 'Code', + border: OutlineInputBorder( + borderRadius: BorderRadius.circular( + 12, + ), + ), + focusedBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular( + 12, + ), + borderSide: BorderSide( + color: Color(0xFF6A4C93), + ), + ), + ), + validator: (value) { + if (value == null || value.isEmpty) { + return 'Required'; + } + if (!value.startsWith('+')) { + return 'Must start with +'; + } + return null; + }, + ), ), - onPressed: () { - setState(() { - _isPasswordVisible = !_isPasswordVisible; - }); - }, - ), - border: OutlineInputBorder( - borderRadius: BorderRadius.circular(12), - ), - focusedBorder: OutlineInputBorder( - borderRadius: BorderRadius.circular(12), - borderSide: BorderSide( - color: Color(0xFF6A4C93), + SizedBox(width: 12), + Expanded( + child: TextFormField( + controller: _phoneController, + keyboardType: TextInputType.phone, + decoration: InputDecoration( + labelText: 'Phone Number', + prefixIcon: Icon(Icons.phone_outlined), + border: OutlineInputBorder( + borderRadius: BorderRadius.circular( + 12, + ), + ), + focusedBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular( + 12, + ), + borderSide: BorderSide( + color: Color(0xFF6A4C93), + ), + ), + ), + validator: (value) { + if (value == null || value.isEmpty) { + return 'Please enter your phone number'; + } + if (value.length < 8) { + return 'Please enter a valid phone number'; + } + return null; + }, + ), ), - ), + ], ), - validator: (value) { - if (value == null || value.isEmpty) { - return 'Please enter your password'; - } - if (value.length < 6) { - return 'Password must be at least 6 characters'; - } - return null; - }, - ), + ], + + // Password field (only for login or registration step 2) + if (!_isRegistering || _registrationStep == 2) ...[ + SizedBox(height: 20), + + TextFormField( + controller: _passwordController, + obscureText: !_isPasswordVisible, + decoration: InputDecoration( + labelText: 'Password', + prefixIcon: Icon(Icons.lock_outline), + suffixIcon: IconButton( + icon: Icon( + _isPasswordVisible + ? Icons.visibility_off + : Icons.visibility, + ), + onPressed: () { + setState(() { + _isPasswordVisible = + !_isPasswordVisible; + }); + }, + ), + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(12), + ), + focusedBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(12), + borderSide: BorderSide( + color: Color(0xFF6A4C93), + ), + ), + ), + validator: (value) { + if (value == null || value.isEmpty) { + return 'Please enter your password'; + } + if (value.length < 6) { + return 'Password must be at least 6 characters'; + } + return null; + }, + ), + ], SizedBox(height: 12), - // Forgot password - Align( - alignment: Alignment.centerRight, - child: TextButton( - onPressed: _showHelpBottomSheet, - child: Text( - 'Forgot Password?', - style: TextStyle( - color: Color(0xFF6A4C93), - fontWeight: FontWeight.w600, + // Forgot password (only show for login) + if (!_isRegistering) + Align( + alignment: Alignment.centerRight, + child: TextButton( + onPressed: _showHelpBottomSheet, + child: Text( + 'Forgot Password?', + style: TextStyle( + color: Color(0xFF6A4C93), + fontWeight: FontWeight.w600, + ), ), ), ), - ), SizedBox(height: 30), - // Sign in button + // Action button based on current state Container( height: 56, child: ElevatedButton( - onPressed: _isLoading ? null : _handleSignIn, + onPressed: _isLoading + ? null + : () { + if (!_isRegistering) { + _handleSignIn(); + } else if (_registrationStep == 1) { + _checkInvitationCode(); + } else { + _handleRegister(); + } + }, style: ElevatedButton.styleFrom( backgroundColor: Color(0xFF6A4C93), foregroundColor: Colors.white, @@ -614,7 +840,11 @@ class _SignInPageState extends State { ), ) : Text( - 'Sign In', + !_isRegistering + ? 'Sign In' + : (_registrationStep == 1 + ? 'Continue' + : 'Register'), style: TextStyle( fontSize: 18, fontWeight: FontWeight.w600, @@ -623,28 +853,86 @@ class _SignInPageState extends State { ), ), - SizedBox(height: 20), - - // Contact link for new accounts - Row( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - Text( - "Don't have an account? ", - style: TextStyle(color: Colors.grey[600]), - ), - GestureDetector( - onTap: _showHelpBottomSheet, + // Back button for registration step 2 + if (_isRegistering && _registrationStep == 2) ...[ + SizedBox(height: 16), + Container( + height: 56, + child: OutlinedButton( + onPressed: () { + setState(() { + _registrationStep = 1; + _phoneController.clear(); + _passwordController.clear(); + }); + }, + style: OutlinedButton.styleFrom( + side: BorderSide(color: Color(0xFF6A4C93)), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(12), + ), + ), child: Text( - 'Contact Support', + 'Back to Invitation Code', style: TextStyle( - color: Color(0xFF6A4C93), + fontSize: 16, fontWeight: FontWeight.w600, + color: Color(0xFF6A4C93), ), ), ), - ], - ), + ), + ], + + SizedBox(height: 20), + + // Toggle between login and registration (only show on step 1) + if (!_isRegistering || _registrationStep == 1) + Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Text( + _isRegistering + ? 'Already have an account? ' + : "Don't have an account? ", + style: TextStyle(color: Colors.grey[600]), + ), + GestureDetector( + onTap: () { + setState(() { + _isRegistering = !_isRegistering; + _registrationStep = 1; + // Clear form when switching modes + _phoneController.clear(); + _passwordController.clear(); + _invitationCodeController.clear(); + }); + }, + child: Text( + _isRegistering + ? 'Sign In' + : 'Register Now!', + style: TextStyle( + color: Color(0xFF6A4C93), + fontWeight: FontWeight.w600, + ), + ), + ), + ], + ), + + if (_isRegistering) ...[ + SizedBox(height: 16), + Text( + 'Registration is only by invitation.\nIf you don\'t have an invite, contact COD/DPSD.', + textAlign: TextAlign.center, + style: TextStyle( + fontSize: 14, + color: Colors.grey[600], + height: 1.3, + ), + ), + ], SizedBox(height: 40), ], diff --git a/frontend/lib/screens/create_invitation_screen.dart b/frontend/lib/screens/create_invitation_screen.dart new file mode 100644 index 0000000..b518552 --- /dev/null +++ b/frontend/lib/screens/create_invitation_screen.dart @@ -0,0 +1,368 @@ +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'dart:convert'; +import '../services/http_service.dart'; +import '../constants/api_constants.dart'; + +class CreateInvitationScreen extends StatefulWidget { + @override + _CreateInvitationScreenState createState() => _CreateInvitationScreenState(); +} + +class _CreateInvitationScreenState extends State { + bool _isLoading = false; + Map? _createdInvitation; + + Future _createInvitationCode() async { + setState(() { + _isLoading = true; + }); + + try { + final response = await HttpService.post(ApiConstants.createInvitationCodeEndpoint, {}); + final data = json.decode(response.body); + + setState(() { + _isLoading = false; + }); + + if (data['status'] == true && data['data'] != null) { + setState(() { + _createdInvitation = data['data']; + }); + + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text(data['message'] ?? 'Invitation code created successfully'), + backgroundColor: Colors.green, + duration: Duration(seconds: 3), + ), + ); + } else { + _showErrorAlert(data['message'] ?? 'Failed to create invitation code'); + } + } catch (e) { + setState(() { + _isLoading = false; + }); + _showErrorAlert('Network error. Please try again.'); + } + } + + void _showErrorAlert(String message) { + showDialog( + context: context, + builder: (context) => AlertDialog( + title: Text('Error'), + content: Text(message), + actions: [ + TextButton( + onPressed: () => Navigator.of(context).pop(), + child: Text('OK', style: TextStyle(color: Color(0xFF6A4C93))), + ), + ], + ), + ); + } + + Future _copyToClipboard(String text) async { + await Clipboard.setData(ClipboardData(text: text)); + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text('Copied to clipboard'), + backgroundColor: Color(0xFF6A4C93), + duration: Duration(seconds: 2), + ), + ); + } + + String _formatDate(String dateString) { + try { + final date = DateTime.parse(dateString); + return '${date.day}/${date.month}/${date.year} at ${date.hour.toString().padLeft(2, '0')}:${date.minute.toString().padLeft(2, '0')}'; + } catch (e) { + return dateString; + } + } + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar( + title: Text( + 'Create Invitation Code', + style: TextStyle( + color: Colors.white, + fontWeight: FontWeight.w600, + ), + ), + backgroundColor: Color(0xFF6A4C93), + foregroundColor: Colors.white, + elevation: 0, + ), + body: Container( + decoration: BoxDecoration( + gradient: LinearGradient( + begin: Alignment.topCenter, + end: Alignment.bottomCenter, + colors: [Color(0xFF6A4C93), Colors.white], + stops: [0.0, 0.3], + ), + ), + child: SafeArea( + child: Padding( + padding: EdgeInsets.all(24.0), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + // Header + Container( + padding: EdgeInsets.all(24), + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(16), + boxShadow: [ + BoxShadow( + color: Colors.black.withOpacity(0.1), + blurRadius: 10, + offset: Offset(0, 5), + ), + ], + ), + child: Column( + children: [ + Icon( + Icons.confirmation_number_outlined, + size: 48, + color: Color(0xFF6A4C93), + ), + SizedBox(height: 16), + Text( + 'Generate New Invitation Code', + style: TextStyle( + fontSize: 20, + fontWeight: FontWeight.bold, + color: Colors.black87, + ), + textAlign: TextAlign.center, + ), + SizedBox(height: 8), + Text( + 'Create a new invitation code for user registration', + style: TextStyle( + fontSize: 14, + color: Colors.grey[600], + ), + textAlign: TextAlign.center, + ), + SizedBox(height: 24), + + // Create button + Container( + width: double.infinity, + height: 56, + child: ElevatedButton( + onPressed: _isLoading ? null : _createInvitationCode, + style: ElevatedButton.styleFrom( + backgroundColor: Color(0xFF6A4C93), + foregroundColor: Colors.white, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(12), + ), + elevation: 2, + ), + child: _isLoading + ? CircularProgressIndicator( + valueColor: AlwaysStoppedAnimation(Colors.white), + ) + : Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon(Icons.add, size: 20), + SizedBox(width: 8), + Text( + 'Create New Invitation Code', + style: TextStyle( + fontSize: 16, + fontWeight: FontWeight.w600, + ), + ), + ], + ), + ), + ), + ], + ), + ), + + SizedBox(height: 24), + + // Created invitation code display + if (_createdInvitation != null) ...[ + Container( + padding: EdgeInsets.all(24), + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(16), + boxShadow: [ + BoxShadow( + color: Colors.black.withOpacity(0.1), + blurRadius: 10, + offset: Offset(0, 5), + ), + ], + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Row( + children: [ + Icon( + Icons.check_circle, + color: Colors.green, + size: 24, + ), + SizedBox(width: 8), + Text( + 'Invitation Code Created', + style: TextStyle( + fontSize: 18, + fontWeight: FontWeight.bold, + color: Colors.green, + ), + ), + ], + ), + + SizedBox(height: 20), + + // Invitation code + Container( + padding: EdgeInsets.all(16), + decoration: BoxDecoration( + color: Colors.grey[100], + borderRadius: BorderRadius.circular(12), + border: Border.all(color: Color(0xFF6A4C93), width: 2), + ), + child: Row( + children: [ + Expanded( + child: Text( + _createdInvitation!['code'] ?? 'N/A', + style: TextStyle( + fontSize: 24, + fontWeight: FontWeight.bold, + color: Color(0xFF6A4C93), + letterSpacing: 2.0, + ), + textAlign: TextAlign.center, + ), + ), + SizedBox(width: 12), + IconButton( + onPressed: () => _copyToClipboard(_createdInvitation!['code'] ?? ''), + icon: Icon( + Icons.copy, + color: Color(0xFF6A4C93), + ), + tooltip: 'Copy to clipboard', + ), + ], + ), + ), + + SizedBox(height: 16), + + // Details + Container( + padding: EdgeInsets.all(16), + decoration: BoxDecoration( + color: Colors.grey[50], + borderRadius: BorderRadius.circular(12), + ), + child: Column( + children: [ + _buildDetailRow( + 'ID:', + _createdInvitation!['id']?.toString() ?? 'N/A', + ), + SizedBox(height: 8), + _buildDetailRow( + 'Created:', + _formatDate(_createdInvitation!['createdDate'] ?? ''), + ), + SizedBox(height: 8), + _buildDetailRow( + 'Expires:', + _formatDate(_createdInvitation!['expirationDate'] ?? ''), + ), + SizedBox(height: 8), + _buildDetailRow( + 'Status:', + _createdInvitation!['expired'] == false ? 'Active' : 'Expired', + valueColor: _createdInvitation!['expired'] == false ? Colors.green : Colors.red, + ), + ], + ), + ), + + SizedBox(height: 20), + + // Copy full details button + Container( + width: double.infinity, + child: OutlinedButton.icon( + onPressed: () { + final details = ''' +Invitation Code: ${_createdInvitation!['code']} +ID: ${_createdInvitation!['id']} +Created: ${_formatDate(_createdInvitation!['createdDate'] ?? '')} +Expires: ${_formatDate(_createdInvitation!['expirationDate'] ?? '')} +Status: ${_createdInvitation!['expired'] == false ? 'Active' : 'Expired'} +'''; + _copyToClipboard(details); + }, + icon: Icon(Icons.copy_all), + label: Text('Copy All Details'), + style: OutlinedButton.styleFrom( + side: BorderSide(color: Color(0xFF6A4C93)), + foregroundColor: Color(0xFF6A4C93), + ), + ), + ), + ], + ), + ), + ], + ], + ), + ), + ), + ), + ); + } + + Widget _buildDetailRow(String label, String value, {Color? valueColor}) { + return Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text( + label, + style: TextStyle( + fontSize: 14, + color: Colors.grey[600], + fontWeight: FontWeight.w500, + ), + ), + Text( + value, + style: TextStyle( + fontSize: 14, + color: valueColor ?? Colors.black87, + fontWeight: FontWeight.w600, + ), + ), + ], + ); + } +} \ No newline at end of file diff --git a/frontend/lib/screens/edit_profile_screen.dart b/frontend/lib/screens/edit_profile_screen.dart index c86a945..8896a22 100644 --- a/frontend/lib/screens/edit_profile_screen.dart +++ b/frontend/lib/screens/edit_profile_screen.dart @@ -21,7 +21,7 @@ class _EditProfileScreenState extends State { final _formKey = GlobalKey(); final TextEditingController _displayNameController = TextEditingController(); final TextEditingController _usernameController = TextEditingController(); - final TextEditingController _emailController = TextEditingController(); + final TextEditingController _phoneController = TextEditingController(); bool _isLoading = false; @override @@ -33,7 +33,7 @@ class _EditProfileScreenState extends State { void _initializeFields() { if (widget.userData != null) { _displayNameController.text = widget.userData!['displayName'] ?? ''; - _emailController.text = widget.userData!['email'] ?? ''; + _phoneController.text = widget.userData!['phoneNumber'] ?? ''; if (!widget.isOnboarding) { _usernameController.text = widget.userData!['username'] ?? ''; } @@ -44,7 +44,7 @@ class _EditProfileScreenState extends State { void dispose() { _displayNameController.dispose(); _usernameController.dispose(); - _emailController.dispose(); + _phoneController.dispose(); super.dispose(); } @@ -299,11 +299,11 @@ class _EditProfileScreenState extends State { ), SizedBox(height: 8), TextFormField( - controller: _emailController, + controller: _phoneController, readOnly: true, decoration: InputDecoration( - hintText: 'Email address', - prefixIcon: Icon(Icons.email, color: Colors.grey[400]), + hintText: 'Phone number', + prefixIcon: Icon(Icons.phone, color: Colors.grey[400]), border: OutlineInputBorder( borderRadius: BorderRadius.circular(12), borderSide: BorderSide(color: Colors.grey[300]!), @@ -328,7 +328,7 @@ class _EditProfileScreenState extends State { ), SizedBox(height: 8), Text( - 'Email cannot be changed', + 'Phone number cannot be changed', style: TextStyle( fontSize: 12, color: Colors.grey[600], diff --git a/frontend/lib/screens/pages/profile_page.dart b/frontend/lib/screens/pages/profile_page.dart index 77f07be..1be4e0b 100644 --- a/frontend/lib/screens/pages/profile_page.dart +++ b/frontend/lib/screens/pages/profile_page.dart @@ -13,6 +13,7 @@ import '../edit_profile_screen.dart'; import '../support_screen.dart'; import '../information_screen.dart'; import '../notifications_settings_screen.dart'; +import '../create_invitation_screen.dart'; class ProfilePage extends StatefulWidget { @override @@ -752,9 +753,11 @@ class _SettingsPageState extends State { width: double.infinity, height: 56, child: ElevatedButton.icon( - onPressed: () => _showCreateAccountDialog(context), - icon: Icon(Icons.person_add, size: 20), - label: Text('Create an Account'), + onPressed: () => Navigator.of(context).push( + MaterialPageRoute(builder: (context) => CreateInvitationScreen()), + ), + icon: Icon(Icons.confirmation_number_outlined, size: 20), + label: Text('Create Invitation Code'), style: ElevatedButton.styleFrom( backgroundColor: Color(0xFF6A4C93), foregroundColor: Colors.white, diff --git a/frontend/lib/services/auth_service.dart b/frontend/lib/services/auth_service.dart index d9e591d..df42a50 100644 --- a/frontend/lib/services/auth_service.dart +++ b/frontend/lib/services/auth_service.dart @@ -12,7 +12,7 @@ class AuthService { static const String _userDataKey = 'user_data'; static Future> login( - String emailOrUsername, + String phoneNumberOrUsername, String password, ) async { try { @@ -20,7 +20,7 @@ class AuthService { Uri.parse('${ApiConstants.baseUrl}${ApiConstants.loginEndpoint}'), headers: {'Content-Type': 'application/json'}, body: jsonEncode({ - 'emailOrUsername': emailOrUsername, + 'phoneNumberOrUsername': phoneNumberOrUsername, 'password': password, }), ); @@ -57,6 +57,10 @@ class AuthService { return await _storage.read(key: _tokenKey); } + static Future saveToken(String token) async { + await _storage.write(key: _tokenKey, value: token); + } + static Future isLoggedIn() async { final token = await getToken(); return token != null && token.isNotEmpty; diff --git a/frontend/lib/services/notification_service.dart b/frontend/lib/services/notification_service.dart index 952c79e..455c5b1 100644 --- a/frontend/lib/services/notification_service.dart +++ b/frontend/lib/services/notification_service.dart @@ -3,6 +3,7 @@ import 'package:flutter/foundation.dart'; import 'dart:convert'; import 'package:http/http.dart' as http; import 'package:googleapis_auth/auth_io.dart'; +import 'package:flutter/services.dart' show rootBundle; class NotificationService { static final NotificationService _instance = NotificationService._internal(); @@ -17,22 +18,8 @@ class NotificationService { // Firebase project configuration static const String _projectId = 'wesalapp-bc676'; - // Service account credentials (JSON string) - static const String _serviceAccountJson = ''' -{ - "type": "service_account", - "project_id": "wesalapp-bc676", - "private_key_id": "90f1ac73e8f8b59e5ad7b0caed638ffa57675d39", - "private_key": "-----BEGIN PRIVATE KEY-----\\nMIIEvAIBADANBgkqhkiG9w0BAQEFAASCBKYwggSiAgEAAoIBAQCe/p4CkGeDft2F\\nLMDNyjbU0g1mLQ4bhaqy2WOHr8BX7kHt/WlpB3WeaSUItW2cXFtXhI66mW7Ejlg7\\n/Los4YLpVXner4P5Vj7sOVrhD1Jt9qmdsEjfsTaKs0t06tfUE9PdFqBl6F5HG9B1\\nkwdV2mQfiurpTb+zUXLpfvMbGT1ny7gJSXhYpRaC6UJUy7DNsxeChviXy1tPGr/r\\nmIaENt0KAZzRSSCp0bzN+gKoAm6qyOGYJ9++HlGXTDYmkGPkdc0lbSWjA2/+K7zR\\nWQNy4oOXCstdHL4lMp87UT8lL1ZnntROELyTBUslmYywxXtZinkOgBrWeUifqLW4\\nbcHy6jJ5AgMBAAECggEACSBwjZEggAneSXDCOI3tC9Zq8nyPnMDVhaK49eb+0Y1Z\\nt4GedWr6M3exqohPnHQowiNX1hpMo3fQVNEzFrRzQVWow0Gr/7oVrpW0Q8sPXkSU\\ng/rElCKmENwt7q40aXYh6UUNPAxUrRxJoRYpi6IXsT/WMEJISNDaGdExv1J5leWi\\no8Op2AhREV/ukFpTnNfzfWKjXN+i3psNCYqZAdAh+a4ZJH0vNpiaCq6uVFw7HzdR\\nF2mMha+MYtp2VupzDJ8DkL4ExQl1KCOCllahzqVrhqmEhhhTfDxPOj5q24Hnfn1p\\npzR+fC8Ex0dGB/j+9jKjQyilo/LzEJdrPxt/9QUdiQKBgQDQ2L7sQsEIihVUq3lW\\n3Od2GNjnloTo24rNLl8hxwgl9EA2VnG50gZPGAkcUA2eeA23T6C6gPbm0QBsfqSP\\nPNTbd6UYF508EE7PMaScFoJMcKDin8x4q5tfVjgao2r8LCOUXfU1ktreQR3bIMKk\\nsgsgBazfBT84ioxvDwoD+4EJqwKBgQDC5HEfouCTxvKwzmpxZ+kkHKPO5QjyxnOH\\nLnt/7jx5t7T1nWNUnusYj+uowaqKAmLz7kBhCbRGADdKuBAr0hY/aEOG3zhTH35K\\nc+8wJ3yDFkh8BhFsOYCxopIPAjEGxw5cnM4+r8QDqy61j4hsR9kSr40WwhRuSxu+\\nHqe38Vl4awKBgBYFJGxIxY2e8YzR36NW+1iqWgRhDHZ433Ou1fz7vVIzJKoWBzuu\\nd1fTkvJXRnhU9C1Fyg6gFmhT1RWbbMJliZPyU4fsxXlVxtl1xINopChnH6+FZcu7\\nXFB7CMNWQ6t/A+la1sXlTApvFzTJiXxQAXhI4OdK6FWP1irHjSjKVdqtAoGAcLQA\\ngyYCrxKux/YmcfyAQ2TYic3DNfnzVypXOuz/RfgpipwAlC/ujl60Dfwo7fRhWuTd\\nkAA3ov9++hOlLmIogXR/EGDHxrIAq3eNy5AaHghl1GsB6k76kD8OLeW7ikrUkFQR\\npQip1uFIerBNWSjXbEne0llbzUhb+77oiKPmdI8CgYBRBIUC4d/T3cgmF9uRqlpL\\nSsa8IxqohU1huAucf5UqNP9OXZ4TtZhM0PbR5SNHcGBXAl+XowCtUeCE9LlWWzpg\\ne/xTu4Mu1hwnRZ8ybujAyTPnN8KEfK8HDjORZnxzzdyPkO4BN+KOH6WZyKhKDTuR\\n6KCch1ooA8YlV43vchpKXg==\\n-----END PRIVATE KEY-----\\n", - "client_email": "firebase-adminsdk-fbsvc@wesalapp-bc676.iam.gserviceaccount.com", - "client_id": "112586481303824467416", - "auth_uri": "https://accounts.google.com/o/oauth2/auth", - "token_uri": "https://oauth2.googleapis.com/token", - "auth_provider_x509_cert_url": "https://www.googleapis.com/oauth2/v1/certs", - "client_x509_cert_url": "https://www.googleapis.com/robot/v1/metadata/x509/firebase-adminsdk-fbsvc%40wesalapp-bc676.iam.gserviceaccount.com", - "universe_domain": "googleapis.com" -} -'''; + // Service account credentials loaded from file + static String? _serviceAccountJson; static const List topics = [ 'all', @@ -257,10 +244,30 @@ class NotificationService { } } + Future _loadServiceAccountCredentials() async { + if (_serviceAccountJson != null) return; + + try { + _serviceAccountJson = await rootBundle.loadString( + 'firebase-service-account.json', + ); + } catch (e) { + print('Error loading service account credentials: $e'); + _serviceAccountJson = null; + } + } + Future _getAccessToken() async { try { + await _loadServiceAccountCredentials(); + + if (_serviceAccountJson == null) { + print('Service account credentials not loaded'); + return null; + } + final credentials = ServiceAccountCredentials.fromJson( - _serviceAccountJson, + _serviceAccountJson!, ); final client = await clientViaServiceAccount(credentials, [ 'https://www.googleapis.com/auth/firebase.messaging',