1
0
Fork 0
mirror of https://github.com/immich-app/immich.git synced 2024-12-28 22:51:59 +00:00

chore(mobile): refactor authentication (#14322)

This commit is contained in:
Alex 2024-11-26 12:43:44 -06:00 committed by GitHub
parent 5417e34fb6
commit 21f14be949
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
26 changed files with 619 additions and 354 deletions

View file

@ -93,7 +93,7 @@ custom_lint:
- lib/models/server_info/server_{config,disk_info,features,version}.model.dart - lib/models/server_info/server_{config,disk_info,features,version}.model.dart
- lib/models/shared_link/shared_link.model.dart - lib/models/shared_link/shared_link.model.dart
- lib/providers/asset_viewer/asset_people.provider.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/image/immich_remote_{image,thumbnail}_provider.dart
- lib/providers/map/map_state.provider.dart - lib/providers/map/map_state.provider.dart
- lib/providers/search/{search,search_filter}.provider.dart - lib/providers/search/{search,search_filter}.provider.dart

View file

@ -0,0 +1,5 @@
import 'package:immich_mobile/interfaces/database.interface.dart';
abstract interface class IAuthRepository implements IDatabaseRepository {
Future<void> clearLocalData();
}

View file

@ -0,0 +1,9 @@
import 'package:immich_mobile/models/auth/login_response.model.dart';
abstract interface class IAuthApiRepository {
Future<LoginResponse> login(String email, String password);
Future<void> logout();
Future<void> changePassword(String newPassword);
}

View file

@ -1,62 +1,58 @@
class AuthenticationState { class AuthState {
final String deviceId; final String deviceId;
final String userId; final String userId;
final String userEmail; final String userEmail;
final bool isAuthenticated; final bool isAuthenticated;
final String name; final String name;
final bool isAdmin; final bool isAdmin;
final bool shouldChangePassword;
final String profileImagePath; final String profileImagePath;
AuthenticationState({
AuthState({
required this.deviceId, required this.deviceId,
required this.userId, required this.userId,
required this.userEmail, required this.userEmail,
required this.isAuthenticated, required this.isAuthenticated,
required this.name, required this.name,
required this.isAdmin, required this.isAdmin,
required this.shouldChangePassword,
required this.profileImagePath, required this.profileImagePath,
}); });
AuthenticationState copyWith({ AuthState copyWith({
String? deviceId, String? deviceId,
String? userId, String? userId,
String? userEmail, String? userEmail,
bool? isAuthenticated, bool? isAuthenticated,
String? name, String? name,
bool? isAdmin, bool? isAdmin,
bool? shouldChangePassword,
String? profileImagePath, String? profileImagePath,
}) { }) {
return AuthenticationState( return AuthState(
deviceId: deviceId ?? this.deviceId, deviceId: deviceId ?? this.deviceId,
userId: userId ?? this.userId, userId: userId ?? this.userId,
userEmail: userEmail ?? this.userEmail, userEmail: userEmail ?? this.userEmail,
isAuthenticated: isAuthenticated ?? this.isAuthenticated, isAuthenticated: isAuthenticated ?? this.isAuthenticated,
name: name ?? this.name, name: name ?? this.name,
isAdmin: isAdmin ?? this.isAdmin, isAdmin: isAdmin ?? this.isAdmin,
shouldChangePassword: shouldChangePassword ?? this.shouldChangePassword,
profileImagePath: profileImagePath ?? this.profileImagePath, profileImagePath: profileImagePath ?? this.profileImagePath,
); );
} }
@override @override
String toString() { 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 @override
bool operator ==(Object other) { bool operator ==(Object other) {
if (identical(this, other)) return true; if (identical(this, other)) return true;
return other is AuthenticationState && return other is AuthState &&
other.deviceId == deviceId && other.deviceId == deviceId &&
other.userId == userId && other.userId == userId &&
other.userEmail == userEmail && other.userEmail == userEmail &&
other.isAuthenticated == isAuthenticated && other.isAuthenticated == isAuthenticated &&
other.name == name && other.name == name &&
other.isAdmin == isAdmin && other.isAdmin == isAdmin &&
other.shouldChangePassword == shouldChangePassword &&
other.profileImagePath == profileImagePath; other.profileImagePath == profileImagePath;
} }
@ -68,7 +64,6 @@ class AuthenticationState {
isAuthenticated.hashCode ^ isAuthenticated.hashCode ^
name.hashCode ^ name.hashCode ^
isAdmin.hashCode ^ isAdmin.hashCode ^
shouldChangePassword.hashCode ^
profileImagePath.hashCode; profileImagePath.hashCode;
} }
} }

View file

@ -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]';
}
}

