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())
.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()
)

View File

@ -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<Map<String, Integer>> 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<LoginResponse> 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<String, Object> 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<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")
@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<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 {
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<ApiResponse<CategorizedInvitationsResponse>> 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()));

View File

@ -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) {

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;
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() {

View File

@ -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() {

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;
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<String> 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<List<String>>() {});
} 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\"]";
}
}
}

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
public interface UserRepository extends JpaRepository<User, Long> {
Optional<User> findByEmail(String email);
Optional<User> findByPhoneNumber(String phoneNumber);
Optional<User> 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 != ''")

View File

@ -21,23 +21,23 @@ public class AuthService {
@Autowired
private JwtUtil jwtUtil;
public String authenticate(String emailOrUsername, String password) {
Optional<User> userOpt = userRepository.findByEmail(emailOrUsername);
public String authenticate(String phoneNumberOrUsername, String password) {
Optional<User> 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());
}
}

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

View File

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

View File

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

View File

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

View File

@ -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<NavigatorState> navigatorKey = GlobalKey<NavigatorState>();
@ -260,10 +262,14 @@ class SignInPage extends StatefulWidget {
class _SignInPageState extends State<SignInPage> {
final _formKey = GlobalKey<FormState>();
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<SignInPage> {
),
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<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
void dispose() {
_emailController.dispose();
_phoneController.dispose();
_passwordController.dispose();
_invitationCodeController.dispose();
_countryCodeController.dispose();
super.dispose();
}
@ -344,8 +391,10 @@ class _SignInPageState extends State<SignInPage> {
_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<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) {
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<SignInPage> {
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<SignInPage> {
// 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<SignInPage> {
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<SignInPage> {
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<SignInPage> {
),
)
: 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<SignInPage> {
),
),
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),
],

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 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<EditProfileScreen> {
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<EditProfileScreen> {
void dispose() {
_displayNameController.dispose();
_usernameController.dispose();
_emailController.dispose();
_phoneController.dispose();
super.dispose();
}
@ -299,11 +299,11 @@ class _EditProfileScreenState extends State<EditProfileScreen> {
),
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<EditProfileScreen> {
),
SizedBox(height: 8),
Text(
'Email cannot be changed',
'Phone number cannot be changed',
style: TextStyle(
fontSize: 12,
color: Colors.grey[600],

View File

@ -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<SettingsPage> {
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,

View File

@ -12,7 +12,7 @@ class AuthService {
static const String _userDataKey = 'user_data';
static Future<Map<String, dynamic>> 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<void> saveToken(String token) async {
await _storage.write(key: _tokenKey, value: token);
}
static Future<bool> isLoggedIn() async {
final token = await getToken();
return token != null && token.isNotEmpty;

View File

@ -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<String> topics = [
'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 {
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',