From 21f14be9490440d045fc12a8e57a3cb8da1da516 Mon Sep 17 00:00:00 2001 From: Alex Date: Tue, 26 Nov 2024 12:43:44 -0600 Subject: [PATCH] chore(mobile): refactor authentication (#14322) --- mobile/analysis_options.yaml | 2 +- mobile/lib/interfaces/auth.interface.dart | 5 + mobile/lib/interfaces/auth_api.interface.dart | 9 + .../auth_state.model.dart} | 19 +- .../lib/models/auth/login_response.model.dart | 30 +++ .../lib/pages/common/album_options.page.dart | 4 +- .../lib/pages/common/album_viewer.page.dart | 4 +- .../lib/pages/common/splash_screen.page.dart | 13 +- .../providers/app_life_cycle.provider.dart | 6 +- mobile/lib/providers/auth.provider.dart | 164 ++++++++++++ .../providers/authentication.provider.dart | 245 ------------------ .../lib/providers/backup/backup.provider.dart | 8 +- mobile/lib/providers/websocket.provider.dart | 4 +- mobile/lib/repositories/auth.repository.dart | 28 ++ .../lib/repositories/auth_api.repository.dart | 56 ++++ .../lib/repositories/database.repository.dart | 1 - mobile/lib/services/api.service.dart | 21 +- mobile/lib/services/auth.service.dart | 96 +++++++ mobile/lib/services/device.service.dart | 24 ++ mobile/lib/services/user.service.dart | 5 +- .../common/app_bar_dialog/app_bar_dialog.dart | 4 +- .../app_bar_dialog/app_bar_profile_info.dart | 7 +- .../widgets/forms/change_password_form.dart | 10 +- .../lib/widgets/forms/login/login_form.dart | 84 ++---- mobile/test/repository.mocks.dart | 6 + mobile/test/services/auth.service_test.dart | 118 +++++++++ 26 files changed, 619 insertions(+), 354 deletions(-) create mode 100644 mobile/lib/interfaces/auth.interface.dart create mode 100644 mobile/lib/interfaces/auth_api.interface.dart rename mobile/lib/models/{authentication/authentication_state.model.dart => auth/auth_state.model.dart} (74%) create mode 100644 mobile/lib/models/auth/login_response.model.dart create mode 100644 mobile/lib/providers/auth.provider.dart delete mode 100644 mobile/lib/providers/authentication.provider.dart create mode 100644 mobile/lib/repositories/auth.repository.dart create mode 100644 mobile/lib/repositories/auth_api.repository.dart create mode 100644 mobile/lib/services/auth.service.dart create mode 100644 mobile/lib/services/device.service.dart create mode 100644 mobile/test/services/auth.service_test.dart diff --git a/mobile/analysis_options.yaml b/mobile/analysis_options.yaml index 80514f1603..7a20c2a6a3 100644 --- a/mobile/analysis_options.yaml +++ b/mobile/analysis_options.yaml @@ -93,7 +93,7 @@ custom_lint: - lib/models/server_info/server_{config,disk_info,features,version}.model.dart - lib/models/shared_link/shared_link.model.dart - lib/providers/asset_viewer/asset_people.provider.dart - - lib/providers/authentication.provider.dart + - lib/providers/auth.provider.dart - lib/providers/image/immich_remote_{image,thumbnail}_provider.dart - lib/providers/map/map_state.provider.dart - lib/providers/search/{search,search_filter}.provider.dart diff --git a/mobile/lib/interfaces/auth.interface.dart b/mobile/lib/interfaces/auth.interface.dart new file mode 100644 index 0000000000..e37323b994 --- /dev/null +++ b/mobile/lib/interfaces/auth.interface.dart @@ -0,0 +1,5 @@ +import 'package:immich_mobile/interfaces/database.interface.dart'; + +abstract interface class IAuthRepository implements IDatabaseRepository { + Future clearLocalData(); +} diff --git a/mobile/lib/interfaces/auth_api.interface.dart b/mobile/lib/interfaces/auth_api.interface.dart new file mode 100644 index 0000000000..0a4b235ff3 --- /dev/null +++ b/mobile/lib/interfaces/auth_api.interface.dart @@ -0,0 +1,9 @@ +import 'package:immich_mobile/models/auth/login_response.model.dart'; + +abstract interface class IAuthApiRepository { + Future login(String email, String password); + + Future logout(); + + Future changePassword(String newPassword); +} diff --git a/mobile/lib/models/authentication/authentication_state.model.dart b/mobile/lib/models/auth/auth_state.model.dart similarity index 74% rename from mobile/lib/models/authentication/authentication_state.model.dart rename to mobile/lib/models/auth/auth_state.model.dart index 9dcd320c81..fb65850f1d 100644 --- a/mobile/lib/models/authentication/authentication_state.model.dart +++ b/mobile/lib/models/auth/auth_state.model.dart @@ -1,62 +1,58 @@ -class AuthenticationState { +class AuthState { final String deviceId; final String userId; final String userEmail; final bool isAuthenticated; final String name; final bool isAdmin; - final bool shouldChangePassword; final String profileImagePath; - AuthenticationState({ + + AuthState({ required this.deviceId, required this.userId, required this.userEmail, required this.isAuthenticated, required this.name, required this.isAdmin, - required this.shouldChangePassword, required this.profileImagePath, }); - AuthenticationState copyWith({ + AuthState copyWith({ String? deviceId, String? userId, String? userEmail, bool? isAuthenticated, String? name, bool? isAdmin, - bool? shouldChangePassword, String? profileImagePath, }) { - return AuthenticationState( + return AuthState( deviceId: deviceId ?? this.deviceId, userId: userId ?? this.userId, userEmail: userEmail ?? this.userEmail, isAuthenticated: isAuthenticated ?? this.isAuthenticated, name: name ?? this.name, isAdmin: isAdmin ?? this.isAdmin, - shouldChangePassword: shouldChangePassword ?? this.shouldChangePassword, profileImagePath: profileImagePath ?? this.profileImagePath, ); } @override String toString() { - return 'AuthenticationState(deviceId: $deviceId, userId: $userId, userEmail: $userEmail, isAuthenticated: $isAuthenticated, name: $name, isAdmin: $isAdmin, shouldChangePassword: $shouldChangePassword, profileImagePath: $profileImagePath)'; + return 'AuthenticationState(deviceId: $deviceId, userId: $userId, userEmail: $userEmail, isAuthenticated: $isAuthenticated, name: $name, isAdmin: $isAdmin, profileImagePath: $profileImagePath)'; } @override bool operator ==(Object other) { if (identical(this, other)) return true; - return other is AuthenticationState && + return other is AuthState && other.deviceId == deviceId && other.userId == userId && other.userEmail == userEmail && other.isAuthenticated == isAuthenticated && other.name == name && other.isAdmin == isAdmin && - other.shouldChangePassword == shouldChangePassword && other.profileImagePath == profileImagePath; } @@ -68,7 +64,6 @@ class AuthenticationState { isAuthenticated.hashCode ^ name.hashCode ^ isAdmin.hashCode ^ - shouldChangePassword.hashCode ^ profileImagePath.hashCode; } } diff --git a/mobile/lib/models/auth/login_response.model.dart b/mobile/lib/models/auth/login_response.model.dart new file mode 100644 index 0000000000..f1398418ca --- /dev/null +++ b/mobile/lib/models/auth/login_response.model.dart @@ -0,0 +1,30 @@ +class LoginResponse { + final String accessToken; + + final bool isAdmin; + + final String name; + + final String profileImagePath; + + final bool shouldChangePassword; + + final String userEmail; + + final String userId; + + LoginResponse({ + required this.accessToken, + required this.isAdmin, + required this.name, + required this.profileImagePath, + required this.shouldChangePassword, + required this.userEmail, + required this.userId, + }); + + @override + String toString() { + return 'LoginResponse[accessToken=$accessToken, isAdmin=$isAdmin, name=$name, profileImagePath=$profileImagePath, shouldChangePassword=$shouldChangePassword, userEmail=$userEmail, userId=$userId]'; + } +} diff --git a/mobile/lib/pages/common/album_options.page.dart b/mobile/lib/pages/common/album_options.page.dart index 93e4c180fe..d9f8544af9 100644 --- a/mobile/lib/pages/common/album_options.page.dart +++ b/mobile/lib/pages/common/album_options.page.dart @@ -7,7 +7,7 @@ import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:immich_mobile/extensions/build_context_extensions.dart'; import 'package:immich_mobile/extensions/theme_extensions.dart'; import 'package:immich_mobile/providers/album/album.provider.dart'; -import 'package:immich_mobile/providers/authentication.provider.dart'; +import 'package:immich_mobile/providers/auth.provider.dart'; import 'package:immich_mobile/utils/immich_loading_overlay.dart'; import 'package:immich_mobile/routing/router.dart'; import 'package:immich_mobile/entities/album.entity.dart'; @@ -25,7 +25,7 @@ class AlbumOptionsPage extends HookConsumerWidget { Widget build(BuildContext context, WidgetRef ref) { final sharedUsers = useState(album.sharedUsers.toList()); final owner = album.owner.value; - final userId = ref.watch(authenticationProvider).userId; + final userId = ref.watch(authProvider).userId; final activityEnabled = useState(album.activityEnabled); final isProcessing = useProcessingOverlay(); final isOwner = owner?.id == userId; diff --git a/mobile/lib/pages/common/album_viewer.page.dart b/mobile/lib/pages/common/album_viewer.page.dart index 97885ae4e6..4822c57a07 100644 --- a/mobile/lib/pages/common/album_viewer.page.dart +++ b/mobile/lib/pages/common/album_viewer.page.dart @@ -15,7 +15,7 @@ import 'package:immich_mobile/utils/immich_loading_overlay.dart'; import 'package:immich_mobile/widgets/album/album_action_filled_button.dart'; import 'package:immich_mobile/widgets/album/album_viewer_editable_title.dart'; import 'package:immich_mobile/providers/multiselect.provider.dart'; -import 'package:immich_mobile/providers/authentication.provider.dart'; +import 'package:immich_mobile/providers/auth.provider.dart'; import 'package:immich_mobile/widgets/album/album_viewer_appbar.dart'; import 'package:immich_mobile/routing/router.dart'; import 'package:immich_mobile/entities/album.entity.dart'; @@ -42,7 +42,7 @@ class AlbumViewerPage extends HookConsumerWidget { () => ref.read(currentAlbumProvider.notifier).set(value), ), ); - final userId = ref.watch(authenticationProvider).userId; + final userId = ref.watch(authProvider).userId; final isProcessing = useProcessingOverlay(); Future onRemoveFromAlbumPressed(Iterable assets) async { diff --git a/mobile/lib/pages/common/splash_screen.page.dart b/mobile/lib/pages/common/splash_screen.page.dart index d23e25372c..d88c6cf366 100644 --- a/mobile/lib/pages/common/splash_screen.page.dart +++ b/mobile/lib/pages/common/splash_screen.page.dart @@ -3,11 +3,10 @@ import 'package:flutter/material.dart'; import 'package:flutter_hooks/flutter_hooks.dart' hide Store; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:immich_mobile/providers/backup/backup.provider.dart'; -import 'package:immich_mobile/providers/authentication.provider.dart'; +import 'package:immich_mobile/providers/auth.provider.dart'; import 'package:immich_mobile/providers/gallery_permission.provider.dart'; import 'package:immich_mobile/routing/router.dart'; import 'package:immich_mobile/entities/store.entity.dart'; -import 'package:immich_mobile/providers/api.provider.dart'; import 'package:logging/logging.dart'; @RoutePage() @@ -16,7 +15,6 @@ class SplashScreenPage extends HookConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { - final apiService = ref.watch(apiServiceProvider); final serverUrl = Store.tryGet(StoreKey.serverUrl); final endpoint = Store.tryGet(StoreKey.serverEndpoint); final accessToken = Store.tryGet(StoreKey.accessToken); @@ -26,14 +24,9 @@ class SplashScreenPage extends HookConsumerWidget { bool isAuthSuccess = false; if (accessToken != null && serverUrl != null && endpoint != null) { - apiService.setEndpoint(endpoint); - try { - isAuthSuccess = await ref - .read(authenticationProvider.notifier) - .setSuccessLoginInfo( + isAuthSuccess = await ref.read(authProvider.notifier).saveAuthInfo( accessToken: accessToken, - serverUrl: serverUrl, ); } catch (error, stackTrace) { log.severe( @@ -53,7 +46,7 @@ class SplashScreenPage extends HookConsumerWidget { log.severe( 'Unable to login using offline or online methods - Logging out completely', ); - ref.read(authenticationProvider.notifier).logout(); + ref.read(authProvider.notifier).logout(); context.replaceRoute(const LoginRoute()); return; } diff --git a/mobile/lib/providers/app_life_cycle.provider.dart b/mobile/lib/providers/app_life_cycle.provider.dart index c06a99da35..8cacb70eb2 100644 --- a/mobile/lib/providers/app_life_cycle.provider.dart +++ b/mobile/lib/providers/app_life_cycle.provider.dart @@ -5,7 +5,7 @@ import 'package:immich_mobile/models/backup/backup_state.model.dart'; import 'package:immich_mobile/providers/backup/backup.provider.dart'; import 'package:immich_mobile/providers/backup/ios_background_settings.provider.dart'; import 'package:immich_mobile/providers/backup/manual_upload.provider.dart'; -import 'package:immich_mobile/providers/authentication.provider.dart'; +import 'package:immich_mobile/providers/auth.provider.dart'; import 'package:immich_mobile/providers/memory.provider.dart'; import 'package:immich_mobile/providers/gallery_permission.provider.dart'; import 'package:immich_mobile/providers/notification_permission.provider.dart'; @@ -42,7 +42,7 @@ class AppLifeCycleNotifier extends StateNotifier { if (!_wasPaused) return; _wasPaused = false; - final isAuthenticated = _ref.read(authenticationProvider).isAuthenticated; + final isAuthenticated = _ref.read(authProvider).isAuthenticated; // Needs to be logged in if (isAuthenticated) { @@ -85,7 +85,7 @@ class AppLifeCycleNotifier extends StateNotifier { state = AppLifeCycleEnum.paused; _wasPaused = true; - if (_ref.read(authenticationProvider).isAuthenticated) { + if (_ref.read(authProvider).isAuthenticated) { // Do not cancel backup if manual upload is in progress if (_ref.read(backupProvider.notifier).backupProgress != BackUpProgressEnum.manualInProgress) { diff --git a/mobile/lib/providers/auth.provider.dart b/mobile/lib/providers/auth.provider.dart new file mode 100644 index 0000000000..5efbdab8d3 --- /dev/null +++ b/mobile/lib/providers/auth.provider.dart @@ -0,0 +1,164 @@ +import 'package:flutter/foundation.dart'; +import 'package:flutter_udid/flutter_udid.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:immich_mobile/entities/store.entity.dart'; +import 'package:immich_mobile/models/auth/login_response.model.dart'; +import 'package:immich_mobile/models/auth/auth_state.model.dart'; +import 'package:immich_mobile/entities/user.entity.dart'; +import 'package:immich_mobile/providers/api.provider.dart'; +import 'package:immich_mobile/services/api.service.dart'; +import 'package:immich_mobile/services/auth.service.dart'; +import 'package:immich_mobile/utils/hash.dart'; +import 'package:logging/logging.dart'; +import 'package:openapi/api.dart'; + +final authProvider = StateNotifierProvider((ref) { + return AuthNotifier( + ref.watch(authServiceProvider), + ref.watch(apiServiceProvider), + ); +}); + +class AuthNotifier extends StateNotifier { + final AuthService _authService; + final ApiService _apiService; + final _log = Logger("AuthenticationNotifier"); + + static const Duration _timeoutDuration = Duration(seconds: 7); + + AuthNotifier( + this._authService, + this._apiService, + ) : super( + AuthState( + deviceId: "", + userId: "", + userEmail: "", + name: '', + profileImagePath: '', + isAdmin: false, + isAuthenticated: false, + ), + ); + + Future validateServerUrl(String url) { + return _authService.validateServerUrl(url); + } + + Future login(String email, String password) async { + final response = await _authService.login(email, password); + await saveAuthInfo(accessToken: response.accessToken); + return response; + } + + Future logout() async { + try { + await _authService.logout(); + } finally { + await _cleanUp(); + } + } + + Future _cleanUp() async { + state = AuthState( + deviceId: "", + userId: "", + userEmail: "", + name: '', + profileImagePath: '', + isAdmin: false, + isAuthenticated: false, + ); + } + + void updateUserProfileImagePath(String path) { + state = state.copyWith(profileImagePath: path); + } + + Future changePassword(String newPassword) async { + try { + await _authService.changePassword(newPassword); + return true; + } catch (_) { + return false; + } + } + + Future saveAuthInfo({ + required String accessToken, + }) async { + _apiService.setAccessToken(accessToken); + + // Get the deviceid from the store if it exists, otherwise generate a new one + String deviceId = + Store.tryGet(StoreKey.deviceId) ?? await FlutterUdid.consistentUdid; + + User? user = Store.tryGet(StoreKey.currentUser); + + UserAdminResponseDto? userResponse; + UserPreferencesResponseDto? userPreferences; + try { + final responses = await Future.wait([ + _apiService.usersApi.getMyUser().timeout(_timeoutDuration), + _apiService.usersApi.getMyPreferences().timeout(_timeoutDuration), + ]); + userResponse = responses[0] as UserAdminResponseDto; + userPreferences = responses[1] as UserPreferencesResponseDto; + } on ApiException catch (error, stackTrace) { + if (error.code == 401) { + _log.severe("Unauthorized access, token likely expired. Logging out."); + return false; + } + _log.severe( + "Error getting user information from the server [API EXCEPTION]", + stackTrace, + ); + } catch (error, stackTrace) { + _log.severe( + "Error getting user information from the server [CATCH ALL]", + error, + stackTrace, + ); + + if (kDebugMode) { + debugPrint( + "Error getting user information from the server [CATCH ALL] $error $stackTrace", + ); + } + } + + // If the user information is successfully retrieved, update the store + // Due to the flow of the code, this will always happen on first login + if (userResponse != null) { + Store.put(StoreKey.deviceId, deviceId); + Store.put(StoreKey.deviceIdHash, fastHash(deviceId)); + Store.put( + StoreKey.currentUser, + User.fromUserDto(userResponse, userPreferences), + ); + Store.put(StoreKey.accessToken, accessToken); + + user = User.fromUserDto(userResponse, userPreferences); + } else { + _log.severe("Unable to get user information from the server."); + } + + // If the user is null, the login was not successful + // and we don't have a local copy of the user from a prior successful login + if (user == null) { + return false; + } + + state = state.copyWith( + isAuthenticated: true, + userId: user.id, + userEmail: user.email, + name: user.name, + profileImagePath: user.profileImagePath, + isAdmin: user.isAdmin, + deviceId: deviceId, + ); + + return true; + } +} diff --git a/mobile/lib/providers/authentication.provider.dart b/mobile/lib/providers/authentication.provider.dart deleted file mode 100644 index 60e31d707e..0000000000 --- a/mobile/lib/providers/authentication.provider.dart +++ /dev/null @@ -1,245 +0,0 @@ -import 'dart:io'; - -import 'package:device_info_plus/device_info_plus.dart'; -import 'package:flutter/material.dart'; -import 'package:flutter_udid/flutter_udid.dart'; -import 'package:hooks_riverpod/hooks_riverpod.dart'; -import 'package:immich_mobile/providers/album/album.provider.dart'; -import 'package:immich_mobile/entities/store.entity.dart'; -import 'package:immich_mobile/models/authentication/authentication_state.model.dart'; -import 'package:immich_mobile/entities/user.entity.dart'; -import 'package:immich_mobile/providers/api.provider.dart'; -import 'package:immich_mobile/providers/db.provider.dart'; -import 'package:immich_mobile/services/api.service.dart'; -import 'package:immich_mobile/utils/db.dart'; -import 'package:immich_mobile/utils/hash.dart'; -import 'package:isar/isar.dart'; -import 'package:logging/logging.dart'; -import 'package:openapi/api.dart'; - -class AuthenticationNotifier extends StateNotifier { - AuthenticationNotifier( - this._apiService, - this._db, - this._ref, - ) : super( - AuthenticationState( - deviceId: "", - userId: "", - userEmail: "", - name: '', - profileImagePath: '', - isAdmin: false, - shouldChangePassword: false, - isAuthenticated: false, - ), - ); - - final ApiService _apiService; - final Isar _db; - final StateNotifierProviderRef - _ref; - final _log = Logger("AuthenticationNotifier"); - - static const Duration _timeoutDuration = Duration(seconds: 7); - - Future login( - String email, - String password, - String serverUrl, - ) async { - try { - // Resolve API server endpoint from user provided serverUrl - await _apiService.resolveAndSetEndpoint(serverUrl); - await _apiService.serverInfoApi.pingServer(); - } catch (e) { - debugPrint('Invalid Server Endpoint Url $e'); - return false; - } - - // Make sign-in request - DeviceInfoPlugin deviceInfoPlugin = DeviceInfoPlugin(); - - if (Platform.isIOS) { - var iosInfo = await deviceInfoPlugin.iosInfo; - _apiService.authenticationApi.apiClient - .addDefaultHeader('deviceModel', iosInfo.utsname.machine); - _apiService.authenticationApi.apiClient - .addDefaultHeader('deviceType', 'iOS'); - } else { - var androidInfo = await deviceInfoPlugin.androidInfo; - _apiService.authenticationApi.apiClient - .addDefaultHeader('deviceModel', androidInfo.model); - _apiService.authenticationApi.apiClient - .addDefaultHeader('deviceType', 'Android'); - } - - try { - var loginResponse = await _apiService.authenticationApi.login( - LoginCredentialDto( - email: email, - password: password, - ), - ); - - if (loginResponse == null) { - debugPrint('Login Response is null'); - return false; - } - - return setSuccessLoginInfo( - accessToken: loginResponse.accessToken, - serverUrl: serverUrl, - ); - } catch (e) { - debugPrint("Error logging in $e"); - return false; - } - } - - Future logout() async { - var log = Logger('AuthenticationNotifier'); - try { - String? userEmail = Store.tryGet(StoreKey.currentUser)?.email; - - await _apiService.authenticationApi - .logout() - .timeout(_timeoutDuration) - .then((_) => log.info("Logout was successful for $userEmail")) - .onError( - (error, stackTrace) => - log.severe("Logout failed for $userEmail", error, stackTrace), - ); - } catch (e, stack) { - log.severe('Logout failed', e, stack); - } finally { - await Future.wait([ - clearAssetsAndAlbums(_db), - Store.delete(StoreKey.currentUser), - Store.delete(StoreKey.accessToken), - ]); - _ref.invalidate(albumProvider); - - state = state.copyWith( - deviceId: "", - userId: "", - userEmail: "", - name: '', - profileImagePath: '', - isAdmin: false, - shouldChangePassword: false, - isAuthenticated: false, - ); - } - } - - updateUserProfileImagePath(String path) { - state = state.copyWith(profileImagePath: path); - } - - Future changePassword(String newPassword) async { - try { - await _apiService.usersApi.updateMyUser( - UserUpdateMeDto( - password: newPassword, - ), - ); - - state = state.copyWith(shouldChangePassword: false); - - return true; - } catch (e) { - debugPrint("Error changing password $e"); - return false; - } - } - - Future setSuccessLoginInfo({ - required String accessToken, - required String serverUrl, - }) async { - _apiService.setAccessToken(accessToken); - - // Get the deviceid from the store if it exists, otherwise generate a new one - String deviceId = - Store.tryGet(StoreKey.deviceId) ?? await FlutterUdid.consistentUdid; - - bool shouldChangePassword = false; - User? user = Store.tryGet(StoreKey.currentUser); - - UserAdminResponseDto? userResponse; - UserPreferencesResponseDto? userPreferences; - try { - final responses = await Future.wait([ - _apiService.usersApi.getMyUser().timeout(_timeoutDuration), - _apiService.usersApi.getMyPreferences().timeout(_timeoutDuration), - ]); - userResponse = responses[0] as UserAdminResponseDto; - userPreferences = responses[1] as UserPreferencesResponseDto; - } on ApiException catch (error, stackTrace) { - if (error.code == 401) { - _log.severe("Unauthorized access, token likely expired. Logging out."); - return false; - } - _log.severe( - "Error getting user information from the server [API EXCEPTION]", - stackTrace, - ); - } catch (error, stackTrace) { - _log.severe( - "Error getting user information from the server [CATCH ALL]", - error, - stackTrace, - ); - debugPrint( - "Error getting user information from the server [CATCH ALL] $error $stackTrace", - ); - } - - // If the user information is successfully retrieved, update the store - // Due to the flow of the code, this will always happen on first login - if (userResponse != null) { - Store.put(StoreKey.deviceId, deviceId); - Store.put(StoreKey.deviceIdHash, fastHash(deviceId)); - Store.put( - StoreKey.currentUser, - User.fromUserDto(userResponse, userPreferences), - ); - Store.put(StoreKey.serverUrl, serverUrl); - Store.put(StoreKey.accessToken, accessToken); - - shouldChangePassword = userResponse.shouldChangePassword; - user = User.fromUserDto(userResponse, userPreferences); - } else { - _log.severe("Unable to get user information from the server."); - } - - // If the user is null, the login was not successful - // and we don't have a local copy of the user from a prior successful login - if (user == null) { - return false; - } - - state = state.copyWith( - isAuthenticated: true, - userId: user.id, - userEmail: user.email, - name: user.name, - profileImagePath: user.profileImagePath, - isAdmin: user.isAdmin, - shouldChangePassword: shouldChangePassword, - deviceId: deviceId, - ); - - return true; - } -} - -final authenticationProvider = - StateNotifierProvider((ref) { - return AuthenticationNotifier( - ref.watch(apiServiceProvider), - ref.watch(dbProvider), - ref, - ); -}); diff --git a/mobile/lib/providers/backup/backup.provider.dart b/mobile/lib/providers/backup/backup.provider.dart index dc6d2f7cc8..aab367485c 100644 --- a/mobile/lib/providers/backup/backup.provider.dart +++ b/mobile/lib/providers/backup/backup.provider.dart @@ -22,8 +22,8 @@ import 'package:immich_mobile/repositories/backup.repository.dart'; import 'package:immich_mobile/repositories/file_media.repository.dart'; import 'package:immich_mobile/services/background.service.dart'; import 'package:immich_mobile/services/backup.service.dart'; -import 'package:immich_mobile/models/authentication/authentication_state.model.dart'; -import 'package:immich_mobile/providers/authentication.provider.dart'; +import 'package:immich_mobile/models/auth/auth_state.model.dart'; +import 'package:immich_mobile/providers/auth.provider.dart'; import 'package:immich_mobile/providers/gallery_permission.provider.dart'; import 'package:immich_mobile/models/server_info/server_disk_info.model.dart'; import 'package:immich_mobile/entities/store.entity.dart'; @@ -92,7 +92,7 @@ class BackupNotifier extends StateNotifier { final log = Logger('BackupNotifier'); final BackupService _backupService; final ServerInfoService _serverInfoService; - final AuthenticationState _authState; + final AuthState _authState; final BackgroundService _backgroundService; final GalleryPermissionNotifier _galleryPermissionNotifier; final Isar _db; @@ -765,7 +765,7 @@ final backupProvider = return BackupNotifier( ref.watch(backupServiceProvider), ref.watch(serverInfoServiceProvider), - ref.watch(authenticationProvider), + ref.watch(authProvider), ref.watch(backgroundServiceProvider), ref.watch(galleryPermissionNotifier.notifier), ref.watch(dbProvider), diff --git a/mobile/lib/providers/websocket.provider.dart b/mobile/lib/providers/websocket.provider.dart index 6216a5de64..6889db7b7f 100644 --- a/mobile/lib/providers/websocket.provider.dart +++ b/mobile/lib/providers/websocket.provider.dart @@ -4,7 +4,7 @@ import 'package:collection/collection.dart'; import 'package:flutter/foundation.dart'; import 'package:flutter/widgets.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; -import 'package:immich_mobile/providers/authentication.provider.dart'; +import 'package:immich_mobile/providers/auth.provider.dart'; import 'package:immich_mobile/entities/asset.entity.dart'; import 'package:immich_mobile/models/server_info/server_version.model.dart'; import 'package:immich_mobile/entities/store.entity.dart'; @@ -103,7 +103,7 @@ class WebsocketNotifier extends StateNotifier { /// Connects websocket to server unless already connected void connect() { if (state.isConnected) return; - final authenticationState = _ref.read(authenticationProvider); + final authenticationState = _ref.read(authProvider); if (authenticationState.isAuthenticated) { try { diff --git a/mobile/lib/repositories/auth.repository.dart b/mobile/lib/repositories/auth.repository.dart new file mode 100644 index 0000000000..f003890696 --- /dev/null +++ b/mobile/lib/repositories/auth.repository.dart @@ -0,0 +1,28 @@ +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:immich_mobile/entities/album.entity.dart'; +import 'package:immich_mobile/entities/asset.entity.dart'; +import 'package:immich_mobile/entities/etag.entity.dart'; +import 'package:immich_mobile/entities/exif_info.entity.dart'; +import 'package:immich_mobile/entities/user.entity.dart'; +import 'package:immich_mobile/interfaces/auth.interface.dart'; +import 'package:immich_mobile/providers/db.provider.dart'; +import 'package:immich_mobile/repositories/database.repository.dart'; + +final authRepositoryProvider = Provider( + (ref) => AuthRepository(ref.watch(dbProvider)), +); + +class AuthRepository extends DatabaseRepository implements IAuthRepository { + AuthRepository(super.db); + + @override + Future clearLocalData() { + return db.writeTxn(() async { + await db.assets.clear(); + await db.exifInfos.clear(); + await db.albums.clear(); + await db.eTags.clear(); + await db.users.clear(); + }); + } +} diff --git a/mobile/lib/repositories/auth_api.repository.dart b/mobile/lib/repositories/auth_api.repository.dart new file mode 100644 index 0000000000..faa2916adb --- /dev/null +++ b/mobile/lib/repositories/auth_api.repository.dart @@ -0,0 +1,56 @@ +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:immich_mobile/interfaces/auth_api.interface.dart'; +import 'package:immich_mobile/models/auth/login_response.model.dart'; +import 'package:immich_mobile/providers/api.provider.dart'; +import 'package:immich_mobile/repositories/api.repository.dart'; +import 'package:immich_mobile/services/api.service.dart'; +import 'package:openapi/api.dart'; + +final authApiRepositoryProvider = + Provider((ref) => AuthApiRepository(ref.watch(apiServiceProvider))); + +class AuthApiRepository extends ApiRepository implements IAuthApiRepository { + final ApiService _apiService; + + AuthApiRepository(this._apiService); + + @override + Future changePassword(String newPassword) async { + await _apiService.usersApi.updateMyUser( + UserUpdateMeDto( + password: newPassword, + ), + ); + } + + @override + Future login(String email, String password) async { + final loginResponseDto = await checkNull( + _apiService.authenticationApi.login( + LoginCredentialDto( + email: email, + password: password, + ), + ), + ); + + return _mapLoginReponse(loginResponseDto); + } + + @override + Future logout() async { + await _apiService.authenticationApi.logout().timeout(Duration(seconds: 7)); + } + + _mapLoginReponse(LoginResponseDto dto) { + return LoginResponse( + accessToken: dto.accessToken, + isAdmin: dto.isAdmin, + name: dto.name, + profileImagePath: dto.profileImagePath, + shouldChangePassword: dto.shouldChangePassword, + userEmail: dto.userEmail, + userId: dto.userId, + ); + } +} diff --git a/mobile/lib/repositories/database.repository.dart b/mobile/lib/repositories/database.repository.dart index f9ee1426bb..3eb74621fa 100644 --- a/mobile/lib/repositories/database.repository.dart +++ b/mobile/lib/repositories/database.repository.dart @@ -1,5 +1,4 @@ import 'dart:async'; - import 'package:immich_mobile/interfaces/database.interface.dart'; import 'package:isar/isar.dart'; diff --git a/mobile/lib/services/api.service.dart b/mobile/lib/services/api.service.dart index 515023d163..bd754ac214 100644 --- a/mobile/lib/services/api.service.dart +++ b/mobile/lib/services/api.service.dart @@ -2,6 +2,7 @@ import 'dart:async'; import 'dart:convert'; import 'dart:io'; +import 'package:device_info_plus/device_info_plus.dart'; import 'package:flutter/material.dart'; import 'package:immich_mobile/entities/store.entity.dart'; import 'package:immich_mobile/utils/url_helper.dart'; @@ -69,7 +70,7 @@ class ApiService implements Authentication { final endpoint = await _resolveEndpoint(serverUrl); setEndpoint(endpoint); - // Save in hivebox for next startup + // Save in local database for next startup Store.put(StoreKey.serverEndpoint, endpoint); return endpoint; } @@ -148,11 +149,27 @@ class ApiService implements Authentication { return ""; } - setAccessToken(String accessToken) { + void setAccessToken(String accessToken) { _accessToken = accessToken; Store.put(StoreKey.accessToken, accessToken); } + Future setDeviceInfoHeader() async { + DeviceInfoPlugin deviceInfoPlugin = DeviceInfoPlugin(); + + if (Platform.isIOS) { + final iosInfo = await deviceInfoPlugin.iosInfo; + authenticationApi.apiClient + .addDefaultHeader('deviceModel', iosInfo.utsname.machine); + authenticationApi.apiClient.addDefaultHeader('deviceType', 'iOS'); + } else { + final androidInfo = await deviceInfoPlugin.androidInfo; + authenticationApi.apiClient + .addDefaultHeader('deviceModel', androidInfo.model); + authenticationApi.apiClient.addDefaultHeader('deviceType', 'Android'); + } + } + static Map getRequestHeaders() { var accessToken = Store.get(StoreKey.accessToken, ""); var customHeadersStr = Store.get(StoreKey.customHeaders, ""); diff --git a/mobile/lib/services/auth.service.dart b/mobile/lib/services/auth.service.dart new file mode 100644 index 0000000000..e61f485987 --- /dev/null +++ b/mobile/lib/services/auth.service.dart @@ -0,0 +1,96 @@ +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:immich_mobile/entities/store.entity.dart'; +import 'package:immich_mobile/interfaces/auth.interface.dart'; +import 'package:immich_mobile/interfaces/auth_api.interface.dart'; +import 'package:immich_mobile/models/auth/login_response.model.dart'; +import 'package:immich_mobile/providers/api.provider.dart'; +import 'package:immich_mobile/repositories/auth.repository.dart'; +import 'package:immich_mobile/repositories/auth_api.repository.dart'; +import 'package:immich_mobile/services/api.service.dart'; +import 'package:logging/logging.dart'; + +final authServiceProvider = Provider( + (ref) => AuthService( + ref.watch(authApiRepositoryProvider), + ref.watch(authRepositoryProvider), + ref.watch(apiServiceProvider), + ), +); + +class AuthService { + final IAuthApiRepository _authApiRepository; + final IAuthRepository _authRepository; + final ApiService _apiService; + + final _log = Logger("AuthService"); + + AuthService( + this._authApiRepository, + this._authRepository, + this._apiService, + ); + + /// Validates the provided server URL by resolving and setting the endpoint. + /// Also sets the device info header and stores the valid URL. + /// + /// [url] - The server URL to be validated. + /// + /// Returns the validated and resolved server URL as a [String]. + /// + /// Throws an exception if the URL cannot be resolved or set. + Future validateServerUrl(String url) async { + final validUrl = await _apiService.resolveAndSetEndpoint(url); + await _apiService.setDeviceInfoHeader(); + Store.put(StoreKey.serverUrl, validUrl); + + return validUrl; + } + + Future login(String email, String password) { + return _authApiRepository.login(email, password); + } + + /// Performs user logout operation by making a server request and clearing local data. + /// + /// This method attempts to log out the user through the authentication API repository. + /// If the server request fails, the error is logged but local data is still cleared. + /// The local data cleanup is guaranteed to execute regardless of the server request outcome. + /// + /// Throws any unhandled exceptions from the API request or local data clearing operations. + Future logout() async { + try { + await _authApiRepository.logout(); + } catch (error, stackTrace) { + _log.severe("Error logging out", error, stackTrace); + } finally { + await clearLocalData(); + } + } + + /// Clears all local authentication-related data. + /// + /// This method performs a concurrent deletion of: + /// - Authentication repository data + /// - Current user information + /// - Access token + /// - Asset ETag + /// + /// All deletions are executed in parallel using [Future.wait]. + Future clearLocalData() { + return Future.wait([ + _authRepository.clearLocalData(), + Store.delete(StoreKey.currentUser), + Store.delete(StoreKey.accessToken), + Store.delete(StoreKey.assetETag), + ]); + } + + Future changePassword(String newPassword) { + try { + return _authApiRepository.changePassword(newPassword); + } catch (error, stackTrace) { + _log.severe("Error changing password", error, stackTrace); + rethrow; + } + } +} diff --git a/mobile/lib/services/device.service.dart b/mobile/lib/services/device.service.dart new file mode 100644 index 0000000000..e1676d5683 --- /dev/null +++ b/mobile/lib/services/device.service.dart @@ -0,0 +1,24 @@ +import 'package:flutter_udid/flutter_udid.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:immich_mobile/entities/store.entity.dart'; + +final deviceServiceProvider = Provider((ref) => DeviceService()); + +class DeviceService { + DeviceService(); + + createDeviceId() { + return FlutterUdid.consistentUdid; + } + + /// Returns the device ID from local storage or creates a new one if not found. + /// + /// This method first attempts to retrieve the device ID from the local store using + /// [StoreKey.deviceId]. If no device ID is found (returns null), it generates a + /// new device ID by calling [createDeviceId]. + /// + /// Returns a [String] representing the device's unique identifier. + String getDeviceId() { + return Store.tryGet(StoreKey.deviceId) ?? createDeviceId(); + } +} diff --git a/mobile/lib/services/user.service.dart b/mobile/lib/services/user.service.dart index 4c2b3cbbd0..13adcc4e7a 100644 --- a/mobile/lib/services/user.service.dart +++ b/mobile/lib/services/user.service.dart @@ -35,8 +35,9 @@ class UserService { this._syncService, ); - Future> getUsers({bool self = false}) => - _userRepository.getAll(self: self); + Future> getUsers({bool self = false}) { + return _userRepository.getAll(self: self); + } Future<({String profileImagePath})?> uploadProfileImage(XFile image) async { try { diff --git a/mobile/lib/widgets/common/app_bar_dialog/app_bar_dialog.dart b/mobile/lib/widgets/common/app_bar_dialog/app_bar_dialog.dart index 471014608a..a83afc00b3 100644 --- a/mobile/lib/widgets/common/app_bar_dialog/app_bar_dialog.dart +++ b/mobile/lib/widgets/common/app_bar_dialog/app_bar_dialog.dart @@ -7,7 +7,7 @@ import 'package:immich_mobile/extensions/build_context_extensions.dart'; import 'package:immich_mobile/models/backup/backup_state.model.dart'; import 'package:immich_mobile/providers/backup/backup.provider.dart'; import 'package:immich_mobile/providers/backup/manual_upload.provider.dart'; -import 'package:immich_mobile/providers/authentication.provider.dart'; +import 'package:immich_mobile/providers/auth.provider.dart'; import 'package:immich_mobile/routing/router.dart'; import 'package:immich_mobile/providers/asset.provider.dart'; import 'package:immich_mobile/providers/user.provider.dart'; @@ -128,7 +128,7 @@ class ImmichAppBarDialog extends HookConsumerWidget { onOk: () async { isLoggingOut.value = true; await ref - .read(authenticationProvider.notifier) + .read(authProvider.notifier) .logout() .whenComplete(() => isLoggingOut.value = false); diff --git a/mobile/lib/widgets/common/app_bar_dialog/app_bar_profile_info.dart b/mobile/lib/widgets/common/app_bar_dialog/app_bar_profile_info.dart index a40dcf914e..f0006d1ada 100644 --- a/mobile/lib/widgets/common/app_bar_dialog/app_bar_profile_info.dart +++ b/mobile/lib/widgets/common/app_bar_dialog/app_bar_profile_info.dart @@ -7,8 +7,7 @@ import 'package:immich_mobile/providers/upload_profile_image.provider.dart'; import 'package:immich_mobile/entities/store.entity.dart'; import 'package:immich_mobile/providers/user.provider.dart'; import 'package:immich_mobile/widgets/common/user_circle_avatar.dart'; -import 'package:immich_mobile/models/authentication/authentication_state.model.dart'; -import 'package:immich_mobile/providers/authentication.provider.dart'; +import 'package:immich_mobile/providers/auth.provider.dart'; import 'package:immich_mobile/widgets/common/immich_loading_indicator.dart'; class AppBarProfileInfoBox extends HookConsumerWidget { @@ -18,7 +17,7 @@ class AppBarProfileInfoBox extends HookConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { - AuthenticationState authState = ref.watch(authenticationProvider); + final authState = ref.watch(authProvider); final uploadProfileImageStatus = ref.watch(uploadProfileImageProvider).status; final user = Store.tryGet(StoreKey.currentUser); @@ -63,7 +62,7 @@ class AppBarProfileInfoBox extends HookConsumerWidget { if (success) { final profileImagePath = ref.read(uploadProfileImageProvider).profileImagePath; - ref.watch(authenticationProvider.notifier).updateUserProfileImagePath( + ref.watch(authProvider.notifier).updateUserProfileImagePath( profileImagePath, ); if (user != null) { diff --git a/mobile/lib/widgets/forms/change_password_form.dart b/mobile/lib/widgets/forms/change_password_form.dart index 98ce66d2d1..fbb8fd927b 100644 --- a/mobile/lib/widgets/forms/change_password_form.dart +++ b/mobile/lib/widgets/forms/change_password_form.dart @@ -7,7 +7,7 @@ import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:immich_mobile/extensions/build_context_extensions.dart'; import 'package:immich_mobile/providers/backup/backup.provider.dart'; import 'package:immich_mobile/providers/backup/manual_upload.provider.dart'; -import 'package:immich_mobile/providers/authentication.provider.dart'; +import 'package:immich_mobile/providers/auth.provider.dart'; import 'package:immich_mobile/providers/asset.provider.dart'; import 'package:immich_mobile/providers/websocket.provider.dart'; import 'package:immich_mobile/widgets/common/immich_toast.dart'; @@ -21,7 +21,7 @@ class ChangePasswordForm extends HookConsumerWidget { useTextEditingController.fromValue(TextEditingValue.empty); final confirmPasswordController = useTextEditingController.fromValue(TextEditingValue.empty); - final authState = ref.watch(authenticationProvider); + final authState = ref.watch(authProvider); final formKey = GlobalKey(); return Center( @@ -73,13 +73,11 @@ class ChangePasswordForm extends HookConsumerWidget { onPressed: () async { if (formKey.currentState!.validate()) { var isSuccess = await ref - .read(authenticationProvider.notifier) + .read(authProvider.notifier) .changePassword(passwordController.value.text); if (isSuccess) { - await ref - .read(authenticationProvider.notifier) - .logout(); + await ref.read(authProvider.notifier).logout(); ref .read(manualUploadProvider.notifier) diff --git a/mobile/lib/widgets/forms/login/login_form.dart b/mobile/lib/widgets/forms/login/login_form.dart index 51383fe195..30b6a74bb1 100644 --- a/mobile/lib/widgets/forms/login/login_form.dart +++ b/mobile/lib/widgets/forms/login/login_form.dart @@ -11,9 +11,7 @@ import 'package:immich_mobile/providers/oauth.provider.dart'; import 'package:immich_mobile/providers/gallery_permission.provider.dart'; import 'package:immich_mobile/routing/router.dart'; import 'package:immich_mobile/entities/store.entity.dart'; -import 'package:immich_mobile/providers/api.provider.dart'; -import 'package:immich_mobile/providers/asset.provider.dart'; -import 'package:immich_mobile/providers/authentication.provider.dart'; +import 'package:immich_mobile/providers/auth.provider.dart'; import 'package:immich_mobile/providers/backup/backup.provider.dart'; import 'package:immich_mobile/providers/server_info.provider.dart'; import 'package:immich_mobile/utils/provider_utils.dart'; @@ -40,13 +38,12 @@ class LoginForm extends HookConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { - final usernameController = + final emailController = useTextEditingController.fromValue(TextEditingValue.empty); final passwordController = useTextEditingController.fromValue(TextEditingValue.empty); final serverEndpointController = useTextEditingController.fromValue(TextEditingValue.empty); - final apiService = ref.watch(apiServiceProvider); final emailFocusNode = useFocusNode(); final passwordFocusNode = useFocusNode(); final serverEndpointFocusNode = useFocusNode(); @@ -85,7 +82,7 @@ class LoginForm extends HookConsumerWidget { /// Fetch the server login credential and enables oAuth login if necessary /// Returns true if successful, false otherwise - Future getServerLoginCredential() async { + Future getServerAuthSettings() async { final serverUrl = sanitizeUrl(serverEndpointController.text); // Guard empty URL @@ -95,13 +92,12 @@ class LoginForm extends HookConsumerWidget { msg: "login_form_server_empty".tr(), toastType: ToastType.error, ); - - return false; } try { isLoadingServer.value = true; - final endpoint = await apiService.resolveAndSetEndpoint(serverUrl); + final endpoint = + await ref.read(authProvider.notifier).validateServerUrl(serverUrl); // Fetch and load server config and features await ref.read(serverInfoProvider.notifier).getServerInfo(); @@ -127,7 +123,6 @@ class LoginForm extends HookConsumerWidget { isOauthEnable.value = false; isPasswordLoginEnable.value = true; isLoadingServer.value = false; - return false; } on HandshakeException { ImmichToast.show( context: context, @@ -138,7 +133,6 @@ class LoginForm extends HookConsumerWidget { isOauthEnable.value = false; isPasswordLoginEnable.value = true; isLoadingServer.value = false; - return false; } catch (e) { ImmichToast.show( context: context, @@ -149,11 +143,9 @@ class LoginForm extends HookConsumerWidget { isOauthEnable.value = false; isPasswordLoginEnable.value = true; isLoadingServer.value = false; - return false; } isLoadingServer.value = false; - return true; } useEffect( @@ -168,67 +160,50 @@ class LoginForm extends HookConsumerWidget { ); populateTestLoginInfo() { - usernameController.text = 'demo@immich.app'; + emailController.text = 'demo@immich.app'; passwordController.text = 'demo'; serverEndpointController.text = 'https://demo.immich.app'; } populateTestLoginInfo1() { - usernameController.text = 'testuser@email.com'; + emailController.text = 'testuser@email.com'; passwordController.text = 'password'; - serverEndpointController.text = 'http://10.1.15.216:2283/api'; + serverEndpointController.text = 'http://10.1.15.216:3000/api'; } login() async { TextInput.finishAutofillContext(); - // Start loading - isLoading.value = true; - // This will remove current cache asset state of previous user login. - ref.read(assetProvider.notifier).clearAllAsset(); + isLoading.value = true; // Invalidate all api repository provider instance to take into account new access token invalidateAllApiRepositoryProviders(ref); try { - final isAuthenticated = - await ref.read(authenticationProvider.notifier).login( - usernameController.text, - passwordController.text, - sanitizeUrl(serverEndpointController.text), - ); - if (isAuthenticated) { - // Resume backup (if enable) then navigate - if (ref.read(authenticationProvider).shouldChangePassword && - !ref.read(authenticationProvider).isAdmin) { - context.pushRoute(const ChangePasswordRoute()); - } else { - final hasPermission = await ref - .read(galleryPermissionNotifier.notifier) - .hasPermission; - if (hasPermission) { - // Don't resume the backup until we have gallery permission - ref.read(backupProvider.notifier).resumeBackup(); - } - context.replaceRoute(const TabControllerRoute()); - } + final result = await ref.read(authProvider.notifier).login( + emailController.text, + passwordController.text, + ); + + if (result.shouldChangePassword && !result.isAdmin) { + context.pushRoute(const ChangePasswordRoute()); } else { - ImmichToast.show( - context: context, - msg: "login_form_failed_login".tr(), - toastType: ToastType.error, - gravity: ToastGravity.TOP, - ); + context.replaceRoute(const TabControllerRoute()); } + } catch (error) { + ImmichToast.show( + context: context, + msg: "login_form_failed_login".tr(), + toastType: ToastType.error, + gravity: ToastGravity.TOP, + ); } finally { - // Make sure we stop loading isLoading.value = false; } } oAuthLogin() async { var oAuthService = ref.watch(oAuthServiceProvider); - ref.watch(assetProvider.notifier).clearAllAsset(); String? oAuthServerUrl; try { @@ -262,11 +237,8 @@ class LoginForm extends HookConsumerWidget { "Finished OAuth login with response: ${loginResponseDto.userEmail}", ); - final isSuccess = await ref - .watch(authenticationProvider.notifier) - .setSuccessLoginInfo( + final isSuccess = await ref.watch(authProvider.notifier).saveAuthInfo( accessToken: loginResponseDto.accessToken, - serverUrl: sanitizeUrl(serverEndpointController.text), ); if (isSuccess) { @@ -309,7 +281,7 @@ class LoginForm extends HookConsumerWidget { ServerEndpointInput( controller: serverEndpointController, focusNode: serverEndpointFocusNode, - onSubmit: getServerLoginCredential, + onSubmit: getServerAuthSettings, ), const SizedBox(height: 18), Row( @@ -344,7 +316,7 @@ class LoginForm extends HookConsumerWidget { ), ), onPressed: - isLoadingServer.value ? null : getServerLoginCredential, + isLoadingServer.value ? null : getServerAuthSettings, icon: const Icon(Icons.arrow_forward_rounded), label: const Text( 'login_form_next_button', @@ -402,7 +374,7 @@ class LoginForm extends HookConsumerWidget { if (isPasswordLoginEnable.value) ...[ const SizedBox(height: 18), EmailInput( - controller: usernameController, + controller: emailController, focusNode: emailFocusNode, onSubmit: passwordFocusNode.requestFocus, ), diff --git a/mobile/test/repository.mocks.dart b/mobile/test/repository.mocks.dart index c76a003eec..3dda932cac 100644 --- a/mobile/test/repository.mocks.dart +++ b/mobile/test/repository.mocks.dart @@ -3,6 +3,8 @@ import 'package:immich_mobile/interfaces/album_api.interface.dart'; import 'package:immich_mobile/interfaces/album_media.interface.dart'; import 'package:immich_mobile/interfaces/asset.interface.dart'; import 'package:immich_mobile/interfaces/asset_media.interface.dart'; +import 'package:immich_mobile/interfaces/auth.interface.dart'; +import 'package:immich_mobile/interfaces/auth_api.interface.dart'; import 'package:immich_mobile/interfaces/backup.interface.dart'; import 'package:immich_mobile/interfaces/etag.interface.dart'; import 'package:immich_mobile/interfaces/exif_info.interface.dart'; @@ -29,3 +31,7 @@ class MockAssetMediaRepository extends Mock implements IAssetMediaRepository {} class MockFileMediaRepository extends Mock implements IFileMediaRepository {} class MockAlbumApiRepository extends Mock implements IAlbumApiRepository {} + +class MockAuthApiRepository extends Mock implements IAuthApiRepository {} + +class MockAuthRepository extends Mock implements IAuthRepository {} diff --git a/mobile/test/services/auth.service_test.dart b/mobile/test/services/auth.service_test.dart new file mode 100644 index 0000000000..b864babb14 --- /dev/null +++ b/mobile/test/services/auth.service_test.dart @@ -0,0 +1,118 @@ +import 'package:flutter/widgets.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:immich_mobile/entities/store.entity.dart'; +import 'package:immich_mobile/services/auth.service.dart'; +import 'package:mocktail/mocktail.dart'; +import '../repository.mocks.dart'; +import '../service.mocks.dart'; +import '../test_utils.dart'; + +void main() { + late AuthService sut; + late MockAuthApiRepository authApiRepository; + late MockAuthRepository authRepository; + late MockApiService apiService; + + setUp(() async { + authApiRepository = MockAuthApiRepository(); + authRepository = MockAuthRepository(); + apiService = MockApiService(); + sut = AuthService(authApiRepository, authRepository, apiService); + }); + + group('validateServerUrl', () { + setUpAll(() async { + WidgetsFlutterBinding.ensureInitialized(); + final db = await TestUtils.initIsar(); + db.writeTxnSync(() => db.clearSync()); + Store.init(db); + }); + + test('Should resolve HTTP endpoint', () async { + const testUrl = 'http://ip:2283'; + const resolvedUrl = 'http://ip:2283/api'; + + when(() => apiService.resolveAndSetEndpoint(testUrl)) + .thenAnswer((_) async => resolvedUrl); + when(() => apiService.setDeviceInfoHeader()).thenAnswer((_) async => {}); + + final result = await sut.validateServerUrl(testUrl); + + expect(result, resolvedUrl); + + verify(() => apiService.resolveAndSetEndpoint(testUrl)).called(1); + verify(() => apiService.setDeviceInfoHeader()).called(1); + }); + + test('Should resolve HTTPS endpoint', () async { + const testUrl = 'https://immich.domain.com'; + const resolvedUrl = 'https://immich.domain.com/api'; + + when(() => apiService.resolveAndSetEndpoint(testUrl)) + .thenAnswer((_) async => resolvedUrl); + when(() => apiService.setDeviceInfoHeader()).thenAnswer((_) async => {}); + + final result = await sut.validateServerUrl(testUrl); + + expect(result, resolvedUrl); + + verify(() => apiService.resolveAndSetEndpoint(testUrl)).called(1); + verify(() => apiService.setDeviceInfoHeader()).called(1); + }); + + test('Should throw error on invalid URL', () async { + const testUrl = 'invalid-url'; + + when(() => apiService.resolveAndSetEndpoint(testUrl)) + .thenThrow(Exception('Invalid URL')); + + expect( + () async => await sut.validateServerUrl(testUrl), + throwsA(isA()), + ); + + verify(() => apiService.resolveAndSetEndpoint(testUrl)).called(1); + verifyNever(() => apiService.setDeviceInfoHeader()); + }); + + test('Should throw error on unreachable server', () async { + const testUrl = 'https://unreachable.server'; + + when(() => apiService.resolveAndSetEndpoint(testUrl)) + .thenThrow(Exception('Server is not reachable')); + + expect( + () async => await sut.validateServerUrl(testUrl), + throwsA(isA()), + ); + + verify(() => apiService.resolveAndSetEndpoint(testUrl)).called(1); + verifyNever(() => apiService.setDeviceInfoHeader()); + }); + }); + + group('logout', () { + test('Should logout user', () async { + when(() => authApiRepository.logout()).thenAnswer((_) async => {}); + when(() => authRepository.clearLocalData()) + .thenAnswer((_) => Future.value(null)); + + await sut.logout(); + + verify(() => authApiRepository.logout()).called(1); + verify(() => authRepository.clearLocalData()).called(1); + }); + + test('Should clear local data even on server error', () async { + when(() => authApiRepository.logout()) + .thenThrow(Exception('Server error')); + when(() => authRepository.clearLocalData()) + .thenAnswer((_) => Future.value(null)); + + await sut.logout(); + + verify(() => authApiRepository.logout()).called(1); + verify(() => authRepository.clearLocalData()).called(1); + }); + }); +}