View file

@ -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/build_context_extensions.dart';
import 'package:immich_mobile/extensions/theme_extensions.dart'; import 'package:immich_mobile/extensions/theme_extensions.dart';
import 'package:immich_mobile/providers/album/album.provider.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/utils/immich_loading_overlay.dart';
import 'package:immich_mobile/routing/router.dart'; import 'package:immich_mobile/routing/router.dart';
import 'package:immich_mobile/entities/album.entity.dart'; import 'package:immich_mobile/entities/album.entity.dart';
@ -25,7 +25,7 @@ class AlbumOptionsPage extends HookConsumerWidget {
Widget build(BuildContext context, WidgetRef ref) { Widget build(BuildContext context, WidgetRef ref) {
final sharedUsers = useState(album.sharedUsers.toList()); final sharedUsers = useState(album.sharedUsers.toList());
final owner = album.owner.value; final owner = album.owner.value;
final userId = ref.watch(authenticationProvider).userId; final userId = ref.watch(authProvider).userId;
final activityEnabled = useState(album.activityEnabled); final activityEnabled = useState(album.activityEnabled);
final isProcessing = useProcessingOverlay(); final isProcessing = useProcessingOverlay();
final isOwner = owner?.id == userId; final isOwner = owner?.id == userId;

View file

@ -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_action_filled_button.dart';
import 'package:immich_mobile/widgets/album/album_viewer_editable_title.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/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/widgets/album/album_viewer_appbar.dart';
import 'package:immich_mobile/routing/router.dart'; import 'package:immich_mobile/routing/router.dart';
import 'package:immich_mobile/entities/album.entity.dart'; import 'package:immich_mobile/entities/album.entity.dart';
@ -42,7 +42,7 @@ class AlbumViewerPage extends HookConsumerWidget {
() => ref.read(currentAlbumProvider.notifier).set(value), () => ref.read(currentAlbumProvider.notifier).set(value),
), ),
); );
final userId = ref.watch(authenticationProvider).userId; final userId = ref.watch(authProvider).userId;
final isProcessing = useProcessingOverlay(); final isProcessing = useProcessingOverlay();
Future<bool> onRemoveFromAlbumPressed(Iterable<Asset> assets) async { Future<bool> onRemoveFromAlbumPressed(Iterable<Asset> assets) async {

View file

@ -3,11 +3,10 @@ import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart' hide Store; import 'package:flutter_hooks/flutter_hooks.dart' hide Store;
import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/providers/backup/backup.provider.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/providers/gallery_permission.provider.dart';
import 'package:immich_mobile/routing/router.dart'; import 'package:immich_mobile/routing/router.dart';
import 'package:immich_mobile/entities/store.entity.dart'; import 'package:immich_mobile/entities/store.entity.dart';
import 'package:immich_mobile/providers/api.provider.dart';
import 'package:logging/logging.dart'; import 'package:logging/logging.dart';
@RoutePage() @RoutePage()
@ -16,7 +15,6 @@ class SplashScreenPage extends HookConsumerWidget {
@override @override
Widget build(BuildContext context, WidgetRef ref) { Widget build(BuildContext context, WidgetRef ref) {
final apiService = ref.watch(apiServiceProvider);
final serverUrl = Store.tryGet(StoreKey.serverUrl); final serverUrl = Store.tryGet(StoreKey.serverUrl);
final endpoint = Store.tryGet(StoreKey.serverEndpoint); final endpoint = Store.tryGet(StoreKey.serverEndpoint);
final accessToken = Store.tryGet(StoreKey.accessToken); final accessToken = Store.tryGet(StoreKey.accessToken);
@ -26,14 +24,9 @@ class SplashScreenPage extends HookConsumerWidget {
bool isAuthSuccess = false; bool isAuthSuccess = false;
if (accessToken != null && serverUrl != null && endpoint != null) { if (accessToken != null && serverUrl != null && endpoint != null) {
apiService.setEndpoint(endpoint);
try { try {
isAuthSuccess = await ref isAuthSuccess = await ref.read(authProvider.notifier).saveAuthInfo(
.read(authenticationProvider.notifier)
.setSuccessLoginInfo(
accessToken: accessToken, accessToken: accessToken,
serverUrl: serverUrl,
); );
} catch (error, stackTrace) { } catch (error, stackTrace) {
log.severe( log.severe(
@ -53,7 +46,7 @@ class SplashScreenPage extends HookConsumerWidget {
log.severe( log.severe(
'Unable to login using offline or online methods - Logging out completely', '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()); context.replaceRoute(const LoginRoute());
return; return;
} }

View file

@ -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/backup.provider.dart';
import 'package:immich_mobile/providers/backup/ios_background_settings.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/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/memory.provider.dart';
import 'package:immich_mobile/providers/gallery_permission.provider.dart'; import 'package:immich_mobile/providers/gallery_permission.provider.dart';
import 'package:immich_mobile/providers/notification_permission.provider.dart'; import 'package:immich_mobile/providers/notification_permission.provider.dart';
@ -42,7 +42,7 @@ class AppLifeCycleNotifier extends StateNotifier<AppLifeCycleEnum> {
if (!_wasPaused) return; if (!_wasPaused) return;
_wasPaused = false; _wasPaused = false;
final isAuthenticated = _ref.read(authenticationProvider).isAuthenticated; final isAuthenticated = _ref.read(authProvider).isAuthenticated;
// Needs to be logged in // Needs to be logged in
if (isAuthenticated) { if (isAuthenticated) {
@ -85,7 +85,7 @@ class AppLifeCycleNotifier extends StateNotifier<AppLifeCycleEnum> {
state = AppLifeCycleEnum.paused; state = AppLifeCycleEnum.paused;
_wasPaused = true; _wasPaused = true;
if (_ref.read(authenticationProvider).isAuthenticated) { if (_ref.read(authProvider).isAuthenticated) {
// Do not cancel backup if manual upload is in progress // Do not cancel backup if manual upload is in progress
if (_ref.read(backupProvider.notifier).backupProgress != if (_ref.read(backupProvider.notifier).backupProgress !=
BackUpProgressEnum.manualInProgress) { BackUpProgressEnum.manualInProgress) {

View file

@ -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<AuthNotifier, AuthState>((ref) {
return AuthNotifier(
ref.watch(authServiceProvider),
ref.watch(apiServiceProvider),
);
});
class AuthNotifier extends StateNotifier<AuthState> {
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<String> validateServerUrl(String url) {
return _authService.validateServerUrl(url);
}
Future<LoginResponse> login(String email, String password) async {
final response = await _authService.login(email, password);
await saveAuthInfo(accessToken: response.accessToken);
return response;
}
Future<void> logout() async {
try {
await _authService.logout();
} finally {
await _cleanUp();
}
}
Future<void> _cleanUp() async {
state = AuthState(
deviceId: "",
userId: "",
userEmail: "",
name: '',
profileImagePath: '',
isAdmin: false,
isAuthenticated: false,
);
}
void updateUserProfileImagePath(String path) {
state = state.copyWith(profileImagePath: path);
}
Future<bool> changePassword(String newPassword) async {
try {
await _authService.changePassword(newPassword);
return true;
} catch (_) {
return false;
}
}
Future<bool> 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;
}
}

View file

@ -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<AuthenticationState> {
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<AuthenticationNotifier, AuthenticationState>
_ref;
final _log = Logger("AuthenticationNotifier");
static const Duration _timeoutDuration = Duration(seconds: 7);
Future<bool> 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<void> 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<bool> 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<bool> 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<AuthenticationNotifier, AuthenticationState>((ref) {
return AuthenticationNotifier(
ref.watch(apiServiceProvider),
ref.watch(dbProvider),
ref,
);
});

View file

@ -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/repositories/file_media.repository.dart';
import 'package:immich_mobile/services/background.service.dart'; import 'package:immich_mobile/services/background.service.dart';
import 'package:immich_mobile/services/backup.service.dart'; import 'package:immich_mobile/services/backup.service.dart';
import 'package:immich_mobile/models/authentication/authentication_state.model.dart'; import 'package:immich_mobile/models/auth/auth_state.model.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/providers/gallery_permission.provider.dart';
import 'package:immich_mobile/models/server_info/server_disk_info.model.dart'; import 'package:immich_mobile/models/server_info/server_disk_info.model.dart';
import 'package:immich_mobile/entities/store.entity.dart'; import 'package:immich_mobile/entities/store.entity.dart';
@ -92,7 +92,7 @@ class BackupNotifier extends StateNotifier<BackUpState> {
final log = Logger('BackupNotifier'); final log = Logger('BackupNotifier');
final BackupService _backupService; final BackupService _backupService;
final ServerInfoService _serverInfoService; final ServerInfoService _serverInfoService;
final AuthenticationState _authState; final AuthState _authState;
final BackgroundService _backgroundService; final BackgroundService _backgroundService;
final GalleryPermissionNotifier _galleryPermissionNotifier; final GalleryPermissionNotifier _galleryPermissionNotifier;
final Isar _db; final Isar _db;
@ -765,7 +765,7 @@ final backupProvider =
return BackupNotifier( return BackupNotifier(
ref.watch(backupServiceProvider), ref.watch(backupServiceProvider),
ref.watch(serverInfoServiceProvider), ref.watch(serverInfoServiceProvider),
ref.watch(authenticationProvider), ref.watch(authProvider),
ref.watch(backgroundServiceProvider), ref.watch(backgroundServiceProvider),
ref.watch(galleryPermissionNotifier.notifier), ref.watch(galleryPermissionNotifier.notifier),
ref.watch(dbProvider), ref.watch(dbProvider),

View file

@ -4,7 +4,7 @@ import 'package:collection/collection.dart';
import 'package:flutter/foundation.dart'; import 'package:flutter/foundation.dart';
import 'package:flutter/widgets.dart'; import 'package:flutter/widgets.dart';
import 'package:hooks_riverpod/hooks_riverpod.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/entities/asset.entity.dart';
import 'package:immich_mobile/models/server_info/server_version.model.dart'; import 'package:immich_mobile/models/server_info/server_version.model.dart';
import 'package:immich_mobile/entities/store.entity.dart'; import 'package:immich_mobile/entities/store.entity.dart';
@ -103,7 +103,7 @@ class WebsocketNotifier extends StateNotifier<WebsocketState> {
/// Connects websocket to server unless already connected /// Connects websocket to server unless already connected
void connect() { void connect() {
if (state.isConnected) return; if (state.isConnected) return;
final authenticationState = _ref.read(authenticationProvider); final authenticationState = _ref.read(authProvider);
if (authenticationState.isAuthenticated) { if (authenticationState.isAuthenticated) {
try { try {

View file

@ -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<IAuthRepository>(
(ref) => AuthRepository(ref.watch(dbProvider)),
);
class AuthRepository extends DatabaseRepository implements IAuthRepository {
AuthRepository(super.db);
@override
Future<void> 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();
});
}
}

View file

@ -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<void> changePassword(String newPassword) async {
await _apiService.usersApi.updateMyUser(
UserUpdateMeDto(
password: newPassword,
),
);
}
@override
Future<LoginResponse> login(String email, String password) async {
final loginResponseDto = await checkNull(
_apiService.authenticationApi.login(
LoginCredentialDto(
email: email,
password: password,
),
),
);
return _mapLoginReponse(loginResponseDto);
}
@override
Future<void> 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,
);
}
}

View file

@ -1,5 +1,4 @@
import 'dart:async'; import 'dart:async';
import 'package:immich_mobile/interfaces/database.interface.dart'; import 'package:immich_mobile/interfaces/database.interface.dart';
import 'package:isar/isar.dart'; import 'package:isar/isar.dart';

View file

@ -2,6 +2,7 @@ import 'dart:async';
import 'dart:convert'; import 'dart:convert';
import 'dart:io'; import 'dart:io';
import 'package:device_info_plus/device_info_plus.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:immich_mobile/entities/store.entity.dart'; import 'package:immich_mobile/entities/store.entity.dart';
import 'package:immich_mobile/utils/url_helper.dart'; import 'package:immich_mobile/utils/url_helper.dart';
@ -69,7 +70,7 @@ class ApiService implements Authentication {
final endpoint = await _resolveEndpoint(serverUrl); final endpoint = await _resolveEndpoint(serverUrl);
setEndpoint(endpoint); setEndpoint(endpoint);
// Save in hivebox for next startup // Save in local database for next startup
Store.put(StoreKey.serverEndpoint, endpoint); Store.put(StoreKey.serverEndpoint, endpoint);
return endpoint; return endpoint;
} }
@ -148,11 +149,27 @@ class ApiService implements Authentication {
return ""; return "";
} }
setAccessToken(String accessToken) { void setAccessToken(String accessToken) {
_accessToken = accessToken; _accessToken = accessToken;
Store.put(StoreKey.accessToken, accessToken); Store.put(StoreKey.accessToken, accessToken);
} }
Future<void> 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<String, String> getRequestHeaders() { static Map<String, String> getRequestHeaders() {
var accessToken = Store.get(StoreKey.accessToken, ""); var accessToken = Store.get(StoreKey.accessToken, "");
var customHeadersStr = Store.get(StoreKey.customHeaders, ""); var customHeadersStr = Store.get(StoreKey.customHeaders, "");

View file

@ -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<String> validateServerUrl(String url) async {
final validUrl = await _apiService.resolveAndSetEndpoint(url);
await _apiService.setDeviceInfoHeader();
Store.put(StoreKey.serverUrl, validUrl);
return validUrl;
}
Future<LoginResponse> 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<void> 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<void> clearLocalData() {
return Future.wait([
_authRepository.clearLocalData(),
Store.delete(StoreKey.currentUser),
Store.delete(StoreKey.accessToken),
Store.delete(StoreKey.assetETag),
]);
}
Future<void> changePassword(String newPassword) {
try {
return _authApiRepository.changePassword(newPassword);
} catch (error, stackTrace) {
_log.severe("Error changing password", error, stackTrace);
rethrow;
}
}
}

View file

@ -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();
}
}

View file

@ -35,8 +35,9 @@ class UserService {
this._syncService, this._syncService,
); );
Future<List<User>> getUsers({bool self = false}) => Future<List<User>> getUsers({bool self = false}) {
_userRepository.getAll(self: self); return _userRepository.getAll(self: self);
}
Future<({String profileImagePath})?> uploadProfileImage(XFile image) async { Future<({String profileImagePath})?> uploadProfileImage(XFile image) async {
try { try {

View file

@ -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/models/backup/backup_state.model.dart';
import 'package:immich_mobile/providers/backup/backup.provider.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/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/routing/router.dart';
import 'package:immich_mobile/providers/asset.provider.dart'; import 'package:immich_mobile/providers/asset.provider.dart';
import 'package:immich_mobile/providers/user.provider.dart'; import 'package:immich_mobile/providers/user.provider.dart';
@ -128,7 +128,7 @@ class ImmichAppBarDialog extends HookConsumerWidget {
onOk: () async { onOk: () async {
isLoggingOut.value = true; isLoggingOut.value = true;
await ref await ref
.read(authenticationProvider.notifier) .read(authProvider.notifier)
.logout() .logout()
.whenComplete(() => isLoggingOut.value = false); .whenComplete(() => isLoggingOut.value = false);

View file

@ -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/entities/store.entity.dart';
import 'package:immich_mobile/providers/user.provider.dart'; import 'package:immich_mobile/providers/user.provider.dart';
import 'package:immich_mobile/widgets/common/user_circle_avatar.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/auth.provider.dart';
import 'package:immich_mobile/providers/authentication.provider.dart';
import 'package:immich_mobile/widgets/common/immich_loading_indicator.dart'; import 'package:immich_mobile/widgets/common/immich_loading_indicator.dart';
class AppBarProfileInfoBox extends HookConsumerWidget { class AppBarProfileInfoBox extends HookConsumerWidget {
@ -18,7 +17,7 @@ class AppBarProfileInfoBox extends HookConsumerWidget {
@override @override
Widget build(BuildContext context, WidgetRef ref) { Widget build(BuildContext context, WidgetRef ref) {
AuthenticationState authState = ref.watch(authenticationProvider); final authState = ref.watch(authProvider);
final uploadProfileImageStatus = final uploadProfileImageStatus =
ref.watch(uploadProfileImageProvider).status; ref.watch(uploadProfileImageProvider).status;
final user = Store.tryGet(StoreKey.currentUser); final user = Store.tryGet(StoreKey.currentUser);
@ -63,7 +62,7 @@ class AppBarProfileInfoBox extends HookConsumerWidget {
if (success) { if (success) {
final profileImagePath = final profileImagePath =
ref.read(uploadProfileImageProvider).profileImagePath; ref.read(uploadProfileImageProvider).profileImagePath;
ref.watch(authenticationProvider.notifier).updateUserProfileImagePath( ref.watch(authProvider.notifier).updateUserProfileImagePath(
profileImagePath, profileImagePath,
); );
if (user != null) { if (user != null) {

View file

@ -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/build_context_extensions.dart';
import 'package:immich_mobile/providers/backup/backup.provider.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/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/asset.provider.dart';
import 'package:immich_mobile/providers/websocket.provider.dart'; import 'package:immich_mobile/providers/websocket.provider.dart';
import 'package:immich_mobile/widgets/common/immich_toast.dart'; import 'package:immich_mobile/widgets/common/immich_toast.dart';
@ -21,7 +21,7 @@ class ChangePasswordForm extends HookConsumerWidget {
useTextEditingController.fromValue(TextEditingValue.empty); useTextEditingController.fromValue(TextEditingValue.empty);
final confirmPasswordController = final confirmPasswordController =
useTextEditingController.fromValue(TextEditingValue.empty); useTextEditingController.fromValue(TextEditingValue.empty);
final authState = ref.watch(authenticationProvider); final authState = ref.watch(authProvider);
final formKey = GlobalKey<FormState>(); final formKey = GlobalKey<FormState>();
return Center( return Center(
@ -73,13 +73,11 @@ class ChangePasswordForm extends HookConsumerWidget {
onPressed: () async { onPressed: () async {
if (formKey.currentState!.validate()) { if (formKey.currentState!.validate()) {
var isSuccess = await ref var isSuccess = await ref
.read(authenticationProvider.notifier) .read(authProvider.notifier)
.changePassword(passwordController.value.text); .changePassword(passwordController.value.text);
if (isSuccess) { if (isSuccess) {
await ref await ref.read(authProvider.notifier).logout();
.read(authenticationProvider.notifier)
.logout();
ref ref
.read(manualUploadProvider.notifier) .read(manualUploadProvider.notifier)

View file

@ -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/providers/gallery_permission.provider.dart';
import 'package:immich_mobile/routing/router.dart'; import 'package:immich_mobile/routing/router.dart';
import 'package:immich_mobile/entities/store.entity.dart'; import 'package:immich_mobile/entities/store.entity.dart';
import 'package:immich_mobile/providers/api.provider.dart'; import 'package:immich_mobile/providers/auth.provider.dart';
import 'package:immich_mobile/providers/asset.provider.dart';
import 'package:immich_mobile/providers/authentication.provider.dart';
import 'package:immich_mobile/providers/backup/backup.provider.dart'; import 'package:immich_mobile/providers/backup/backup.provider.dart';
import 'package:immich_mobile/providers/server_info.provider.dart'; import 'package:immich_mobile/providers/server_info.provider.dart';
import 'package:immich_mobile/utils/provider_utils.dart'; import 'package:immich_mobile/utils/provider_utils.dart';
@ -40,13 +38,12 @@ class LoginForm extends HookConsumerWidget {
@override @override
Widget build(BuildContext context, WidgetRef ref) { Widget build(BuildContext context, WidgetRef ref) {
final usernameController = final emailController =
useTextEditingController.fromValue(TextEditingValue.empty); useTextEditingController.fromValue(TextEditingValue.empty);
final passwordController = final passwordController =
useTextEditingController.fromValue(TextEditingValue.empty); useTextEditingController.fromValue(TextEditingValue.empty);
final serverEndpointController = final serverEndpointController =
useTextEditingController.fromValue(TextEditingValue.empty); useTextEditingController.fromValue(TextEditingValue.empty);
final apiService = ref.watch(apiServiceProvider);
final emailFocusNode = useFocusNode(); final emailFocusNode = useFocusNode();
final passwordFocusNode = useFocusNode(); final passwordFocusNode = useFocusNode();
final serverEndpointFocusNode = useFocusNode(); final serverEndpointFocusNode = useFocusNode();
@ -85,7 +82,7 @@ class LoginForm extends HookConsumerWidget {
/// Fetch the server login credential and enables oAuth login if necessary /// Fetch the server login credential and enables oAuth login if necessary
/// Returns true if successful, false otherwise /// Returns true if successful, false otherwise
Future<bool> getServerLoginCredential() async { Future<void> getServerAuthSettings() async {
final serverUrl = sanitizeUrl(serverEndpointController.text); final serverUrl = sanitizeUrl(serverEndpointController.text);
// Guard empty URL // Guard empty URL
@ -95,13 +92,12 @@ class LoginForm extends HookConsumerWidget {
msg: "login_form_server_empty".tr(), msg: "login_form_server_empty".tr(),
toastType: ToastType.error, toastType: ToastType.error,
); );
return false;
} }
try { try {
isLoadingServer.value = true; 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 // Fetch and load server config and features
await ref.read(serverInfoProvider.notifier).getServerInfo(); await ref.read(serverInfoProvider.notifier).getServerInfo();
@ -127,7 +123,6 @@ class LoginForm extends HookConsumerWidget {
isOauthEnable.value = false; isOauthEnable.value = false;
isPasswordLoginEnable.value = true; isPasswordLoginEnable.value = true;
isLoadingServer.value = false; isLoadingServer.value = false;
return false;
} on HandshakeException { } on HandshakeException {
ImmichToast.show( ImmichToast.show(
context: context, context: context,
@ -138,7 +133,6 @@ class LoginForm extends HookConsumerWidget {
isOauthEnable.value = false; isOauthEnable.value = false;
isPasswordLoginEnable.value = true; isPasswordLoginEnable.value = true;
isLoadingServer.value = false; isLoadingServer.value = false;
return false;
} catch (e) { } catch (e) {
ImmichToast.show( ImmichToast.show(
context: context, context: context,
@ -149,11 +143,9 @@ class LoginForm extends HookConsumerWidget {
isOauthEnable.value = false; isOauthEnable.value = false;
isPasswordLoginEnable.value = true; isPasswordLoginEnable.value = true;
isLoadingServer.value = false; isLoadingServer.value = false;
return false;
} }
isLoadingServer.value = false; isLoadingServer.value = false;
return true;
} }
useEffect( useEffect(
@ -168,67 +160,50 @@ class LoginForm extends HookConsumerWidget {
); );
populateTestLoginInfo() { populateTestLoginInfo() {
usernameController.text = 'demo@immich.app'; emailController.text = 'demo@immich.app';
passwordController.text = 'demo'; passwordController.text = 'demo';
serverEndpointController.text = 'https://demo.immich.app'; serverEndpointController.text = 'https://demo.immich.app';
} }
populateTestLoginInfo1() { populateTestLoginInfo1() {
usernameController.text = 'testuser@email.com'; emailController.text = 'testuser@email.com';
passwordController.text = 'password'; passwordController.text = 'password';
serverEndpointController.text = 'http://10.1.15.216:2283/api'; serverEndpointController.text = 'http://10.1.15.216:3000/api';
} }
login() async { login() async {
TextInput.finishAutofillContext(); TextInput.finishAutofillContext();
// Start loading
isLoading.value = true;
// This will remove current cache asset state of previous user login. isLoading.value = true;
ref.read(assetProvider.notifier).clearAllAsset();
// Invalidate all api repository provider instance to take into account new access token // Invalidate all api repository provider instance to take into account new access token
invalidateAllApiRepositoryProviders(ref); invalidateAllApiRepositoryProviders(ref);
try { try {
final isAuthenticated = final result = await ref.read(authProvider.notifier).login(
await ref.read(authenticationProvider.notifier).login( emailController.text,
usernameController.text, passwordController.text,
passwordController.text, );
sanitizeUrl(serverEndpointController.text),
); if (result.shouldChangePassword && !result.isAdmin) {
if (isAuthenticated) { context.pushRoute(const ChangePasswordRoute());
// 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());
}
} else { } else {
ImmichToast.show( context.replaceRoute(const TabControllerRoute());
context: context,
msg: "login_form_failed_login".tr(),
toastType: ToastType.error,
gravity: ToastGravity.TOP,
);
} }
} catch (error) {
ImmichToast.show(
context: context,
msg: "login_form_failed_login".tr(),
toastType: ToastType.error,
gravity: ToastGravity.TOP,
);
} finally { } finally {
// Make sure we stop loading
isLoading.value = false; isLoading.value = false;
} }
} }
oAuthLogin() async { oAuthLogin() async {
var oAuthService = ref.watch(oAuthServiceProvider); var oAuthService = ref.watch(oAuthServiceProvider);
ref.watch(assetProvider.notifier).clearAllAsset();
String? oAuthServerUrl; String? oAuthServerUrl;
try { try {
@ -262,11 +237,8 @@ class LoginForm extends HookConsumerWidget {
"Finished OAuth login with response: ${loginResponseDto.userEmail}", "Finished OAuth login with response: ${loginResponseDto.userEmail}",
); );
final isSuccess = await ref final isSuccess = await ref.watch(authProvider.notifier).saveAuthInfo(
.watch(authenticationProvider.notifier)
.setSuccessLoginInfo(
accessToken: loginResponseDto.accessToken, accessToken: loginResponseDto.accessToken,
serverUrl: sanitizeUrl(serverEndpointController.text),
); );
if (isSuccess) { if (isSuccess) {
@ -309,7 +281,7 @@ class LoginForm extends HookConsumerWidget {
ServerEndpointInput( ServerEndpointInput(
controller: serverEndpointController, controller: serverEndpointController,
focusNode: serverEndpointFocusNode, focusNode: serverEndpointFocusNode,
onSubmit: getServerLoginCredential, onSubmit: getServerAuthSettings,
), ),
const SizedBox(height: 18), const SizedBox(height: 18),
Row( Row(
@ -344,7 +316,7 @@ class LoginForm extends HookConsumerWidget {
), ),
), ),
onPressed: onPressed:
isLoadingServer.value ? null : getServerLoginCredential, isLoadingServer.value ? null : getServerAuthSettings,
icon: const Icon(Icons.arrow_forward_rounded), icon: const Icon(Icons.arrow_forward_rounded),
label: const Text( label: const Text(
'login_form_next_button', 'login_form_next_button',
@ -402,7 +374,7 @@ class LoginForm extends HookConsumerWidget {
if (isPasswordLoginEnable.value) ...[ if (isPasswordLoginEnable.value) ...[
const SizedBox(height: 18), const SizedBox(height: 18),
EmailInput( EmailInput(
controller: usernameController, controller: emailController,
focusNode: emailFocusNode, focusNode: emailFocusNode,
onSubmit: passwordFocusNode.requestFocus, onSubmit: passwordFocusNode.requestFocus,
), ),

View file

@ -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/album_media.interface.dart';
import 'package:immich_mobile/interfaces/asset.interface.dart'; import 'package:immich_mobile/interfaces/asset.interface.dart';
import 'package:immich_mobile/interfaces/asset_media.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/backup.interface.dart';
import 'package:immich_mobile/interfaces/etag.interface.dart'; import 'package:immich_mobile/interfaces/etag.interface.dart';
import 'package:immich_mobile/interfaces/exif_info.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 MockFileMediaRepository extends Mock implements IFileMediaRepository {}
class MockAlbumApiRepository extends Mock implements IAlbumApiRepository {} class MockAlbumApiRepository extends Mock implements IAlbumApiRepository {}
class MockAuthApiRepository extends Mock implements IAuthApiRepository {}
class MockAuthRepository extends Mock implements IAuthRepository {}

View file

@ -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<Exception>()),
);
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<Exception>()),
);
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);
});
});
}