From eb3008caf1a5328ba4aedd1de4d8a445c9dae4e2 Mon Sep 17 00:00:00 2001 From: vergiLgood1 Date: Tue, 27 May 2025 10:14:08 +0700 Subject: [PATCH] feat(auth): update biometric login to use refresh token and add sign out confirmation dialog --- .../src/cores/services/biometric_service.dart | 16 ++++- .../authentication_repository.dart | 54 ++++++++------ .../pages/signin/signin_screen.dart | 12 ++-- .../settings/settings_controller.dart | 43 ++++++++++++ .../pages/settings/setting_screen.dart | 39 +++++++++-- .../loaders/custom_circular_loader.dart | 3 + .../lib/src/utils/popups/dialogs.dart | 70 +++++++++++++++++++ 7 files changed, 198 insertions(+), 39 deletions(-) create mode 100644 sigap-mobile/lib/src/utils/popups/dialogs.dart diff --git a/sigap-mobile/lib/src/cores/services/biometric_service.dart b/sigap-mobile/lib/src/cores/services/biometric_service.dart index ba6f3f3..033650e 100644 --- a/sigap-mobile/lib/src/cores/services/biometric_service.dart +++ b/sigap-mobile/lib/src/cores/services/biometric_service.dart @@ -90,10 +90,11 @@ class BiometricService extends GetxService { ); } - // Also store the auth token, which is more secure than storing credentials + // Store the current session's refresh token instead of access token + // The refresh token has longer validity and can be used to generate new tokens final session = SupabaseService.instance.client.auth.currentSession; if (session != null) { - await _secureStorage.write(key: _sessionKey, value: session.accessToken); + await _secureStorage.write(key: _sessionKey, value: session.refreshToken); } // Parse user metadata with our type-safe model @@ -131,7 +132,16 @@ class BiometricService extends GetxService { final success = await authenticate(reason: 'Log in to your account'); if (!success) return null; - return await _secureStorage.read(key: _sessionKey); + // Return the stored refresh token + final token = await _secureStorage.read(key: _sessionKey); + + if (token == null || token.isEmpty) { + // If no token is found, disable biometric login as it's in an inconsistent state + await disableBiometricLogin(); + return null; + } + + return token; } // Get stored user identifier (NIK or NRP) diff --git a/sigap-mobile/lib/src/features/auth/data/repositories/authentication_repository.dart b/sigap-mobile/lib/src/features/auth/data/repositories/authentication_repository.dart index 157b43a..f790a43 100644 --- a/sigap-mobile/lib/src/features/auth/data/repositories/authentication_repository.dart +++ b/sigap-mobile/lib/src/features/auth/data/repositories/authentication_repository.dart @@ -52,9 +52,37 @@ class AuthenticationRepository extends GetxController { return false; } - // Use the session token to restore the session - final response = await restoreSession(sessionToken); - return response; + try { + // Use the session token to restore the session + await _supabase.auth.setSession(sessionToken); + + // Verify if session was successfully restored + final session = _supabase.auth.currentSession; + if (session != null) { + _logger.i('Biometric login successful'); + Get.offAllNamed(AppRoutes.navigationMenu); + return true; + } else { + _logger.w('Session is null after biometric login'); + return false; + } + } catch (sessionError) { + _logger.e('Session error: $sessionError'); + + // Try alternate method with recoverSession if setSession fails + try { + final response = await _supabase.auth.recoverSession(sessionToken); + if (response.session != null) { + _logger.i('Biometric login successful using recoverSession'); + Get.offAllNamed(AppRoutes.navigationMenu); + return true; + } + return false; + } catch (recoverError) { + _logger.e('Recover session error: $recoverError'); + return false; + } + } } catch (e) { // If token is invalid or expired, disable biometric login await _biometricService.disableBiometricLogin(); @@ -63,6 +91,7 @@ class AuthenticationRepository extends GetxController { } } + static bool _isRedirecting = false; /// Updated screenRedirect method to handle onboarding preferences @@ -825,23 +854,4 @@ class AuthenticationRepository extends GetxController { throw TExceptions('Something went wrong. Please try again later.'); } } - - // Restore session using a stored token (for biometric authentication) - Future restoreSession(String sessionToken) async { - try { - // Use the token to restore a session - await _supabase.auth.recoverSession(sessionToken); - - // Check if session was successfully restored - final session = _supabase.auth.currentSession; - if (session != null) { - return true; - } else { - throw 'Failed to restore session'; - } - } catch (e) { - _logger.e('Error restoring session: $e'); - throw 'Session restoration failed: $e'; - } - } } diff --git a/sigap-mobile/lib/src/features/auth/presentasion/pages/signin/signin_screen.dart b/sigap-mobile/lib/src/features/auth/presentasion/pages/signin/signin_screen.dart index fb24f1b..4a9f8a8 100644 --- a/sigap-mobile/lib/src/features/auth/presentasion/pages/signin/signin_screen.dart +++ b/sigap-mobile/lib/src/features/auth/presentasion/pages/signin/signin_screen.dart @@ -9,6 +9,7 @@ import 'package:sigap/src/features/auth/presentasion/widgets/auth_header.dart'; import 'package:sigap/src/features/auth/presentasion/widgets/password_field.dart'; import 'package:sigap/src/features/auth/presentasion/widgets/social_button.dart'; import 'package:sigap/src/shared/widgets/text/custom_text_field.dart'; +import 'package:sigap/src/utils/constants/colors.dart'; import 'package:sigap/src/utils/constants/image_strings.dart'; // Added for logo image import 'package:sigap/src/utils/helpers/helper_functions.dart'; import 'package:sigap/src/utils/validators/validation.dart'; @@ -66,6 +67,7 @@ class SignInScreen extends StatelessWidget { errorText: controller.emailError.value, textInputAction: TextInputAction.next, prefixIcon: const Icon(TablerIcons.mail, size: 20), + hintText: 'Enter your email address', ), ), @@ -79,6 +81,7 @@ class SignInScreen extends StatelessWidget { errorText: controller.passwordError.value, onToggleVisibility: controller.togglePasswordVisibility, prefixIcon: const Icon(TablerIcons.lock, size: 20), + hintText: 'Enter your password', ), ), @@ -117,17 +120,12 @@ class SignInScreen extends StatelessWidget { onPressed: controller.signInWithBiometrics, style: ElevatedButton.styleFrom( backgroundColor: - Theme.of(context).colorScheme.surface, + isDarkMode ? TColors.primary : TColors.accent, foregroundColor: - Theme.of(context).colorScheme.primary, + Theme.of(context).primaryColor, elevation: 0, shape: RoundedRectangleBorder( borderRadius: BorderRadius.circular(30), - side: BorderSide( - color: Theme.of( - context, - ).colorScheme.outline.withOpacity(0.3), - ), ), padding: const EdgeInsets.symmetric(vertical: 12), minimumSize: const Size(double.infinity, 0), diff --git a/sigap-mobile/lib/src/features/personalization/presentasion/controllers/settings/settings_controller.dart b/sigap-mobile/lib/src/features/personalization/presentasion/controllers/settings/settings_controller.dart index f464a83..7f5e01c 100644 --- a/sigap-mobile/lib/src/features/personalization/presentasion/controllers/settings/settings_controller.dart +++ b/sigap-mobile/lib/src/features/personalization/presentasion/controllers/settings/settings_controller.dart @@ -1,4 +1,7 @@ +import 'package:flutter/material.dart'; +import 'package:flutter/widgets.dart'; import 'package:get/get.dart'; +import 'package:sigap/src/features/auth/data/repositories/authentication_repository.dart'; import 'package:sigap/src/features/personalization/data/models/models/profile_model.dart'; import 'package:sigap/src/features/personalization/data/models/models/users_model.dart'; import 'package:sigap/src/features/personalization/presentasion/controllers/settings/base_settings_controller.dart'; @@ -9,12 +12,22 @@ import 'package:sigap/src/features/personalization/presentasion/controllers/sett import 'package:sigap/src/features/personalization/presentasion/controllers/settings/privacy_controller.dart'; import 'package:sigap/src/features/personalization/presentasion/controllers/settings/security_controller.dart'; import 'package:sigap/src/features/personalization/presentasion/controllers/settings/text_size_controller.dart'; +import 'package:sigap/src/shared/widgets/loaders/custom_circular_loader.dart'; +import 'package:sigap/src/utils/popups/dialogs.dart'; +import 'package:sigap/src/utils/popups/loaders.dart'; class SettingsController extends BaseSettingsController { + // Singleton instance + static SettingsController get instance => Get.find(); + // User info final Rx currentUser = Rx(null); final Rx userProfile = Rx(null); + // Get auth repository for sign out functionality + final AuthenticationRepository _authRepo = + Get.find(); + // Current tab index final RxInt selectedTabIndex = 0.obs; @@ -27,6 +40,9 @@ class SettingsController extends BaseSettingsController { late final EmailController emailController; late final TextSizeController textSizeController; + // Track sign out loading state + final RxBool isSigningOut = false.obs; + @override void onInit() { super.onInit(); @@ -113,4 +129,31 @@ class SettingsController extends BaseSettingsController { setLoading(false); } } + + // Handle sign out with confirmation + void handleSignOut(BuildContext context) { + TDialogs.confirmDialog( + context: context, + title: 'Sign Out', + message: 'Are you sure you want to sign out?', + onConfirm: () async { + try { + isSigningOut.value = true; + + TLoader.fullScreenLoader(text: 'Signing out, please wait...'); + + await _authRepo.signOut(); + + isSigningOut.value = false; + } catch (e) { + isSigningOut.value = false; + // Show error snackbar if sign out fails + TLoaders.errorSnackBar( + title: 'Sign Out Failed', + message: 'Failed to sign out. please try again later.', + ); + } + }, + ); + } } diff --git a/sigap-mobile/lib/src/features/personalization/presentasion/pages/settings/setting_screen.dart b/sigap-mobile/lib/src/features/personalization/presentasion/pages/settings/setting_screen.dart index d5a9764..455e50f 100644 --- a/sigap-mobile/lib/src/features/personalization/presentasion/pages/settings/setting_screen.dart +++ b/sigap-mobile/lib/src/features/personalization/presentasion/pages/settings/setting_screen.dart @@ -12,6 +12,7 @@ import 'package:sigap/src/features/personalization/presentasion/pages/settings/w import 'package:sigap/src/features/personalization/presentasion/pages/settings/widgets/security_setting.dart'; import 'package:sigap/src/features/personalization/presentasion/pages/settings/widgets/terms_services_screen.dart'; import 'package:sigap/src/features/personalization/presentasion/pages/settings/widgets/text_size_setting.dart'; +import 'package:sigap/src/utils/constants/colors.dart'; import 'package:sigap/src/utils/constants/sizes.dart'; class SettingsScreen extends StatefulWidget { @@ -24,9 +25,9 @@ class SettingsScreen extends StatefulWidget { class _SettingsScreenState extends State with SingleTickerProviderStateMixin { late TabController _tabController; - // Use find instead of implicit creation with Get.put - final SettingsController _settingsController = Get.find(); + // Get the profile controller to access user data + final SettingsController _settingController = Get.find(); final ProfileController _profileController = Get.find(); @override @@ -80,7 +81,7 @@ class _SettingsScreenState extends State child: Text( 'Failed to load profile', style: theme.textTheme.bodyMedium?.copyWith( - color: Colors.red, + color: TColors.error, ), ), ), @@ -298,6 +299,15 @@ class _SettingsScreenState extends State ); }, ), + _buildSettingsItem( + icon: Icons.logout, + title: 'Sign Out', + isDestructive: true, + onTap: () => _settingController.handleSignOut(context), + ), + + // Add some padding at the bottom for better scroll experience + const SizedBox(height: 24), ], ), ); @@ -335,6 +345,7 @@ class _SettingsScreenState extends State required IconData icon, required String title, required VoidCallback onTap, + bool isDestructive = false, }) { final theme = Theme.of(context); @@ -342,16 +353,30 @@ class _SettingsScreenState extends State onTap: onTap, child: Container( padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 16), - child: Row( children: [ - Icon(icon, size: 24, color: theme.iconTheme.color), + Icon( + icon, + size: 24, + color: isDestructive ? TColors.error : theme.iconTheme.color, + ), const SizedBox(width: 15), - Expanded(child: Text(title, style: theme.textTheme.bodyLarge)), - Icon(Icons.chevron_right, color: theme.hintColor), + Expanded( + child: Text( + title, + style: theme.textTheme.bodyLarge?.copyWith( + color: isDestructive ? TColors.error : null, + ), + ), + ), + Icon( + Icons.chevron_right, + color: isDestructive ? TColors.error : theme.hintColor, + ), ], ), ), ); } } + diff --git a/sigap-mobile/lib/src/shared/widgets/loaders/custom_circular_loader.dart b/sigap-mobile/lib/src/shared/widgets/loaders/custom_circular_loader.dart index e805bb9..0f6d63e 100644 --- a/sigap-mobile/lib/src/shared/widgets/loaders/custom_circular_loader.dart +++ b/sigap-mobile/lib/src/shared/widgets/loaders/custom_circular_loader.dart @@ -78,4 +78,7 @@ class TLoader { static Widget centeredLoader({double? size, Color? color}) { return Center(child: DCircularLoader(size: size, color: color)); } + + } + diff --git a/sigap-mobile/lib/src/utils/popups/dialogs.dart b/sigap-mobile/lib/src/utils/popups/dialogs.dart new file mode 100644 index 0000000..ef23635 --- /dev/null +++ b/sigap-mobile/lib/src/utils/popups/dialogs.dart @@ -0,0 +1,70 @@ +import 'package:flutter/material.dart'; + +/// Class for handling dialog popups in the application +class TDialogs { + /// Display a confirmation dialog with optional custom button text + static Future confirmDialog({ + required BuildContext context, + required String title, + required String message, + String confirmText = 'Yes', + String cancelText = 'Cancel', + VoidCallback? onConfirm, + VoidCallback? onCancel, + }) async { + final result = await showDialog( + context: context, + barrierDismissible: false, + builder: + (context) => AlertDialog( + title: Text(title), + content: Text(message), + actions: [ + TextButton( + onPressed: () { + Navigator.of(context).pop(false); + if (onCancel != null) onCancel(); + }, + child: Text(cancelText), + ), + ElevatedButton( + onPressed: () { + Navigator.of(context).pop(true); + if (onConfirm != null) onConfirm(); + }, + child: Text(confirmText), + ), + ], + ), + ); + + return result; + } + + /// Show a simple alert dialog with a single OK button + static Future alertDialog({ + required BuildContext context, + required String title, + required String message, + String buttonText = 'OK', + VoidCallback? onPressed, + }) async { + await showDialog( + context: context, + builder: + (context) => AlertDialog( + title: Text(title), + content: Text(message), + actions: [ + ElevatedButton( + onPressed: () { + Navigator.of(context).pop(); + if (onPressed != null) onPressed(); + }, + child: Text(buttonText), + ), + ], + ), + ); + } +}