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:
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/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
|
||||||
|
|
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 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;
|
||||||
}
|
}
|
||||||
}
|
}
|
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/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;
|
||||||
|
|
|
@ -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 {
|
||||||
|
|
|
@ -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;
|
||||||
}
|
}
|
||||||
|
|
|
@ -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) {
|
||||||
|
|
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/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),
|
||||||
|
|
|
@ -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 {
|
||||||
|
|
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 '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';
|
||||||
|
|
||||||
|
|
|
@ -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, "");
|
||||||
|
|
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,
|
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 {
|
||||||
|
|
|
@ -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);
|
||||||
|
|
||||||
|
|
|
@ -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) {
|
||||||
|
|
|
@ -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)
|
||||||
|
|
|
@ -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,
|
||||||
),
|
),
|
||||||
|
|
|
@ -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 {}
|
||||||
|
|
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