mirror of
https://github.com/immich-app/immich.git
synced 2024-12-28 06:31:58 +00:00
chore(mobile): refactor authentication (#14322)
This commit is contained in:
parent
5417e34fb6
commit
21f14be949
26 changed files with 619 additions and 354 deletions
|
@ -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
|
||||
|
|
5
mobile/lib/interfaces/auth.interface.dart
Normal file
5
mobile/lib/interfaces/auth.interface.dart
Normal file
|
@ -0,0 +1,5 @@
|
|||
import 'package:immich_mobile/interfaces/database.interface.dart';
|
||||
|
||||
abstract interface class IAuthRepository implements IDatabaseRepository {
|
||||
Future<void> clearLocalData();
|
||||
}
|
9
mobile/lib/interfaces/auth_api.interface.dart
Normal file
9
mobile/lib/interfaces/auth_api.interface.dart
Normal 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);
|
||||
}
|
|
@ -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;
|
||||
}
|
||||
}
|
30
mobile/lib/models/auth/login_response.model.dart
Normal file
30
mobile/lib/models/auth/login_response.model.dart
Normal 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]';
|
||||
}
|
||||
}
|
|
@ -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;
|
||||
|
|
|
@ -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<bool> onRemoveFromAlbumPressed(Iterable<Asset> assets) async {
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
|
|
|
@ -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<AppLifeCycleEnum> {
|
|||
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<AppLifeCycleEnum> {
|
|||
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) {
|
||||
|
|
164
mobile/lib/providers/auth.provider.dart
Normal file
164
mobile/lib/providers/auth.provider.dart
Normal 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;
|
||||
}
|
||||
}
|
|
@ -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,
|
||||
);
|
||||
});
|
|
@ -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<BackUpState> {
|
|||
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),
|
||||
|
|
|
@ -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<WebsocketState> {
|
|||
/// 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 {
|
||||
|
|
28
mobile/lib/repositories/auth.repository.dart
Normal file
28
mobile/lib/repositories/auth.repository.dart
Normal 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();
|
||||
});
|
||||
}
|
||||
}
|
56
mobile/lib/repositories/auth_api.repository.dart
Normal file
56
mobile/lib/repositories/auth_api.repository.dart
Normal 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,
|
||||
);
|
||||
}
|
||||
}
|
|
@ -1,5 +1,4 @@
|
|||
import 'dart:async';
|
||||
|
||||
import 'package:immich_mobile/interfaces/database.interface.dart';
|
||||
import 'package:isar/isar.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<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() {
|
||||
var accessToken = Store.get(StoreKey.accessToken, "");
|
||||
var customHeadersStr = Store.get(StoreKey.customHeaders, "");
|
||||
|
|
96
mobile/lib/services/auth.service.dart
Normal file
96
mobile/lib/services/auth.service.dart
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
24
mobile/lib/services/device.service.dart
Normal file
24
mobile/lib/services/device.service.dart
Normal 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();
|
||||
}
|
||||
}
|
|
@ -35,8 +35,9 @@ class UserService {
|
|||
this._syncService,
|
||||
);
|
||||
|
||||
Future<List<User>> getUsers({bool self = false}) =>
|
||||
_userRepository.getAll(self: self);
|
||||
Future<List<User>> getUsers({bool self = false}) {
|
||||
return _userRepository.getAll(self: self);
|
||||
}
|
||||
|
||||
Future<({String profileImagePath})?> uploadProfileImage(XFile image) async {
|
||||
try {
|
||||
|
|
|
@ -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);
|
||||
|
||||
|
|
|
@ -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) {
|
||||
|
|
|
@ -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<FormState>();
|
||||
|
||||
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)
|
||||
|
|
|
@ -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<bool> getServerLoginCredential() async {
|
||||
Future<void> 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,
|
||||
),
|
||||
|
|
|
@ -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 {}
|
||||
|
|
118
mobile/test/services/auth.service_test.dart
Normal file
118
mobile/test/services/auth.service_test.dart
Normal 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);
|
||||
});
|
||||
});
|
||||
}
|
Loading…
Reference in a new issue