feat(auth): update biometric login to use refresh token and add sign out confirmation dialog

This commit is contained in:
vergiLgood1 2025-05-27 10:14:08 +07:00
parent 0f3cb701c4
commit eb3008caf1
7 changed files with 198 additions and 39 deletions

View File

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

View File

@ -52,9 +52,37 @@ class AuthenticationRepository extends GetxController {
return false;
}
try {
// Use the session token to restore the session
final response = await restoreSession(sessionToken);
return response;
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<bool> 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';
}
}
}

View File

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

View File

@ -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<SettingsController>();
// User info
final Rx<UserModel?> currentUser = Rx<UserModel?>(null);
final Rx<ProfileModel?> userProfile = Rx<ProfileModel?>(null);
// Get auth repository for sign out functionality
final AuthenticationRepository _authRepo =
Get.find<AuthenticationRepository>();
// 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.',
);
}
},
);
}
}

View File

@ -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<SettingsScreen>
with SingleTickerProviderStateMixin {
late TabController _tabController;
// Use find instead of implicit creation with Get.put
final SettingsController _settingsController = Get.find<SettingsController>();
// Get the profile controller to access user data
final SettingsController _settingController = Get.find<SettingsController>();
final ProfileController _profileController = Get.find<ProfileController>();
@override
@ -80,7 +81,7 @@ class _SettingsScreenState extends State<SettingsScreen>
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<SettingsScreen>
);
},
),
_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<SettingsScreen>
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<SettingsScreen>
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,
),
],
),
),
);
}
}

View File

@ -78,4 +78,7 @@ class TLoader {
static Widget centeredLoader({double? size, Color? color}) {
return Center(child: DCircularLoader(size: size, color: color));
}
}

View File

@ -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<bool?> 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<bool>(
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<void> alertDialog({
required BuildContext context,
required String title,
required String message,
String buttonText = 'OK',
VoidCallback? onPressed,
}) async {
await showDialog<void>(
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),
),
],
),
);
}
}