From e85a3bebb89f53e38dbb09c9207c40dc0420c21b Mon Sep 17 00:00:00 2001 From: sBubshait Date: Thu, 7 Aug 2025 03:50:04 +0300 Subject: [PATCH] feat: registration using invitation codes in UI --- frontend/lib/constants/api_constants.dart | 2 + frontend/lib/main.dart | 488 ++++++++++++++---- frontend/lib/screens/edit_profile_screen.dart | 14 +- frontend/lib/services/auth_service.dart | 8 +- 4 files changed, 403 insertions(+), 109 deletions(-) diff --git a/frontend/lib/constants/api_constants.dart b/frontend/lib/constants/api_constants.dart index cdb6ebd..5c73bc2 100644 --- a/frontend/lib/constants/api_constants.dart +++ b/frontend/lib/constants/api_constants.dart @@ -3,6 +3,8 @@ class ApiConstants { // Auth endpoints static const String loginEndpoint = '/login'; + static const String registerEndpoint = '/register'; + static const String checkInvitationEndpoint = '/checkInvitation'; // User endpoints static const String getUserEndpoint = '/getUser'; diff --git a/frontend/lib/main.dart b/frontend/lib/main.dart index 9785e5d..614ed4f 100644 --- a/frontend/lib/main.dart +++ b/frontend/lib/main.dart @@ -7,6 +7,8 @@ import 'screens/edit_profile_screen.dart'; import 'services/auth_service.dart'; import 'services/user_service.dart'; import 'services/app_lifecycle_service.dart'; +import 'services/http_service.dart'; +import 'dart:convert'; final GlobalKey navigatorKey = GlobalKey(); @@ -260,10 +262,14 @@ class SignInPage extends StatefulWidget { class _SignInPageState extends State { final _formKey = GlobalKey(); - final _emailController = TextEditingController(); + final _phoneController = TextEditingController(); final _passwordController = TextEditingController(); + final _invitationCodeController = TextEditingController(); + final _countryCodeController = TextEditingController(text: '+966'); bool _isPasswordVisible = false; bool _isLoading = false; + bool _isRegistering = false; + int _registrationStep = 1; // 1 = invitation code, 2 = phone & password void _showHelpBottomSheet() { showModalBottomSheet( @@ -297,7 +303,7 @@ class _SignInPageState extends State { ), SizedBox(height: 16), Text( - 'For account creation or password reset, please contact ERP Management Group from your Aramco email address.', + 'Registration is by invitation only. If you don\'t have an invitation code, please contact COD/DPSD.', textAlign: TextAlign.center, style: TextStyle( fontSize: 16, @@ -331,10 +337,51 @@ class _SignInPageState extends State { ); } + Future _checkInvitationCode() async { + if (_invitationCodeController.text.trim().isEmpty) { + _showErrorAlert('Please enter your invitation code'); + return; + } + + setState(() { + _isLoading = true; + }); + + try { + final response = await HttpService.post('/checkInvitation', { + 'code': _invitationCodeController.text.trim().toUpperCase(), + }); + + final data = json.decode(response.body); + + setState(() { + _isLoading = false; + }); + + if (data['status'] == true && + data['data'] != null && + data['data']['valid'] == true) { + // Invitation code is valid, move to step 2 + setState(() { + _registrationStep = 2; + }); + } else { + _showErrorAlert(data['message'] ?? 'Invalid invitation code'); + } + } catch (e) { + setState(() { + _isLoading = false; + }); + _showErrorAlert('Network error. Please try again.'); + } + } + @override void dispose() { - _emailController.dispose(); + _phoneController.dispose(); _passwordController.dispose(); + _invitationCodeController.dispose(); + _countryCodeController.dispose(); super.dispose(); } @@ -344,8 +391,10 @@ class _SignInPageState extends State { _isLoading = true; }); + final phoneNumber = + _countryCodeController.text.trim() + _phoneController.text.trim(); final result = await AuthService.login( - _emailController.text.trim(), + phoneNumber, _passwordController.text, ); @@ -386,11 +435,83 @@ class _SignInPageState extends State { } } + void _handleRegister() async { + if (_formKey.currentState!.validate()) { + setState(() { + _isLoading = true; + }); + + try { + final phoneNumber = + _countryCodeController.text.trim() + _phoneController.text.trim(); + + final response = await HttpService.post('/register', { + 'code': _invitationCodeController.text.trim().toUpperCase(), + 'phoneNumber': phoneNumber, + 'password': _passwordController.text, + }); + + final data = json.decode(response.body); + + setState(() { + _isLoading = false; + }); + + if (data['status'] == true && data['data'] != null) { + // Registration successful, save token and navigate + final token = data['data']['token']; + if (token != null) { + await AuthService.saveToken(token); + + final userResult = await UserService.getCurrentUser( + forceRefresh: true, + ); + if (userResult['success'] == true) { + final userData = userResult['data']; + + // Check if user needs onboarding (activated = false) + if (userData['activated'] == false) { + Navigator.of(context).pushReplacement( + MaterialPageRoute( + builder: (context) => EditProfileScreen( + userData: userData, + isOnboarding: true, + ), + ), + ); + } else { + AppLifecycleService.startAllPolling(); + Navigator.of(context).pushReplacement( + MaterialPageRoute( + builder: (context) => NotificationPermissionScreen(), + ), + ); + } + } else { + _showErrorAlert( + 'Registration successful but failed to load user data', + ); + } + } else { + _showErrorAlert('Registration successful but no token received'); + } + } else { + _showErrorAlert(data['message'] ?? 'Registration failed'); + } + } catch (e) { + setState(() { + _isLoading = false; + }); + _showErrorAlert('Network error. Please try again.'); + } + } + } + void _showErrorAlert(String message) { showDialog( context: context, builder: (context) => AlertDialog( - title: Text('Login Failed'), + title: Text(_isRegistering ? 'Registration' : 'Login Failed'), content: Text(message), actions: [ TextButton( @@ -430,7 +551,7 @@ class _SignInPageState extends State { icon: Icon(Icons.arrow_back, color: Colors.white), ), Text( - 'Sign In', + _isRegistering ? 'Register' : 'Sign In', style: TextStyle( fontSize: 20, fontWeight: FontWeight.w600, @@ -482,7 +603,9 @@ class _SignInPageState extends State { // Welcome text Text( - 'Welcome Back!', + _isRegistering + ? 'Create Account' + : 'Welcome Back!', style: TextStyle( fontSize: 24, fontWeight: FontWeight.bold, @@ -494,7 +617,9 @@ class _SignInPageState extends State { SizedBox(height: 8), Text( - 'Sign in to socialize with your colleagues\nand transform your social life!', + _isRegistering + ? 'Join with an invitation code to connect\nwith your colleagues!' + : 'Sign in to socialize with your colleagues\nand transform your social life!', style: TextStyle( fontSize: 16, color: Colors.grey[600], @@ -504,100 +629,201 @@ class _SignInPageState extends State { SizedBox(height: 40), - // Email field - TextFormField( - controller: _emailController, - keyboardType: TextInputType.emailAddress, - decoration: InputDecoration( - labelText: 'Email', - prefixIcon: Icon(Icons.email_outlined), - border: OutlineInputBorder( - borderRadius: BorderRadius.circular(12), - ), - focusedBorder: OutlineInputBorder( - borderRadius: BorderRadius.circular(12), - borderSide: BorderSide( - color: Color(0xFF6A4C93), + // Registration Step 1: Invitation Code + if (_isRegistering && _registrationStep == 1) ...[ + TextFormField( + controller: _invitationCodeController, + decoration: InputDecoration( + labelText: 'Invitation Code', + prefixIcon: Icon( + Icons.confirmation_number_outlined, ), + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(12), + ), + focusedBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(12), + borderSide: BorderSide( + color: Color(0xFF6A4C93), + ), + ), + helperText: + '6-character code with letters and numbers', ), + maxLength: 6, + textCapitalization: + TextCapitalization.characters, + validator: (value) { + if (value == null || value.isEmpty) { + return 'Please enter your invitation code'; + } + if (value.length != 6) { + return 'Invitation code must be 6 characters'; + } + if (!RegExp( + r'^[A-Z0-9]{6}$', + ).hasMatch(value)) { + return 'Code must contain only letters and numbers'; + } + return null; + }, ), - validator: (value) { - if (value == null || value.isEmpty) { - return 'Please enter your email'; - } - if (!value.contains('@')) { - return 'Please enter a valid email'; - } - return null; - }, - ), + ], - SizedBox(height: 20), - - // Password field - TextFormField( - controller: _passwordController, - obscureText: !_isPasswordVisible, - decoration: InputDecoration( - labelText: 'Password', - prefixIcon: Icon(Icons.lock_outline), - suffixIcon: IconButton( - icon: Icon( - _isPasswordVisible - ? Icons.visibility_off - : Icons.visibility, + // Login or Registration Step 2: Phone & Password + if (!_isRegistering || _registrationStep == 2) ...[ + // Phone number field with country code input + Row( + children: [ + SizedBox( + width: 100, + child: TextFormField( + controller: _countryCodeController, + keyboardType: TextInputType.phone, + decoration: InputDecoration( + labelText: 'Code', + border: OutlineInputBorder( + borderRadius: BorderRadius.circular( + 12, + ), + ), + focusedBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular( + 12, + ), + borderSide: BorderSide( + color: Color(0xFF6A4C93), + ), + ), + ), + validator: (value) { + if (value == null || value.isEmpty) { + return 'Required'; + } + if (!value.startsWith('+')) { + return 'Must start with +'; + } + return null; + }, + ), ), - onPressed: () { - setState(() { - _isPasswordVisible = !_isPasswordVisible; - }); - }, - ), - border: OutlineInputBorder( - borderRadius: BorderRadius.circular(12), - ), - focusedBorder: OutlineInputBorder( - borderRadius: BorderRadius.circular(12), - borderSide: BorderSide( - color: Color(0xFF6A4C93), + SizedBox(width: 12), + Expanded( + child: TextFormField( + controller: _phoneController, + keyboardType: TextInputType.phone, + decoration: InputDecoration( + labelText: 'Phone Number', + prefixIcon: Icon(Icons.phone_outlined), + border: OutlineInputBorder( + borderRadius: BorderRadius.circular( + 12, + ), + ), + focusedBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular( + 12, + ), + borderSide: BorderSide( + color: Color(0xFF6A4C93), + ), + ), + ), + validator: (value) { + if (value == null || value.isEmpty) { + return 'Please enter your phone number'; + } + if (value.length < 8) { + return 'Please enter a valid phone number'; + } + return null; + }, + ), ), - ), + ], ), - validator: (value) { - if (value == null || value.isEmpty) { - return 'Please enter your password'; - } - if (value.length < 6) { - return 'Password must be at least 6 characters'; - } - return null; - }, - ), + ], + + // Password field (only for login or registration step 2) + if (!_isRegistering || _registrationStep == 2) ...[ + SizedBox(height: 20), + + TextFormField( + controller: _passwordController, + obscureText: !_isPasswordVisible, + decoration: InputDecoration( + labelText: 'Password', + prefixIcon: Icon(Icons.lock_outline), + suffixIcon: IconButton( + icon: Icon( + _isPasswordVisible + ? Icons.visibility_off + : Icons.visibility, + ), + onPressed: () { + setState(() { + _isPasswordVisible = + !_isPasswordVisible; + }); + }, + ), + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(12), + ), + focusedBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(12), + borderSide: BorderSide( + color: Color(0xFF6A4C93), + ), + ), + ), + validator: (value) { + if (value == null || value.isEmpty) { + return 'Please enter your password'; + } + if (value.length < 6) { + return 'Password must be at least 6 characters'; + } + return null; + }, + ), + ], SizedBox(height: 12), - // Forgot password - Align( - alignment: Alignment.centerRight, - child: TextButton( - onPressed: _showHelpBottomSheet, - child: Text( - 'Forgot Password?', - style: TextStyle( - color: Color(0xFF6A4C93), - fontWeight: FontWeight.w600, + // Forgot password (only show for login) + if (!_isRegistering) + Align( + alignment: Alignment.centerRight, + child: TextButton( + onPressed: _showHelpBottomSheet, + child: Text( + 'Forgot Password?', + style: TextStyle( + color: Color(0xFF6A4C93), + fontWeight: FontWeight.w600, + ), ), ), ), - ), SizedBox(height: 30), - // Sign in button + // Action button based on current state Container( height: 56, child: ElevatedButton( - onPressed: _isLoading ? null : _handleSignIn, + onPressed: _isLoading + ? null + : () { + if (!_isRegistering) { + _handleSignIn(); + } else if (_registrationStep == 1) { + _checkInvitationCode(); + } else { + _handleRegister(); + } + }, style: ElevatedButton.styleFrom( backgroundColor: Color(0xFF6A4C93), foregroundColor: Colors.white, @@ -614,7 +840,11 @@ class _SignInPageState extends State { ), ) : Text( - 'Sign In', + !_isRegistering + ? 'Sign In' + : (_registrationStep == 1 + ? 'Continue' + : 'Register'), style: TextStyle( fontSize: 18, fontWeight: FontWeight.w600, @@ -623,28 +853,86 @@ class _SignInPageState extends State { ), ), - SizedBox(height: 20), - - // Contact link for new accounts - Row( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - Text( - "Don't have an account? ", - style: TextStyle(color: Colors.grey[600]), - ), - GestureDetector( - onTap: _showHelpBottomSheet, + // Back button for registration step 2 + if (_isRegistering && _registrationStep == 2) ...[ + SizedBox(height: 16), + Container( + height: 56, + child: OutlinedButton( + onPressed: () { + setState(() { + _registrationStep = 1; + _phoneController.clear(); + _passwordController.clear(); + }); + }, + style: OutlinedButton.styleFrom( + side: BorderSide(color: Color(0xFF6A4C93)), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(12), + ), + ), child: Text( - 'Contact Support', + 'Back to Invitation Code', style: TextStyle( - color: Color(0xFF6A4C93), + fontSize: 16, fontWeight: FontWeight.w600, + color: Color(0xFF6A4C93), ), ), ), - ], - ), + ), + ], + + SizedBox(height: 20), + + // Toggle between login and registration (only show on step 1) + if (!_isRegistering || _registrationStep == 1) + Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Text( + _isRegistering + ? 'Already have an account? ' + : "Don't have an account? ", + style: TextStyle(color: Colors.grey[600]), + ), + GestureDetector( + onTap: () { + setState(() { + _isRegistering = !_isRegistering; + _registrationStep = 1; + // Clear form when switching modes + _phoneController.clear(); + _passwordController.clear(); + _invitationCodeController.clear(); + }); + }, + child: Text( + _isRegistering + ? 'Sign In' + : 'Register Now!', + style: TextStyle( + color: Color(0xFF6A4C93), + fontWeight: FontWeight.w600, + ), + ), + ), + ], + ), + + if (_isRegistering) ...[ + SizedBox(height: 16), + Text( + 'Registration is only by invitation.\nIf you don\'t have an invite, contact COD/DPSD.', + textAlign: TextAlign.center, + style: TextStyle( + fontSize: 14, + color: Colors.grey[600], + height: 1.3, + ), + ), + ], SizedBox(height: 40), ], diff --git a/frontend/lib/screens/edit_profile_screen.dart b/frontend/lib/screens/edit_profile_screen.dart index c86a945..8896a22 100644 --- a/frontend/lib/screens/edit_profile_screen.dart +++ b/frontend/lib/screens/edit_profile_screen.dart @@ -21,7 +21,7 @@ class _EditProfileScreenState extends State { final _formKey = GlobalKey(); final TextEditingController _displayNameController = TextEditingController(); final TextEditingController _usernameController = TextEditingController(); - final TextEditingController _emailController = TextEditingController(); + final TextEditingController _phoneController = TextEditingController(); bool _isLoading = false; @override @@ -33,7 +33,7 @@ class _EditProfileScreenState extends State { void _initializeFields() { if (widget.userData != null) { _displayNameController.text = widget.userData!['displayName'] ?? ''; - _emailController.text = widget.userData!['email'] ?? ''; + _phoneController.text = widget.userData!['phoneNumber'] ?? ''; if (!widget.isOnboarding) { _usernameController.text = widget.userData!['username'] ?? ''; } @@ -44,7 +44,7 @@ class _EditProfileScreenState extends State { void dispose() { _displayNameController.dispose(); _usernameController.dispose(); - _emailController.dispose(); + _phoneController.dispose(); super.dispose(); } @@ -299,11 +299,11 @@ class _EditProfileScreenState extends State { ), SizedBox(height: 8), TextFormField( - controller: _emailController, + controller: _phoneController, readOnly: true, decoration: InputDecoration( - hintText: 'Email address', - prefixIcon: Icon(Icons.email, color: Colors.grey[400]), + hintText: 'Phone number', + prefixIcon: Icon(Icons.phone, color: Colors.grey[400]), border: OutlineInputBorder( borderRadius: BorderRadius.circular(12), borderSide: BorderSide(color: Colors.grey[300]!), @@ -328,7 +328,7 @@ class _EditProfileScreenState extends State { ), SizedBox(height: 8), Text( - 'Email cannot be changed', + 'Phone number cannot be changed', style: TextStyle( fontSize: 12, color: Colors.grey[600], diff --git a/frontend/lib/services/auth_service.dart b/frontend/lib/services/auth_service.dart index d9e591d..df42a50 100644 --- a/frontend/lib/services/auth_service.dart +++ b/frontend/lib/services/auth_service.dart @@ -12,7 +12,7 @@ class AuthService { static const String _userDataKey = 'user_data'; static Future> login( - String emailOrUsername, + String phoneNumberOrUsername, String password, ) async { try { @@ -20,7 +20,7 @@ class AuthService { Uri.parse('${ApiConstants.baseUrl}${ApiConstants.loginEndpoint}'), headers: {'Content-Type': 'application/json'}, body: jsonEncode({ - 'emailOrUsername': emailOrUsername, + 'phoneNumberOrUsername': phoneNumberOrUsername, 'password': password, }), ); @@ -57,6 +57,10 @@ class AuthService { return await _storage.read(key: _tokenKey); } + static Future saveToken(String token) async { + await _storage.write(key: _tokenKey, value: token); + } + static Future isLoggedIn() async { final token = await getToken(); return token != null && token.isNotEmpty;