Merge pull request #16 from sBubshait/feature/phone-signin

Feature/phone signin
This commit is contained in:
Saleh Bubshait 2025-08-07 04:07:34 +03:00 committed by GitHub
commit 52ddacd31c
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
25 changed files with 1269 additions and 197 deletions

View File

@ -55,7 +55,7 @@ public class SecurityConfig {
.csrf(csrf -> csrf.disable()) .csrf(csrf -> csrf.disable())
.sessionManagement(session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS)) .sessionManagement(session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
.authorizeHttpRequests(auth -> auth .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") .requestMatchers("/admin/**").hasRole("ADMIN")
.anyRequest().authenticated() .anyRequest().authenticated()
) )

View File

@ -8,9 +8,15 @@ import online.wesal.wesal.dto.LoginRequest;
import online.wesal.wesal.dto.LoginResponse; import online.wesal.wesal.dto.LoginResponse;
import online.wesal.wesal.dto.UpdateUserRequest; import online.wesal.wesal.dto.UpdateUserRequest;
import online.wesal.wesal.dto.UsernameSelectionRequest; 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.User;
import online.wesal.wesal.entity.InvitationCode;
import online.wesal.wesal.service.AuthService; import online.wesal.wesal.service.AuthService;
import online.wesal.wesal.service.UserService; 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.beans.factory.annotation.Autowired;
import org.springframework.http.ResponseEntity; import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*; import org.springframework.web.bind.annotation.*;
@ -29,6 +35,12 @@ public class AuthController {
@Autowired @Autowired
private UserService userService; private UserService userService;
@Autowired
private InvitationCodeService invitationCodeService;
@Autowired
private JwtUtil jwtUtil;
@GetMapping("/") @GetMapping("/")
@Operation(summary = "Health check", description = "Returns API status") @Operation(summary = "Health check", description = "Returns API status")
public ResponseEntity<Map<String, Integer>> healthCheck() { public ResponseEntity<Map<String, Integer>> healthCheck() {
@ -36,10 +48,10 @@ public class AuthController {
} }
@PostMapping("/login") @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<LoginResponse> login(@Valid @RequestBody LoginRequest loginRequest) { public ResponseEntity<LoginResponse> login(@Valid @RequestBody LoginRequest loginRequest) {
try { try {
String token = authService.authenticate(loginRequest.getEmailOrUsername(), loginRequest.getPassword()); String token = authService.authenticate(loginRequest.getPhoneNumberOrUsername(), loginRequest.getPassword());
return ResponseEntity.ok(new LoginResponse(token)); return ResponseEntity.ok(new LoginResponse(token));
} catch (RuntimeException e) { } catch (RuntimeException e) {
return ResponseEntity.badRequest().build(); return ResponseEntity.badRequest().build();
@ -84,7 +96,7 @@ public class AuthController {
// Create user data without password // Create user data without password
Map<String, Object> userData = Map.of( Map<String, Object> userData = Map.of(
"id", user.getId(), "id", user.getId(),
"email", user.getEmail(), "phoneNumber", user.getPhoneNumber(),
"username", user.getUsername() != null ? user.getUsername() : "", "username", user.getUsername() != null ? user.getUsername() : "",
"displayName", user.getDisplayName(), "displayName", user.getDisplayName(),
"avatar", user.getAvatar() != null ? user.getAvatar() : "", "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<Map<String, Object>> 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<Map<String, Object>> 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") @PostMapping("/admin/createUser")
@Operation(summary = "Create new user (Admin only)", description = "Creates a new user - requires admin privileges") @Operation(summary = "Create new user (Admin only)", description = "Creates a new user - requires admin privileges")
public ResponseEntity<?> createUser(@Valid @RequestBody CreateUserRequest request) { public ResponseEntity<?> createUser(@Valid @RequestBody CreateUserRequest request) {
try { 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); return ResponseEntity.ok(user);
} catch (RuntimeException e) { } catch (RuntimeException e) {
return ResponseEntity.badRequest().body(Map.of("error", e.getMessage())); 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<Map<String, Object>> 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
));
}
}
} }

View File

@ -51,8 +51,8 @@ public class InvitationController {
} }
try { try {
String userEmail = authentication.getName(); String userPhoneNumber = authentication.getName();
InvitationResponse response = invitationService.createInvitation(request, userEmail); InvitationResponse response = invitationService.createInvitation(request, userPhoneNumber);
return ResponseEntity.ok(ApiResponse.success(response)); return ResponseEntity.ok(ApiResponse.success(response));
} catch (RuntimeException e) { } catch (RuntimeException e) {
String message; 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") @Operation(summary = "Get all categorized invitations", description = "Get invitations in three categories: created, accepted, and available")
public ResponseEntity<ApiResponse<CategorizedInvitationsResponse>> getAllCategorizedInvitations(Authentication authentication) { public ResponseEntity<ApiResponse<CategorizedInvitationsResponse>> getAllCategorizedInvitations(Authentication authentication) {
try { try {
String userEmail = authentication.getName(); String userPhoneNumber = authentication.getName();
CategorizedInvitationsResponse response = invitationService.getAllCategorizedInvitations(userEmail); CategorizedInvitationsResponse response = invitationService.getAllCategorizedInvitations(userPhoneNumber);
return ResponseEntity.ok(ApiResponse.success(response)); return ResponseEntity.ok(ApiResponse.success(response));
} catch (RuntimeException e) { } catch (RuntimeException e) {
return ResponseEntity.badRequest().body(ApiResponse.error(e.getMessage())); return ResponseEntity.badRequest().body(ApiResponse.error(e.getMessage()));
@ -104,8 +104,8 @@ public class InvitationController {
Authentication authentication) { Authentication authentication) {
try { try {
String userEmail = authentication.getName(); String userPhoneNumber = authentication.getName();
invitationService.acceptInvitation(request.getId(), userEmail); invitationService.acceptInvitation(request.getId(), userPhoneNumber);
return ResponseEntity.ok(new ApiResponse<>(true)); return ResponseEntity.ok(new ApiResponse<>(true));
} catch (RuntimeException e) { } catch (RuntimeException e) {
return ResponseEntity.badRequest().body(ApiResponse.error(e.getMessage())); return ResponseEntity.badRequest().body(ApiResponse.error(e.getMessage()));
@ -121,8 +121,8 @@ public class InvitationController {
Authentication authentication) { Authentication authentication) {
try { try {
String userEmail = authentication.getName(); String userPhoneNumber = authentication.getName();
invitationService.cancelInvitation(request.getId(), userEmail); invitationService.cancelInvitation(request.getId(), userPhoneNumber);
return ResponseEntity.ok(new ApiResponse<>(true)); return ResponseEntity.ok(new ApiResponse<>(true));
} catch (RuntimeException e) { } catch (RuntimeException e) {
return ResponseEntity.badRequest().body(ApiResponse.error(e.getMessage())); return ResponseEntity.badRequest().body(ApiResponse.error(e.getMessage()));

View File

@ -89,7 +89,7 @@ public class PostController {
Long targetUserId; Long targetUserId;
if (id == null) { if (id == null) {
// Use authenticated user's ID when no id is provided // Use authenticated user's ID when no id is provided
String userEmail = authentication.getName(); String userPhoneNumber = authentication.getName();
targetUserId = userService.getCurrentUser().getId(); targetUserId = userService.getCurrentUser().getId();
} else { } else {
if (id <= 0) { if (id <= 0) {

View File

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

View File

@ -1,14 +1,14 @@
package online.wesal.wesal.dto; package online.wesal.wesal.dto;
import jakarta.validation.constraints.Email;
import jakarta.validation.constraints.NotBlank; import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.Size; import jakarta.validation.constraints.Size;
import jakarta.validation.constraints.Pattern;
public class CreateUserRequest { public class CreateUserRequest {
@Email
@NotBlank @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 @NotBlank
@Size(min = 6) @Size(min = 6)
@ -19,18 +19,18 @@ public class CreateUserRequest {
public CreateUserRequest() {} public CreateUserRequest() {}
public CreateUserRequest(String email, String password, String displayName) { public CreateUserRequest(String phoneNumber, String password, String displayName) {
this.email = email; this.phoneNumber = phoneNumber;
this.password = password; this.password = password;
this.displayName = displayName; this.displayName = displayName;
} }
public String getEmail() { public String getPhoneNumber() {
return email; return phoneNumber;
} }
public void setEmail(String email) { public void setPhoneNumber(String phoneNumber) {
this.email = email; this.phoneNumber = phoneNumber;
} }
public String getPassword() { public String getPassword() {

View File

@ -5,24 +5,24 @@ import jakarta.validation.constraints.NotBlank;
public class LoginRequest { public class LoginRequest {
@NotBlank @NotBlank
private String emailOrUsername; private String phoneNumberOrUsername;
@NotBlank @NotBlank
private String password; private String password;
public LoginRequest() {} public LoginRequest() {}
public LoginRequest(String emailOrUsername, String password) { public LoginRequest(String phoneNumberOrUsername, String password) {
this.emailOrUsername = emailOrUsername; this.phoneNumberOrUsername = phoneNumberOrUsername;
this.password = password; this.password = password;
} }
public String getEmailOrUsername() { public String getPhoneNumberOrUsername() {
return emailOrUsername; return phoneNumberOrUsername;
} }
public void setEmailOrUsername(String emailOrUsername) { public void setPhoneNumberOrUsername(String phoneNumberOrUsername) {
this.emailOrUsername = emailOrUsername; this.phoneNumberOrUsername = phoneNumberOrUsername;
} }
public String getPassword() { public String getPassword() {

View File

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

View File

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

View File

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

View File

@ -1,9 +1,9 @@
package online.wesal.wesal.entity; package online.wesal.wesal.entity;
import jakarta.persistence.*; import jakarta.persistence.*;
import jakarta.validation.constraints.Email;
import jakarta.validation.constraints.NotBlank; import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.Size; import jakarta.validation.constraints.Size;
import jakarta.validation.constraints.Pattern;
import com.fasterxml.jackson.annotation.JsonProperty; import com.fasterxml.jackson.annotation.JsonProperty;
import com.fasterxml.jackson.core.type.TypeReference; import com.fasterxml.jackson.core.type.TypeReference;
import com.fasterxml.jackson.databind.ObjectMapper; import com.fasterxml.jackson.databind.ObjectMapper;
@ -19,9 +19,9 @@ public class User {
private Long id; private Long id;
@Column(unique = true, nullable = false) @Column(unique = true, nullable = false)
@Email
@NotBlank @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) @Column(unique = true)
private String username; private String username;
@ -46,12 +46,12 @@ public class User {
private String role = "USER"; private String role = "USER";
@Column(columnDefinition = "TEXT") @Column(columnDefinition = "TEXT")
private String subscriptions = "[\"newinvites\",\"invitesfollowup\",\"appnews\"]"; private String subscriptions = "[\"postcomments\",\"newinvites\",\"invitesfollowup\",\"appnews\"]";
public User() {} public User() {}
public User(String email, String password, String displayName) { public User(String phoneNumber, String password, String displayName) {
this.email = email; this.phoneNumber = phoneNumber;
this.password = password; this.password = password;
this.displayName = displayName; this.displayName = displayName;
} }
@ -64,12 +64,12 @@ public class User {
this.id = id; this.id = id;
} }
public String getEmail() { public String getPhoneNumber() {
return email; return phoneNumber;
} }
public void setEmail(String email) { public void setPhoneNumber(String phoneNumber) {
this.email = email; this.phoneNumber = phoneNumber;
} }
public String getUsername() { public String getUsername() {
@ -130,13 +130,13 @@ public class User {
public List<String> getSubscriptions() { public List<String> getSubscriptions() {
if (subscriptions == null || subscriptions.isEmpty()) { if (subscriptions == null || subscriptions.isEmpty()) {
return Arrays.asList("newinvites", "invitesfollowup", "appnews"); return Arrays.asList("postcomments", "newinvites", "invitesfollowup", "appnews");
} }
try { try {
ObjectMapper mapper = new ObjectMapper(); ObjectMapper mapper = new ObjectMapper();
return mapper.readValue(subscriptions, new TypeReference<List<String>>() {}); return mapper.readValue(subscriptions, new TypeReference<List<String>>() {});
} catch (Exception e) { } 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(); ObjectMapper mapper = new ObjectMapper();
this.subscriptions = mapper.writeValueAsString(subscriptions); this.subscriptions = mapper.writeValueAsString(subscriptions);
} catch (Exception e) { } catch (Exception e) {
this.subscriptions = "[\"newinvites\",\"invitesfollowup\",\"appnews\"]"; this.subscriptions = "[\"postcomments\",\"newinvites\",\"invitesfollowup\",\"appnews\"]";
} }
} }
} }

View File

@ -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<InvitationCode, Long> {
Optional<InvitationCode> findByCode(String code);
boolean existsByCode(String code);
@Query("SELECT ic FROM InvitationCode ic WHERE ic.code = :code AND ic.expirationDate > :currentTime")
Optional<InvitationCode> findValidCodeByCode(@Param("code") String code, @Param("currentTime") LocalDateTime currentTime);
}

View File

@ -11,9 +11,9 @@ import java.util.Optional;
@Repository @Repository
public interface UserRepository extends JpaRepository<User, Long> { public interface UserRepository extends JpaRepository<User, Long> {
Optional<User> findByEmail(String email); Optional<User> findByPhoneNumber(String phoneNumber);
Optional<User> findByUsername(String username); Optional<User> findByUsername(String username);
boolean existsByEmail(String email); boolean existsByPhoneNumber(String phoneNumber);
boolean existsByUsername(String username); boolean existsByUsername(String username);
@Query("SELECT u FROM User u WHERE u.subscriptions LIKE %:subscription% AND u.fcmToken IS NOT NULL AND u.fcmToken != ''") @Query("SELECT u FROM User u WHERE u.subscriptions LIKE %:subscription% AND u.fcmToken IS NOT NULL AND u.fcmToken != ''")

View File

@ -21,23 +21,23 @@ public class AuthService {
@Autowired @Autowired
private JwtUtil jwtUtil; private JwtUtil jwtUtil;
public String authenticate(String emailOrUsername, String password) { public String authenticate(String phoneNumberOrUsername, String password) {
Optional<User> userOpt = userRepository.findByEmail(emailOrUsername); Optional<User> userOpt = userRepository.findByPhoneNumber(phoneNumberOrUsername);
if (userOpt.isEmpty()) { if (userOpt.isEmpty()) {
userOpt = userRepository.findByUsername(emailOrUsername); userOpt = userRepository.findByUsername(phoneNumberOrUsername);
} }
if (userOpt.isPresent()) { if (userOpt.isPresent()) {
User user = userOpt.get(); User user = userOpt.get();
System.out.println("Authenticating user: " + user.getEmail()); System.out.println("Authenticating user: " + user.getPhoneNumber());
if (passwordEncoder.matches(password, user.getPassword())) { if (passwordEncoder.matches(password, user.getPassword())) {
System.out.println("Password matches!"); System.out.println("Password matches!");
if (!user.isActivated()) { if (!user.isActivated()) {
user.setActivated(true); user.setActivated(true);
userRepository.save(user); userRepository.save(user);
} }
return jwtUtil.generateToken(user.getEmail()); return jwtUtil.generateToken(user.getPhoneNumber());
} }
} }

View File

@ -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<InvitationCode> invitationCodeOpt = invitationCodeRepository.findValidCodeByCode(code, LocalDateTime.now());
return invitationCodeOpt.isPresent();
}
public Optional<InvitationCode> 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<InvitationCode> 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();
}
}

View File

@ -42,8 +42,8 @@ public class InvitationService {
private SubscriptionNotificationService subscriptionNotificationService; private SubscriptionNotificationService subscriptionNotificationService;
@Transactional @Transactional
public InvitationResponse createInvitation(CreateInvitationRequest request, String userEmail) { public InvitationResponse createInvitation(CreateInvitationRequest request, String userPhoneNumber) {
User creator = userRepository.findByEmail(userEmail) User creator = userRepository.findByPhoneNumber(userPhoneNumber)
.orElseThrow(() -> new RuntimeException("User not found")); .orElseThrow(() -> new RuntimeException("User not found"));
Tag tag = tagRepository.findById(request.getTagId()) Tag tag = tagRepository.findById(request.getTagId())
@ -85,8 +85,8 @@ public class InvitationService {
.collect(Collectors.toList()); .collect(Collectors.toList());
} }
public CategorizedInvitationsResponse getAllCategorizedInvitations(String userEmail) { public CategorizedInvitationsResponse getAllCategorizedInvitations(String userPhoneNumber) {
User user = userRepository.findByEmail(userEmail) User user = userRepository.findByPhoneNumber(userPhoneNumber)
.orElseThrow(() -> new RuntimeException("Authentication error. Please log in again")); .orElseThrow(() -> new RuntimeException("Authentication error. Please log in again"));
List<InvitationResponse> created = invitationRepository.findByCreatorIdOrderByCreationDateDesc(user.getId()) List<InvitationResponse> created = invitationRepository.findByCreatorIdOrderByCreationDateDesc(user.getId())
@ -110,8 +110,8 @@ public class InvitationService {
} }
@Transactional @Transactional
public void acceptInvitation(Long invitationId, String userEmail) { public void acceptInvitation(Long invitationId, String userPhoneNumber) {
User user = userRepository.findByEmail(userEmail) User user = userRepository.findByPhoneNumber(userPhoneNumber)
.orElseThrow(() -> new RuntimeException("Authentication error. Please log in again")); .orElseThrow(() -> new RuntimeException("Authentication error. Please log in again"));
Invitation invitation = invitationRepository.findById(invitationId) Invitation invitation = invitationRepository.findById(invitationId)
@ -171,8 +171,8 @@ public class InvitationService {
} }
@Transactional @Transactional
public void cancelInvitation(Long invitationId, String userEmail) { public void cancelInvitation(Long invitationId, String userPhoneNumber) {
User user = userRepository.findByEmail(userEmail) User user = userRepository.findByPhoneNumber(userPhoneNumber)
.orElseThrow(() -> new RuntimeException("Authentication error. Please log in again")); .orElseThrow(() -> new RuntimeException("Authentication error. Please log in again"));
Invitation invitation = invitationRepository.findById(invitationId) Invitation invitation = invitationRepository.findById(invitationId)

View File

@ -20,15 +20,15 @@ public class UserDetailsServiceImpl implements UserDetailsService {
private UserRepository userRepository; private UserRepository userRepository;
@Override @Override
public UserDetails loadUserByUsername(String email) throws UsernameNotFoundException { public UserDetails loadUserByUsername(String phoneNumber) throws UsernameNotFoundException {
User user = userRepository.findByEmail(email) User user = userRepository.findByPhoneNumber(phoneNumber)
.orElseThrow(() -> new UsernameNotFoundException("User not found: " + email)); .orElseThrow(() -> new UsernameNotFoundException("User not found: " + phoneNumber));
Collection<GrantedAuthority> authorities = new ArrayList<>(); Collection<GrantedAuthority> authorities = new ArrayList<>();
authorities.add(new SimpleGrantedAuthority("ROLE_" + user.getRole())); authorities.add(new SimpleGrantedAuthority("ROLE_" + user.getRole()));
return new org.springframework.security.core.userdetails.User( return new org.springframework.security.core.userdetails.User(
user.getEmail(), user.getPhoneNumber(),
user.getPassword(), user.getPassword(),
authorities authorities
); );

View File

@ -20,8 +20,8 @@ public class UserService {
public User getCurrentUser() { public User getCurrentUser() {
Authentication authentication = SecurityContextHolder.getContext().getAuthentication(); Authentication authentication = SecurityContextHolder.getContext().getAuthentication();
String email = authentication.getName(); String phoneNumber = authentication.getName();
return userRepository.findByEmail(email) return userRepository.findByPhoneNumber(phoneNumber)
.orElseThrow(() -> new RuntimeException("User not found")); .orElseThrow(() -> new RuntimeException("User not found"));
} }
@ -44,16 +44,16 @@ public class UserService {
return "ADMIN".equals(user.getRole()); 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()) { if (!isCurrentUserAdmin()) {
throw new RuntimeException("Access denied: Admin privileges required"); throw new RuntimeException("Access denied: Admin privileges required");
} }
if (userRepository.existsByEmail(email)) { if (userRepository.existsByPhoneNumber(phoneNumber)) {
throw new RuntimeException("User with this email already exists"); 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); return userRepository.save(user);
} }

View File

@ -3,6 +3,8 @@ class ApiConstants {
// Auth endpoints // Auth endpoints
static const String loginEndpoint = '/login'; static const String loginEndpoint = '/login';
static const String registerEndpoint = '/register';
static const String checkInvitationEndpoint = '/checkInvitation';
// User endpoints // User endpoints
static const String getUserEndpoint = '/getUser'; static const String getUserEndpoint = '/getUser';

View File

@ -7,6 +7,8 @@ import 'screens/edit_profile_screen.dart';
import 'services/auth_service.dart'; import 'services/auth_service.dart';
import 'services/user_service.dart'; import 'services/user_service.dart';
import 'services/app_lifecycle_service.dart'; import 'services/app_lifecycle_service.dart';
import 'services/http_service.dart';
import 'dart:convert';
final GlobalKey<NavigatorState> navigatorKey = GlobalKey<NavigatorState>(); final GlobalKey<NavigatorState> navigatorKey = GlobalKey<NavigatorState>();
@ -260,10 +262,14 @@ class SignInPage extends StatefulWidget {
class _SignInPageState extends State<SignInPage> { class _SignInPageState extends State<SignInPage> {
final _formKey = GlobalKey<FormState>(); final _formKey = GlobalKey<FormState>();
final _emailController = TextEditingController(); final _phoneController = TextEditingController();
final _passwordController = TextEditingController(); final _passwordController = TextEditingController();
final _invitationCodeController = TextEditingController();
final _countryCodeController = TextEditingController(text: '+966');
bool _isPasswordVisible = false; bool _isPasswordVisible = false;
bool _isLoading = false; bool _isLoading = false;
bool _isRegistering = false;
int _registrationStep = 1; // 1 = invitation code, 2 = phone & password
void _showHelpBottomSheet() { void _showHelpBottomSheet() {
showModalBottomSheet( showModalBottomSheet(
@ -297,7 +303,7 @@ class _SignInPageState extends State<SignInPage> {
), ),
SizedBox(height: 16), SizedBox(height: 16),
Text( 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, textAlign: TextAlign.center,
style: TextStyle( style: TextStyle(
fontSize: 16, fontSize: 16,
@ -331,10 +337,51 @@ class _SignInPageState extends State<SignInPage> {
); );
} }
Future<void> _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 @override
void dispose() { void dispose() {
_emailController.dispose(); _phoneController.dispose();
_passwordController.dispose(); _passwordController.dispose();
_invitationCodeController.dispose();
_countryCodeController.dispose();
super.dispose(); super.dispose();
} }
@ -344,8 +391,10 @@ class _SignInPageState extends State<SignInPage> {
_isLoading = true; _isLoading = true;
}); });
final phoneNumber =
_countryCodeController.text.trim() + _phoneController.text.trim();
final result = await AuthService.login( final result = await AuthService.login(
_emailController.text.trim(), phoneNumber,
_passwordController.text, _passwordController.text,
); );
@ -386,11 +435,83 @@ class _SignInPageState extends State<SignInPage> {
} }
} }
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) { void _showErrorAlert(String message) {
showDialog( showDialog(
context: context, context: context,
builder: (context) => AlertDialog( builder: (context) => AlertDialog(
title: Text('Login Failed'), title: Text(_isRegistering ? 'Registration' : 'Login Failed'),
content: Text(message), content: Text(message),
actions: [ actions: [
TextButton( TextButton(
@ -430,7 +551,7 @@ class _SignInPageState extends State<SignInPage> {
icon: Icon(Icons.arrow_back, color: Colors.white), icon: Icon(Icons.arrow_back, color: Colors.white),
), ),
Text( Text(
'Sign In', _isRegistering ? 'Register' : 'Sign In',
style: TextStyle( style: TextStyle(
fontSize: 20, fontSize: 20,
fontWeight: FontWeight.w600, fontWeight: FontWeight.w600,
@ -482,7 +603,9 @@ class _SignInPageState extends State<SignInPage> {
// Welcome text // Welcome text
Text( Text(
'Welcome Back!', _isRegistering
? 'Create Account'
: 'Welcome Back!',
style: TextStyle( style: TextStyle(
fontSize: 24, fontSize: 24,
fontWeight: FontWeight.bold, fontWeight: FontWeight.bold,
@ -494,7 +617,9 @@ class _SignInPageState extends State<SignInPage> {
SizedBox(height: 8), SizedBox(height: 8),
Text( 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( style: TextStyle(
fontSize: 16, fontSize: 16,
color: Colors.grey[600], color: Colors.grey[600],
@ -504,100 +629,201 @@ class _SignInPageState extends State<SignInPage> {
SizedBox(height: 40), SizedBox(height: 40),
// Email field // Registration Step 1: Invitation Code
TextFormField( if (_isRegistering && _registrationStep == 1) ...[
controller: _emailController, TextFormField(
keyboardType: TextInputType.emailAddress, controller: _invitationCodeController,
decoration: InputDecoration( decoration: InputDecoration(
labelText: 'Email', labelText: 'Invitation Code',
prefixIcon: Icon(Icons.email_outlined), prefixIcon: Icon(
border: OutlineInputBorder( Icons.confirmation_number_outlined,
borderRadius: BorderRadius.circular(12),
),
focusedBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(12),
borderSide: BorderSide(
color: Color(0xFF6A4C93),
), ),
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), // Login or Registration Step 2: Phone & Password
if (!_isRegistering || _registrationStep == 2) ...[
// Password field // Phone number field with country code input
TextFormField( Row(
controller: _passwordController, children: [
obscureText: !_isPasswordVisible, SizedBox(
decoration: InputDecoration( width: 100,
labelText: 'Password', child: TextFormField(
prefixIcon: Icon(Icons.lock_outline), controller: _countryCodeController,
suffixIcon: IconButton( keyboardType: TextInputType.phone,
icon: Icon( decoration: InputDecoration(
_isPasswordVisible labelText: 'Code',
? Icons.visibility_off border: OutlineInputBorder(
: Icons.visibility, 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: () { SizedBox(width: 12),
setState(() { Expanded(
_isPasswordVisible = !_isPasswordVisible; child: TextFormField(
}); controller: _phoneController,
}, keyboardType: TextInputType.phone,
), decoration: InputDecoration(
border: OutlineInputBorder( labelText: 'Phone Number',
borderRadius: BorderRadius.circular(12), prefixIcon: Icon(Icons.phone_outlined),
), border: OutlineInputBorder(
focusedBorder: OutlineInputBorder( borderRadius: BorderRadius.circular(
borderRadius: BorderRadius.circular(12), 12,
borderSide: BorderSide( ),
color: Color(0xFF6A4C93), ),
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'; // Password field (only for login or registration step 2)
} if (!_isRegistering || _registrationStep == 2) ...[
if (value.length < 6) { SizedBox(height: 20),
return 'Password must be at least 6 characters';
} TextFormField(
return null; 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), SizedBox(height: 12),
// Forgot password // Forgot password (only show for login)
Align( if (!_isRegistering)
alignment: Alignment.centerRight, Align(
child: TextButton( alignment: Alignment.centerRight,
onPressed: _showHelpBottomSheet, child: TextButton(
child: Text( onPressed: _showHelpBottomSheet,
'Forgot Password?', child: Text(
style: TextStyle( 'Forgot Password?',
color: Color(0xFF6A4C93), style: TextStyle(
fontWeight: FontWeight.w600, color: Color(0xFF6A4C93),
fontWeight: FontWeight.w600,
),
), ),
), ),
), ),
),
SizedBox(height: 30), SizedBox(height: 30),
// Sign in button // Action button based on current state
Container( Container(
height: 56, height: 56,
child: ElevatedButton( child: ElevatedButton(
onPressed: _isLoading ? null : _handleSignIn, onPressed: _isLoading
? null
: () {
if (!_isRegistering) {
_handleSignIn();
} else if (_registrationStep == 1) {
_checkInvitationCode();
} else {
_handleRegister();
}
},
style: ElevatedButton.styleFrom( style: ElevatedButton.styleFrom(
backgroundColor: Color(0xFF6A4C93), backgroundColor: Color(0xFF6A4C93),
foregroundColor: Colors.white, foregroundColor: Colors.white,
@ -614,7 +840,11 @@ class _SignInPageState extends State<SignInPage> {
), ),
) )
: Text( : Text(
'Sign In', !_isRegistering
? 'Sign In'
: (_registrationStep == 1
? 'Continue'
: 'Register'),
style: TextStyle( style: TextStyle(
fontSize: 18, fontSize: 18,
fontWeight: FontWeight.w600, fontWeight: FontWeight.w600,
@ -623,28 +853,86 @@ class _SignInPageState extends State<SignInPage> {
), ),
), ),
SizedBox(height: 20), // Back button for registration step 2
if (_isRegistering && _registrationStep == 2) ...[
// Contact link for new accounts SizedBox(height: 16),
Row( Container(
mainAxisAlignment: MainAxisAlignment.center, height: 56,
children: [ child: OutlinedButton(
Text( onPressed: () {
"Don't have an account? ", setState(() {
style: TextStyle(color: Colors.grey[600]), _registrationStep = 1;
), _phoneController.clear();
GestureDetector( _passwordController.clear();
onTap: _showHelpBottomSheet, });
},
style: OutlinedButton.styleFrom(
side: BorderSide(color: Color(0xFF6A4C93)),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
),
),
child: Text( child: Text(
'Contact Support', 'Back to Invitation Code',
style: TextStyle( style: TextStyle(
color: Color(0xFF6A4C93), fontSize: 16,
fontWeight: FontWeight.w600, 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), SizedBox(height: 40),
], ],

View File

@ -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<CreateInvitationScreen> {
bool _isLoading = false;
Map<String, dynamic>? _createdInvitation;
Future<void> _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<void> _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<Color>(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,
),
),
],
);
}
}

View File

@ -21,7 +21,7 @@ class _EditProfileScreenState extends State<EditProfileScreen> {
final _formKey = GlobalKey<FormState>(); final _formKey = GlobalKey<FormState>();
final TextEditingController _displayNameController = TextEditingController(); final TextEditingController _displayNameController = TextEditingController();
final TextEditingController _usernameController = TextEditingController(); final TextEditingController _usernameController = TextEditingController();
final TextEditingController _emailController = TextEditingController(); final TextEditingController _phoneController = TextEditingController();
bool _isLoading = false; bool _isLoading = false;
@override @override
@ -33,7 +33,7 @@ class _EditProfileScreenState extends State<EditProfileScreen> {
void _initializeFields() { void _initializeFields() {
if (widget.userData != null) { if (widget.userData != null) {
_displayNameController.text = widget.userData!['displayName'] ?? ''; _displayNameController.text = widget.userData!['displayName'] ?? '';
_emailController.text = widget.userData!['email'] ?? ''; _phoneController.text = widget.userData!['phoneNumber'] ?? '';
if (!widget.isOnboarding) { if (!widget.isOnboarding) {
_usernameController.text = widget.userData!['username'] ?? ''; _usernameController.text = widget.userData!['username'] ?? '';
} }
@ -44,7 +44,7 @@ class _EditProfileScreenState extends State<EditProfileScreen> {
void dispose() { void dispose() {
_displayNameController.dispose(); _displayNameController.dispose();
_usernameController.dispose(); _usernameController.dispose();
_emailController.dispose(); _phoneController.dispose();
super.dispose(); super.dispose();
} }
@ -299,11 +299,11 @@ class _EditProfileScreenState extends State<EditProfileScreen> {
), ),
SizedBox(height: 8), SizedBox(height: 8),
TextFormField( TextFormField(
controller: _emailController, controller: _phoneController,
readOnly: true, readOnly: true,
decoration: InputDecoration( decoration: InputDecoration(
hintText: 'Email address', hintText: 'Phone number',
prefixIcon: Icon(Icons.email, color: Colors.grey[400]), prefixIcon: Icon(Icons.phone, color: Colors.grey[400]),
border: OutlineInputBorder( border: OutlineInputBorder(
borderRadius: BorderRadius.circular(12), borderRadius: BorderRadius.circular(12),
borderSide: BorderSide(color: Colors.grey[300]!), borderSide: BorderSide(color: Colors.grey[300]!),
@ -328,7 +328,7 @@ class _EditProfileScreenState extends State<EditProfileScreen> {
), ),
SizedBox(height: 8), SizedBox(height: 8),
Text( Text(
'Email cannot be changed', 'Phone number cannot be changed',
style: TextStyle( style: TextStyle(
fontSize: 12, fontSize: 12,
color: Colors.grey[600], color: Colors.grey[600],

View File

@ -13,6 +13,7 @@ import '../edit_profile_screen.dart';
import '../support_screen.dart'; import '../support_screen.dart';
import '../information_screen.dart'; import '../information_screen.dart';
import '../notifications_settings_screen.dart'; import '../notifications_settings_screen.dart';
import '../create_invitation_screen.dart';
class ProfilePage extends StatefulWidget { class ProfilePage extends StatefulWidget {
@override @override
@ -752,9 +753,11 @@ class _SettingsPageState extends State<SettingsPage> {
width: double.infinity, width: double.infinity,
height: 56, height: 56,
child: ElevatedButton.icon( child: ElevatedButton.icon(
onPressed: () => _showCreateAccountDialog(context), onPressed: () => Navigator.of(context).push(
icon: Icon(Icons.person_add, size: 20), MaterialPageRoute(builder: (context) => CreateInvitationScreen()),
label: Text('Create an Account'), ),
icon: Icon(Icons.confirmation_number_outlined, size: 20),
label: Text('Create Invitation Code'),
style: ElevatedButton.styleFrom( style: ElevatedButton.styleFrom(
backgroundColor: Color(0xFF6A4C93), backgroundColor: Color(0xFF6A4C93),
foregroundColor: Colors.white, foregroundColor: Colors.white,

View File

@ -12,7 +12,7 @@ class AuthService {
static const String _userDataKey = 'user_data'; static const String _userDataKey = 'user_data';
static Future<Map<String, dynamic>> login( static Future<Map<String, dynamic>> login(
String emailOrUsername, String phoneNumberOrUsername,
String password, String password,
) async { ) async {
try { try {
@ -20,7 +20,7 @@ class AuthService {
Uri.parse('${ApiConstants.baseUrl}${ApiConstants.loginEndpoint}'), Uri.parse('${ApiConstants.baseUrl}${ApiConstants.loginEndpoint}'),
headers: {'Content-Type': 'application/json'}, headers: {'Content-Type': 'application/json'},
body: jsonEncode({ body: jsonEncode({
'emailOrUsername': emailOrUsername, 'phoneNumberOrUsername': phoneNumberOrUsername,
'password': password, 'password': password,
}), }),
); );
@ -57,6 +57,10 @@ class AuthService {
return await _storage.read(key: _tokenKey); return await _storage.read(key: _tokenKey);
} }
static Future<void> saveToken(String token) async {
await _storage.write(key: _tokenKey, value: token);
}
static Future<bool> isLoggedIn() async { static Future<bool> isLoggedIn() async {
final token = await getToken(); final token = await getToken();
return token != null && token.isNotEmpty; return token != null && token.isNotEmpty;

View File

@ -3,6 +3,7 @@ import 'package:flutter/foundation.dart';
import 'dart:convert'; import 'dart:convert';
import 'package:http/http.dart' as http; import 'package:http/http.dart' as http;
import 'package:googleapis_auth/auth_io.dart'; import 'package:googleapis_auth/auth_io.dart';
import 'package:flutter/services.dart' show rootBundle;
class NotificationService { class NotificationService {
static final NotificationService _instance = NotificationService._internal(); static final NotificationService _instance = NotificationService._internal();
@ -17,22 +18,8 @@ class NotificationService {
// Firebase project configuration // Firebase project configuration
static const String _projectId = 'wesalapp-bc676'; static const String _projectId = 'wesalapp-bc676';
// Service account credentials (JSON string) // Service account credentials loaded from file
static const String _serviceAccountJson = ''' static 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"
}
''';
static const List<String> topics = [ static const List<String> topics = [
'all', 'all',
@ -257,10 +244,30 @@ class NotificationService {
} }
} }
Future<void> _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<String?> _getAccessToken() async { Future<String?> _getAccessToken() async {
try { try {
await _loadServiceAccountCredentials();
if (_serviceAccountJson == null) {
print('Service account credentials not loaded');
return null;
}
final credentials = ServiceAccountCredentials.fromJson( final credentials = ServiceAccountCredentials.fromJson(
_serviceAccountJson, _serviceAccountJson!,
); );
final client = await clientViaServiceAccount(credentials, [ final client = await clientViaServiceAccount(credentials, [
'https://www.googleapis.com/auth/firebase.messaging', 'https://www.googleapis.com/auth/firebase.messaging',