feat(auth): update biometric login to use refresh token and add sign out confirmation dialog
This commit is contained in:
parent
0f3cb701c4
commit
eb3008caf1
|
@ -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;
|
final session = SupabaseService.instance.client.auth.currentSession;
|
||||||
if (session != null) {
|
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
|
// 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');
|
final success = await authenticate(reason: 'Log in to your account');
|
||||||
if (!success) return null;
|
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)
|
// Get stored user identifier (NIK or NRP)
|
||||||
|
|
|
@ -52,9 +52,37 @@ class AuthenticationRepository extends GetxController {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Use the session token to restore the session
|
try {
|
||||||
final response = await restoreSession(sessionToken);
|
// Use the session token to restore the session
|
||||||
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) {
|
} catch (e) {
|
||||||
// If token is invalid or expired, disable biometric login
|
// If token is invalid or expired, disable biometric login
|
||||||
await _biometricService.disableBiometricLogin();
|
await _biometricService.disableBiometricLogin();
|
||||||
|
@ -63,6 +91,7 @@ class AuthenticationRepository extends GetxController {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
static bool _isRedirecting = false;
|
static bool _isRedirecting = false;
|
||||||
|
|
||||||
/// Updated screenRedirect method to handle onboarding preferences
|
/// Updated screenRedirect method to handle onboarding preferences
|
||||||
|
@ -825,23 +854,4 @@ class AuthenticationRepository extends GetxController {
|
||||||
throw TExceptions('Something went wrong. Please try again later.');
|
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';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
|
@ -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/password_field.dart';
|
||||||
import 'package:sigap/src/features/auth/presentasion/widgets/social_button.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/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/constants/image_strings.dart'; // Added for logo image
|
||||||
import 'package:sigap/src/utils/helpers/helper_functions.dart';
|
import 'package:sigap/src/utils/helpers/helper_functions.dart';
|
||||||
import 'package:sigap/src/utils/validators/validation.dart';
|
import 'package:sigap/src/utils/validators/validation.dart';
|
||||||
|
@ -66,6 +67,7 @@ class SignInScreen extends StatelessWidget {
|
||||||
errorText: controller.emailError.value,
|
errorText: controller.emailError.value,
|
||||||
textInputAction: TextInputAction.next,
|
textInputAction: TextInputAction.next,
|
||||||
prefixIcon: const Icon(TablerIcons.mail, size: 20),
|
prefixIcon: const Icon(TablerIcons.mail, size: 20),
|
||||||
|
hintText: 'Enter your email address',
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|
||||||
|
@ -79,6 +81,7 @@ class SignInScreen extends StatelessWidget {
|
||||||
errorText: controller.passwordError.value,
|
errorText: controller.passwordError.value,
|
||||||
onToggleVisibility: controller.togglePasswordVisibility,
|
onToggleVisibility: controller.togglePasswordVisibility,
|
||||||
prefixIcon: const Icon(TablerIcons.lock, size: 20),
|
prefixIcon: const Icon(TablerIcons.lock, size: 20),
|
||||||
|
hintText: 'Enter your password',
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|
||||||
|
@ -117,17 +120,12 @@ class SignInScreen extends StatelessWidget {
|
||||||
onPressed: controller.signInWithBiometrics,
|
onPressed: controller.signInWithBiometrics,
|
||||||
style: ElevatedButton.styleFrom(
|
style: ElevatedButton.styleFrom(
|
||||||
backgroundColor:
|
backgroundColor:
|
||||||
Theme.of(context).colorScheme.surface,
|
isDarkMode ? TColors.primary : TColors.accent,
|
||||||
foregroundColor:
|
foregroundColor:
|
||||||
Theme.of(context).colorScheme.primary,
|
Theme.of(context).primaryColor,
|
||||||
elevation: 0,
|
elevation: 0,
|
||||||
shape: RoundedRectangleBorder(
|
shape: RoundedRectangleBorder(
|
||||||
borderRadius: BorderRadius.circular(30),
|
borderRadius: BorderRadius.circular(30),
|
||||||
side: BorderSide(
|
|
||||||
color: Theme.of(
|
|
||||||
context,
|
|
||||||
).colorScheme.outline.withOpacity(0.3),
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
padding: const EdgeInsets.symmetric(vertical: 12),
|
padding: const EdgeInsets.symmetric(vertical: 12),
|
||||||
minimumSize: const Size(double.infinity, 0),
|
minimumSize: const Size(double.infinity, 0),
|
||||||
|
|
|
@ -1,4 +1,7 @@
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter/widgets.dart';
|
||||||
import 'package:get/get.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/profile_model.dart';
|
||||||
import 'package:sigap/src/features/personalization/data/models/models/users_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';
|
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/privacy_controller.dart';
|
||||||
import 'package:sigap/src/features/personalization/presentasion/controllers/settings/security_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/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 {
|
class SettingsController extends BaseSettingsController {
|
||||||
|
// Singleton instance
|
||||||
|
static SettingsController get instance => Get.find<SettingsController>();
|
||||||
|
|
||||||
// User info
|
// User info
|
||||||
final Rx<UserModel?> currentUser = Rx<UserModel?>(null);
|
final Rx<UserModel?> currentUser = Rx<UserModel?>(null);
|
||||||
final Rx<ProfileModel?> userProfile = Rx<ProfileModel?>(null);
|
final Rx<ProfileModel?> userProfile = Rx<ProfileModel?>(null);
|
||||||
|
|
||||||
|
// Get auth repository for sign out functionality
|
||||||
|
final AuthenticationRepository _authRepo =
|
||||||
|
Get.find<AuthenticationRepository>();
|
||||||
|
|
||||||
// Current tab index
|
// Current tab index
|
||||||
final RxInt selectedTabIndex = 0.obs;
|
final RxInt selectedTabIndex = 0.obs;
|
||||||
|
|
||||||
|
@ -27,6 +40,9 @@ class SettingsController extends BaseSettingsController {
|
||||||
late final EmailController emailController;
|
late final EmailController emailController;
|
||||||
late final TextSizeController textSizeController;
|
late final TextSizeController textSizeController;
|
||||||
|
|
||||||
|
// Track sign out loading state
|
||||||
|
final RxBool isSigningOut = false.obs;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void onInit() {
|
void onInit() {
|
||||||
super.onInit();
|
super.onInit();
|
||||||
|
@ -113,4 +129,31 @@ class SettingsController extends BaseSettingsController {
|
||||||
setLoading(false);
|
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.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -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/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/terms_services_screen.dart';
|
||||||
import 'package:sigap/src/features/personalization/presentasion/pages/settings/widgets/text_size_setting.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';
|
import 'package:sigap/src/utils/constants/sizes.dart';
|
||||||
|
|
||||||
class SettingsScreen extends StatefulWidget {
|
class SettingsScreen extends StatefulWidget {
|
||||||
|
@ -24,9 +25,9 @@ class SettingsScreen extends StatefulWidget {
|
||||||
class _SettingsScreenState extends State<SettingsScreen>
|
class _SettingsScreenState extends State<SettingsScreen>
|
||||||
with SingleTickerProviderStateMixin {
|
with SingleTickerProviderStateMixin {
|
||||||
late TabController _tabController;
|
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
|
// Get the profile controller to access user data
|
||||||
|
final SettingsController _settingController = Get.find<SettingsController>();
|
||||||
final ProfileController _profileController = Get.find<ProfileController>();
|
final ProfileController _profileController = Get.find<ProfileController>();
|
||||||
|
|
||||||
@override
|
@override
|
||||||
|
@ -80,7 +81,7 @@ class _SettingsScreenState extends State<SettingsScreen>
|
||||||
child: Text(
|
child: Text(
|
||||||
'Failed to load profile',
|
'Failed to load profile',
|
||||||
style: theme.textTheme.bodyMedium?.copyWith(
|
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 IconData icon,
|
||||||
required String title,
|
required String title,
|
||||||
required VoidCallback onTap,
|
required VoidCallback onTap,
|
||||||
|
bool isDestructive = false,
|
||||||
}) {
|
}) {
|
||||||
final theme = Theme.of(context);
|
final theme = Theme.of(context);
|
||||||
|
|
||||||
|
@ -342,16 +353,30 @@ class _SettingsScreenState extends State<SettingsScreen>
|
||||||
onTap: onTap,
|
onTap: onTap,
|
||||||
child: Container(
|
child: Container(
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 16),
|
padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 16),
|
||||||
|
|
||||||
child: Row(
|
child: Row(
|
||||||
children: [
|
children: [
|
||||||
Icon(icon, size: 24, color: theme.iconTheme.color),
|
Icon(
|
||||||
|
icon,
|
||||||
|
size: 24,
|
||||||
|
color: isDestructive ? TColors.error : theme.iconTheme.color,
|
||||||
|
),
|
||||||
const SizedBox(width: 15),
|
const SizedBox(width: 15),
|
||||||
Expanded(child: Text(title, style: theme.textTheme.bodyLarge)),
|
Expanded(
|
||||||
Icon(Icons.chevron_right, color: theme.hintColor),
|
child: Text(
|
||||||
|
title,
|
||||||
|
style: theme.textTheme.bodyLarge?.copyWith(
|
||||||
|
color: isDestructive ? TColors.error : null,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Icon(
|
||||||
|
Icons.chevron_right,
|
||||||
|
color: isDestructive ? TColors.error : theme.hintColor,
|
||||||
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -78,4 +78,7 @@ class TLoader {
|
||||||
static Widget centeredLoader({double? size, Color? color}) {
|
static Widget centeredLoader({double? size, Color? color}) {
|
||||||
return Center(child: DCircularLoader(size: size, color: color));
|
return Center(child: DCircularLoader(size: size, color: color));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -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),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
Loading…
Reference in New Issue