diff --git a/docker/docker-compose.dev.yml b/docker/docker-compose.dev.yml index 0b14ea7d6a..5da5bd3f91 100644 --- a/docker/docker-compose.dev.yml +++ b/docker/docker-compose.dev.yml @@ -125,26 +125,23 @@ services: ports: - 5432:5432 healthcheck: - test: pg_isready --dbname='${DB_DATABASE_NAME}' --username='${DB_USERNAME}' || exit 1; Chksum="$$(psql --dbname='${DB_DATABASE_NAME}' --username='${DB_USERNAME}' --tuples-only --no-align --command='SELECT COALESCE(SUM(checksum_failures), 0) FROM pg_stat_database')"; echo "checksum failure count is $$Chksum"; [ "$$Chksum" = '0' ] || exit 1 + test: >- + pg_isready --dbname="$${POSTGRES_DB}" --username="$${POSTGRES_USER}" || exit 1; + Chksum="$$(psql --dbname="$${POSTGRES_DB}" --username="$${POSTGRES_USER}" --tuples-only --no-align + --command='SELECT COALESCE(SUM(checksum_failures), 0) FROM pg_stat_database')"; + echo "checksum failure count is $$Chksum"; + [ "$$Chksum" = '0' ] || exit 1 interval: 5m start_interval: 30s start_period: 5m - command: - [ - 'postgres', - '-c', - 'shared_preload_libraries=vectors.so', - '-c', - 'search_path="$$user", public, vectors', - '-c', - 'logging_collector=on', - '-c', - 'max_wal_size=2GB', - '-c', - 'shared_buffers=512MB', - '-c', - 'wal_compression=on', - ] + command: >- + postgres + -c shared_preload_libraries=vectors.so + -c 'search_path="$$user", public, vectors' + -c logging_collector=on + -c max_wal_size=2GB + -c shared_buffers=512MB + -c wal_compression=on # set IMMICH_TELEMETRY_INCLUDE=all in .env to enable metrics # immich-prometheus: diff --git a/docker/docker-compose.prod.yml b/docker/docker-compose.prod.yml index 96e324f0d9..8d80003ee4 100644 --- a/docker/docker-compose.prod.yml +++ b/docker/docker-compose.prod.yml @@ -67,26 +67,23 @@ services: ports: - 5432:5432 healthcheck: - test: pg_isready --dbname='${DB_DATABASE_NAME}' --username='${DB_USERNAME}' || exit 1; Chksum="$$(psql --dbname='${DB_DATABASE_NAME}' --username='${DB_USERNAME}' --tuples-only --no-align --command='SELECT COALESCE(SUM(checksum_failures), 0) FROM pg_stat_database')"; echo "checksum failure count is $$Chksum"; [ "$$Chksum" = '0' ] || exit 1 + test: >- + pg_isready --dbname="$${POSTGRES_DB}" --username="$${POSTGRES_USER}" || exit 1; + Chksum="$$(psql --dbname="$${POSTGRES_DB}" --username="$${POSTGRES_USER}" --tuples-only --no-align + --command='SELECT COALESCE(SUM(checksum_failures), 0) FROM pg_stat_database')"; + echo "checksum failure count is $$Chksum"; + [ "$$Chksum" = '0' ] || exit 1 interval: 5m start_interval: 30s start_period: 5m - command: - [ - 'postgres', - '-c', - 'shared_preload_libraries=vectors.so', - '-c', - 'search_path="$$user", public, vectors', - '-c', - 'logging_collector=on', - '-c', - 'max_wal_size=2GB', - '-c', - 'shared_buffers=512MB', - '-c', - 'wal_compression=on', - ] + command: >- + postgres + -c shared_preload_libraries=vectors.so + -c 'search_path="$$user", public, vectors' + -c logging_collector=on + -c max_wal_size=2GB + -c shared_buffers=512MB + -c wal_compression=on restart: always # set IMMICH_TELEMETRY_INCLUDE=all in .env to enable metrics diff --git a/docker/docker-compose.yml b/docker/docker-compose.yml index 86ec637cbb..4b8453ce58 100644 --- a/docker/docker-compose.yml +++ b/docker/docker-compose.yml @@ -65,26 +65,23 @@ services: # Do not edit the next line. If you want to change the database storage location on your system, edit the value of DB_DATA_LOCATION in the .env file - ${DB_DATA_LOCATION}:/var/lib/postgresql/data healthcheck: - test: pg_isready --dbname='${DB_DATABASE_NAME}' --username='${DB_USERNAME}' || exit 1; Chksum="$$(psql --dbname='${DB_DATABASE_NAME}' --username='${DB_USERNAME}' --tuples-only --no-align --command='SELECT COALESCE(SUM(checksum_failures), 0) FROM pg_stat_database')"; echo "checksum failure count is $$Chksum"; [ "$$Chksum" = '0' ] || exit 1 + test: >- + pg_isready --dbname="$${POSTGRES_DB}" --username="$${POSTGRES_USER}" || exit 1; + Chksum="$$(psql --dbname="$${POSTGRES_DB}" --username="$${POSTGRES_USER}" --tuples-only --no-align + --command='SELECT COALESCE(SUM(checksum_failures), 0) FROM pg_stat_database')"; + echo "checksum failure count is $$Chksum"; + [ "$$Chksum" = '0' ] || exit 1 interval: 5m start_interval: 30s start_period: 5m - command: - [ - 'postgres', - '-c', - 'shared_preload_libraries=vectors.so', - '-c', - 'search_path="$$user", public, vectors', - '-c', - 'logging_collector=on', - '-c', - 'max_wal_size=2GB', - '-c', - 'shared_buffers=512MB', - '-c', - 'wal_compression=on', - ] + command: >- + postgres + -c shared_preload_libraries=vectors.so + -c 'search_path="$$user", public, vectors' + -c logging_collector=on + -c max_wal_size=2GB + -c shared_buffers=512MB + -c wal_compression=on restart: always volumes: diff --git a/docs/docs/developer/pr-checklist.md b/docs/docs/developer/pr-checklist.md index 6015694976..58581e669a 100644 --- a/docs/docs/developer/pr-checklist.md +++ b/docs/docs/developer/pr-checklist.md @@ -11,6 +11,7 @@ When contributing code through a pull request, please check the following: - [ ] `npm run lint` (linting via ESLint) - [ ] `npm run format` (formatting via Prettier) - [ ] `npm run check:svelte` (Type checking via SvelteKit) +- [ ] `npm run check:typescript` (check typescript) - [ ] `npm test` (unit tests) ## Documentation diff --git a/e2e/src/api/specs/server.e2e-spec.ts b/e2e/src/api/specs/server.e2e-spec.ts index 4bff4b3dea..c89280f579 100644 --- a/e2e/src/api/specs/server.e2e-spec.ts +++ b/e2e/src/api/specs/server.e2e-spec.ts @@ -133,6 +133,7 @@ describe('/server', () => { userDeleteDelay: 7, isInitialized: true, externalDomain: '', + publicUsers: true, isOnboarded: false, mapDarkStyleUrl: 'https://tiles.immich.cloud/v1/style/dark.json', mapLightStyleUrl: 'https://tiles.immich.cloud/v1/style/light.json', diff --git a/i18n/en.json b/i18n/en.json index 149b998562..f44dd21c67 100644 --- a/i18n/en.json +++ b/i18n/en.json @@ -1,4 +1,6 @@ { + "user_usage_stats": "Account usage statistics", + "user_usage_stats_description": "View account usage statistics", "about": "Refresh", "account": "Account", "account_settings": "Account Settings", @@ -222,6 +224,8 @@ "send_welcome_email": "Send welcome email", "server_external_domain_settings": "External domain", "server_external_domain_settings_description": "Domain for public shared links, including http(s)://", + "server_public_users": "Public Users", + "server_public_users_description": "All users (name and email) are listed when adding a user to shared albums. When disabled, the user list will only be available to admin users.", "server_settings": "Server Settings", "server_settings_description": "Manage server settings", "server_welcome_message": "Welcome message", @@ -1311,6 +1315,7 @@ "view_all_users": "View all users", "view_in_timeline": "View in timeline", "view_links": "View links", + "view_name": "View", "view_next_asset": "View next asset", "view_previous_asset": "View previous asset", "view_stack": "View Stack", @@ -1324,5 +1329,7 @@ "years_ago": "{years, plural, one {# year} other {# years}} ago", "yes": "Yes", "you_dont_have_any_shared_links": "You don't have any shared links", - "zoom_image": "Zoom Image" + "zoom_image": "Zoom Image", + "timeline": "Timeline", + "total": "Total" } diff --git a/mobile/.fvmrc b/mobile/.fvmrc index 7395731481..691c22dd17 100644 --- a/mobile/.fvmrc +++ b/mobile/.fvmrc @@ -1,3 +1,3 @@ { - "flutter": "3.24.4" + "flutter": "3.24.5" } diff --git a/mobile/analysis_options.yaml b/mobile/analysis_options.yaml index 80514f1603..7a20c2a6a3 100644 --- a/mobile/analysis_options.yaml +++ b/mobile/analysis_options.yaml @@ -93,7 +93,7 @@ custom_lint: - lib/models/server_info/server_{config,disk_info,features,version}.model.dart - lib/models/shared_link/shared_link.model.dart - lib/providers/asset_viewer/asset_people.provider.dart - - lib/providers/authentication.provider.dart + - lib/providers/auth.provider.dart - lib/providers/image/immich_remote_{image,thumbnail}_provider.dart - lib/providers/map/map_state.provider.dart - lib/providers/search/{search,search_filter}.provider.dart diff --git a/mobile/lib/interfaces/auth.interface.dart b/mobile/lib/interfaces/auth.interface.dart new file mode 100644 index 0000000000..e37323b994 --- /dev/null +++ b/mobile/lib/interfaces/auth.interface.dart @@ -0,0 +1,5 @@ +import 'package:immich_mobile/interfaces/database.interface.dart'; + +abstract interface class IAuthRepository implements IDatabaseRepository { + Future<void> clearLocalData(); +} diff --git a/mobile/lib/interfaces/auth_api.interface.dart b/mobile/lib/interfaces/auth_api.interface.dart new file mode 100644 index 0000000000..0a4b235ff3 --- /dev/null +++ b/mobile/lib/interfaces/auth_api.interface.dart @@ -0,0 +1,9 @@ +import 'package:immich_mobile/models/auth/login_response.model.dart'; + +abstract interface class IAuthApiRepository { + Future<LoginResponse> login(String email, String password); + + Future<void> logout(); + + Future<void> changePassword(String newPassword); +} diff --git a/mobile/lib/models/authentication/authentication_state.model.dart b/mobile/lib/models/auth/auth_state.model.dart similarity index 74% rename from mobile/lib/models/authentication/authentication_state.model.dart rename to mobile/lib/models/auth/auth_state.model.dart index 9dcd320c81..fb65850f1d 100644 --- a/mobile/lib/models/authentication/authentication_state.model.dart +++ b/mobile/lib/models/auth/auth_state.model.dart @@ -1,62 +1,58 @@ -class AuthenticationState { +class AuthState { final String deviceId; final String userId; final String userEmail; final bool isAuthenticated; final String name; final bool isAdmin; - final bool shouldChangePassword; final String profileImagePath; - AuthenticationState({ + + AuthState({ required this.deviceId, required this.userId, required this.userEmail, required this.isAuthenticated, required this.name, required this.isAdmin, - required this.shouldChangePassword, required this.profileImagePath, }); - AuthenticationState copyWith({ + AuthState copyWith({ String? deviceId, String? userId, String? userEmail, bool? isAuthenticated, String? name, bool? isAdmin, - bool? shouldChangePassword, String? profileImagePath, }) { - return AuthenticationState( + return AuthState( deviceId: deviceId ?? this.deviceId, userId: userId ?? this.userId, userEmail: userEmail ?? this.userEmail, isAuthenticated: isAuthenticated ?? this.isAuthenticated, name: name ?? this.name, isAdmin: isAdmin ?? this.isAdmin, - shouldChangePassword: shouldChangePassword ?? this.shouldChangePassword, profileImagePath: profileImagePath ?? this.profileImagePath, ); } @override String toString() { - return 'AuthenticationState(deviceId: $deviceId, userId: $userId, userEmail: $userEmail, isAuthenticated: $isAuthenticated, name: $name, isAdmin: $isAdmin, shouldChangePassword: $shouldChangePassword, profileImagePath: $profileImagePath)'; + return 'AuthenticationState(deviceId: $deviceId, userId: $userId, userEmail: $userEmail, isAuthenticated: $isAuthenticated, name: $name, isAdmin: $isAdmin, profileImagePath: $profileImagePath)'; } @override bool operator ==(Object other) { if (identical(this, other)) return true; - return other is AuthenticationState && + return other is AuthState && other.deviceId == deviceId && other.userId == userId && other.userEmail == userEmail && other.isAuthenticated == isAuthenticated && other.name == name && other.isAdmin == isAdmin && - other.shouldChangePassword == shouldChangePassword && other.profileImagePath == profileImagePath; } @@ -68,7 +64,6 @@ class AuthenticationState { isAuthenticated.hashCode ^ name.hashCode ^ isAdmin.hashCode ^ - shouldChangePassword.hashCode ^ profileImagePath.hashCode; } } diff --git a/mobile/lib/models/auth/login_response.model.dart b/mobile/lib/models/auth/login_response.model.dart new file mode 100644 index 0000000000..f1398418ca --- /dev/null +++ b/mobile/lib/models/auth/login_response.model.dart @@ -0,0 +1,30 @@ +class LoginResponse { + final String accessToken; + + final bool isAdmin; + + final String name; + + final String profileImagePath; + + final bool shouldChangePassword; + + final String userEmail; + + final String userId; + + LoginResponse({ + required this.accessToken, + required this.isAdmin, + required this.name, + required this.profileImagePath, + required this.shouldChangePassword, + required this.userEmail, + required this.userId, + }); + + @override + String toString() { + return 'LoginResponse[accessToken=$accessToken, isAdmin=$isAdmin, name=$name, profileImagePath=$profileImagePath, shouldChangePassword=$shouldChangePassword, userEmail=$userEmail, userId=$userId]'; + } +} diff --git a/mobile/lib/pages/common/album_options.page.dart b/mobile/lib/pages/common/album_options.page.dart index 93e4c180fe..d9f8544af9 100644 --- a/mobile/lib/pages/common/album_options.page.dart +++ b/mobile/lib/pages/common/album_options.page.dart @@ -7,7 +7,7 @@ import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:immich_mobile/extensions/build_context_extensions.dart'; import 'package:immich_mobile/extensions/theme_extensions.dart'; import 'package:immich_mobile/providers/album/album.provider.dart'; -import 'package:immich_mobile/providers/authentication.provider.dart'; +import 'package:immich_mobile/providers/auth.provider.dart'; import 'package:immich_mobile/utils/immich_loading_overlay.dart'; import 'package:immich_mobile/routing/router.dart'; import 'package:immich_mobile/entities/album.entity.dart'; @@ -25,7 +25,7 @@ class AlbumOptionsPage extends HookConsumerWidget { Widget build(BuildContext context, WidgetRef ref) { final sharedUsers = useState(album.sharedUsers.toList()); final owner = album.owner.value; - final userId = ref.watch(authenticationProvider).userId; + final userId = ref.watch(authProvider).userId; final activityEnabled = useState(album.activityEnabled); final isProcessing = useProcessingOverlay(); final isOwner = owner?.id == userId; diff --git a/mobile/lib/pages/common/album_viewer.page.dart b/mobile/lib/pages/common/album_viewer.page.dart index 97885ae4e6..4822c57a07 100644 --- a/mobile/lib/pages/common/album_viewer.page.dart +++ b/mobile/lib/pages/common/album_viewer.page.dart @@ -15,7 +15,7 @@ import 'package:immich_mobile/utils/immich_loading_overlay.dart'; import 'package:immich_mobile/widgets/album/album_action_filled_button.dart'; import 'package:immich_mobile/widgets/album/album_viewer_editable_title.dart'; import 'package:immich_mobile/providers/multiselect.provider.dart'; -import 'package:immich_mobile/providers/authentication.provider.dart'; +import 'package:immich_mobile/providers/auth.provider.dart'; import 'package:immich_mobile/widgets/album/album_viewer_appbar.dart'; import 'package:immich_mobile/routing/router.dart'; import 'package:immich_mobile/entities/album.entity.dart'; @@ -42,7 +42,7 @@ class AlbumViewerPage extends HookConsumerWidget { () => ref.read(currentAlbumProvider.notifier).set(value), ), ); - final userId = ref.watch(authenticationProvider).userId; + final userId = ref.watch(authProvider).userId; final isProcessing = useProcessingOverlay(); Future<bool> onRemoveFromAlbumPressed(Iterable<Asset> assets) async { diff --git a/mobile/lib/pages/common/splash_screen.page.dart b/mobile/lib/pages/common/splash_screen.page.dart index d23e25372c..d88c6cf366 100644 --- a/mobile/lib/pages/common/splash_screen.page.dart +++ b/mobile/lib/pages/common/splash_screen.page.dart @@ -3,11 +3,10 @@ import 'package:flutter/material.dart'; import 'package:flutter_hooks/flutter_hooks.dart' hide Store; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:immich_mobile/providers/backup/backup.provider.dart'; -import 'package:immich_mobile/providers/authentication.provider.dart'; +import 'package:immich_mobile/providers/auth.provider.dart'; import 'package:immich_mobile/providers/gallery_permission.provider.dart'; import 'package:immich_mobile/routing/router.dart'; import 'package:immich_mobile/entities/store.entity.dart'; -import 'package:immich_mobile/providers/api.provider.dart'; import 'package:logging/logging.dart'; @RoutePage() @@ -16,7 +15,6 @@ class SplashScreenPage extends HookConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { - final apiService = ref.watch(apiServiceProvider); final serverUrl = Store.tryGet(StoreKey.serverUrl); final endpoint = Store.tryGet(StoreKey.serverEndpoint); final accessToken = Store.tryGet(StoreKey.accessToken); @@ -26,14 +24,9 @@ class SplashScreenPage extends HookConsumerWidget { bool isAuthSuccess = false; if (accessToken != null && serverUrl != null && endpoint != null) { - apiService.setEndpoint(endpoint); - try { - isAuthSuccess = await ref - .read(authenticationProvider.notifier) - .setSuccessLoginInfo( + isAuthSuccess = await ref.read(authProvider.notifier).saveAuthInfo( accessToken: accessToken, - serverUrl: serverUrl, ); } catch (error, stackTrace) { log.severe( @@ -53,7 +46,7 @@ class SplashScreenPage extends HookConsumerWidget { log.severe( 'Unable to login using offline or online methods - Logging out completely', ); - ref.read(authenticationProvider.notifier).logout(); + ref.read(authProvider.notifier).logout(); context.replaceRoute(const LoginRoute()); return; } diff --git a/mobile/lib/providers/app_life_cycle.provider.dart b/mobile/lib/providers/app_life_cycle.provider.dart index c06a99da35..8cacb70eb2 100644 --- a/mobile/lib/providers/app_life_cycle.provider.dart +++ b/mobile/lib/providers/app_life_cycle.provider.dart @@ -5,7 +5,7 @@ import 'package:immich_mobile/models/backup/backup_state.model.dart'; import 'package:immich_mobile/providers/backup/backup.provider.dart'; import 'package:immich_mobile/providers/backup/ios_background_settings.provider.dart'; import 'package:immich_mobile/providers/backup/manual_upload.provider.dart'; -import 'package:immich_mobile/providers/authentication.provider.dart'; +import 'package:immich_mobile/providers/auth.provider.dart'; import 'package:immich_mobile/providers/memory.provider.dart'; import 'package:immich_mobile/providers/gallery_permission.provider.dart'; import 'package:immich_mobile/providers/notification_permission.provider.dart'; @@ -42,7 +42,7 @@ class AppLifeCycleNotifier extends StateNotifier<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) { diff --git a/mobile/lib/providers/auth.provider.dart b/mobile/lib/providers/auth.provider.dart new file mode 100644 index 0000000000..5efbdab8d3 --- /dev/null +++ b/mobile/lib/providers/auth.provider.dart @@ -0,0 +1,164 @@ +import 'package:flutter/foundation.dart'; +import 'package:flutter_udid/flutter_udid.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:immich_mobile/entities/store.entity.dart'; +import 'package:immich_mobile/models/auth/login_response.model.dart'; +import 'package:immich_mobile/models/auth/auth_state.model.dart'; +import 'package:immich_mobile/entities/user.entity.dart'; +import 'package:immich_mobile/providers/api.provider.dart'; +import 'package:immich_mobile/services/api.service.dart'; +import 'package:immich_mobile/services/auth.service.dart'; +import 'package:immich_mobile/utils/hash.dart'; +import 'package:logging/logging.dart'; +import 'package:openapi/api.dart'; + +final authProvider = StateNotifierProvider<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; + } +} diff --git a/mobile/lib/providers/authentication.provider.dart b/mobile/lib/providers/authentication.provider.dart deleted file mode 100644 index 60e31d707e..0000000000 --- a/mobile/lib/providers/authentication.provider.dart +++ /dev/null @@ -1,245 +0,0 @@ -import 'dart:io'; - -import 'package:device_info_plus/device_info_plus.dart'; -import 'package:flutter/material.dart'; -import 'package:flutter_udid/flutter_udid.dart'; -import 'package:hooks_riverpod/hooks_riverpod.dart'; -import 'package:immich_mobile/providers/album/album.provider.dart'; -import 'package:immich_mobile/entities/store.entity.dart'; -import 'package:immich_mobile/models/authentication/authentication_state.model.dart'; -import 'package:immich_mobile/entities/user.entity.dart'; -import 'package:immich_mobile/providers/api.provider.dart'; -import 'package:immich_mobile/providers/db.provider.dart'; -import 'package:immich_mobile/services/api.service.dart'; -import 'package:immich_mobile/utils/db.dart'; -import 'package:immich_mobile/utils/hash.dart'; -import 'package:isar/isar.dart'; -import 'package:logging/logging.dart'; -import 'package:openapi/api.dart'; - -class AuthenticationNotifier extends StateNotifier<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, - ); -}); diff --git a/mobile/lib/providers/backup/backup.provider.dart b/mobile/lib/providers/backup/backup.provider.dart index dc6d2f7cc8..aab367485c 100644 --- a/mobile/lib/providers/backup/backup.provider.dart +++ b/mobile/lib/providers/backup/backup.provider.dart @@ -22,8 +22,8 @@ import 'package:immich_mobile/repositories/backup.repository.dart'; import 'package:immich_mobile/repositories/file_media.repository.dart'; import 'package:immich_mobile/services/background.service.dart'; import 'package:immich_mobile/services/backup.service.dart'; -import 'package:immich_mobile/models/authentication/authentication_state.model.dart'; -import 'package:immich_mobile/providers/authentication.provider.dart'; +import 'package:immich_mobile/models/auth/auth_state.model.dart'; +import 'package:immich_mobile/providers/auth.provider.dart'; import 'package:immich_mobile/providers/gallery_permission.provider.dart'; import 'package:immich_mobile/models/server_info/server_disk_info.model.dart'; import 'package:immich_mobile/entities/store.entity.dart'; @@ -92,7 +92,7 @@ class BackupNotifier extends StateNotifier<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), diff --git a/mobile/lib/providers/backup/backup_verification.provider.dart b/mobile/lib/providers/backup/backup_verification.provider.dart index 7b8e7b8c4b..5881814320 100644 --- a/mobile/lib/providers/backup/backup_verification.provider.dart +++ b/mobile/lib/providers/backup/backup_verification.provider.dart @@ -35,7 +35,7 @@ class BackupVerification extends _$BackupVerification { return; } final connection = await Connectivity().checkConnectivity(); - if (connection.contains(ConnectivityResult.wifi)) { + if (!connection.contains(ConnectivityResult.wifi)) { if (context.mounted) { ImmichToast.show( context: context, diff --git a/mobile/lib/providers/websocket.provider.dart b/mobile/lib/providers/websocket.provider.dart index 6216a5de64..6889db7b7f 100644 --- a/mobile/lib/providers/websocket.provider.dart +++ b/mobile/lib/providers/websocket.provider.dart @@ -4,7 +4,7 @@ import 'package:collection/collection.dart'; import 'package:flutter/foundation.dart'; import 'package:flutter/widgets.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; -import 'package:immich_mobile/providers/authentication.provider.dart'; +import 'package:immich_mobile/providers/auth.provider.dart'; import 'package:immich_mobile/entities/asset.entity.dart'; import 'package:immich_mobile/models/server_info/server_version.model.dart'; import 'package:immich_mobile/entities/store.entity.dart'; @@ -103,7 +103,7 @@ class WebsocketNotifier extends StateNotifier<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 { diff --git a/mobile/lib/repositories/auth.repository.dart b/mobile/lib/repositories/auth.repository.dart new file mode 100644 index 0000000000..ababf35c9b --- /dev/null +++ b/mobile/lib/repositories/auth.repository.dart @@ -0,0 +1,30 @@ +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(() { + return Future.wait([ + db.assets.clear(), + db.exifInfos.clear(), + db.albums.clear(), + db.eTags.clear(), + db.users.clear(), + ]); + }); + } +} diff --git a/mobile/lib/repositories/auth_api.repository.dart b/mobile/lib/repositories/auth_api.repository.dart new file mode 100644 index 0000000000..faa2916adb --- /dev/null +++ b/mobile/lib/repositories/auth_api.repository.dart @@ -0,0 +1,56 @@ +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:immich_mobile/interfaces/auth_api.interface.dart'; +import 'package:immich_mobile/models/auth/login_response.model.dart'; +import 'package:immich_mobile/providers/api.provider.dart'; +import 'package:immich_mobile/repositories/api.repository.dart'; +import 'package:immich_mobile/services/api.service.dart'; +import 'package:openapi/api.dart'; + +final authApiRepositoryProvider = + Provider((ref) => AuthApiRepository(ref.watch(apiServiceProvider))); + +class AuthApiRepository extends ApiRepository implements IAuthApiRepository { + final ApiService _apiService; + + AuthApiRepository(this._apiService); + + @override + Future<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, + ); + } +} diff --git a/mobile/lib/repositories/database.repository.dart b/mobile/lib/repositories/database.repository.dart index f9ee1426bb..3eb74621fa 100644 --- a/mobile/lib/repositories/database.repository.dart +++ b/mobile/lib/repositories/database.repository.dart @@ -1,5 +1,4 @@ import 'dart:async'; - import 'package:immich_mobile/interfaces/database.interface.dart'; import 'package:isar/isar.dart'; diff --git a/mobile/lib/services/api.service.dart b/mobile/lib/services/api.service.dart index 515023d163..bd754ac214 100644 --- a/mobile/lib/services/api.service.dart +++ b/mobile/lib/services/api.service.dart @@ -2,6 +2,7 @@ import 'dart:async'; import 'dart:convert'; import 'dart:io'; +import 'package:device_info_plus/device_info_plus.dart'; import 'package:flutter/material.dart'; import 'package:immich_mobile/entities/store.entity.dart'; import 'package:immich_mobile/utils/url_helper.dart'; @@ -69,7 +70,7 @@ class ApiService implements Authentication { final endpoint = await _resolveEndpoint(serverUrl); setEndpoint(endpoint); - // Save in hivebox for next startup + // Save in local database for next startup Store.put(StoreKey.serverEndpoint, endpoint); return endpoint; } @@ -148,11 +149,27 @@ class ApiService implements Authentication { return ""; } - setAccessToken(String accessToken) { + void setAccessToken(String accessToken) { _accessToken = accessToken; Store.put(StoreKey.accessToken, accessToken); } + Future<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, ""); diff --git a/mobile/lib/services/auth.service.dart b/mobile/lib/services/auth.service.dart new file mode 100644 index 0000000000..fa6e282e63 --- /dev/null +++ b/mobile/lib/services/auth.service.dart @@ -0,0 +1,98 @@ +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().catchError((error, stackTrace) { + _log.severe("Error clearing local data", error, stackTrace); + }); + } + } + + /// 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; + } + } +} diff --git a/mobile/lib/services/device.service.dart b/mobile/lib/services/device.service.dart new file mode 100644 index 0000000000..e1676d5683 --- /dev/null +++ b/mobile/lib/services/device.service.dart @@ -0,0 +1,24 @@ +import 'package:flutter_udid/flutter_udid.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:immich_mobile/entities/store.entity.dart'; + +final deviceServiceProvider = Provider((ref) => DeviceService()); + +class DeviceService { + DeviceService(); + + createDeviceId() { + return FlutterUdid.consistentUdid; + } + + /// Returns the device ID from local storage or creates a new one if not found. + /// + /// This method first attempts to retrieve the device ID from the local store using + /// [StoreKey.deviceId]. If no device ID is found (returns null), it generates a + /// new device ID by calling [createDeviceId]. + /// + /// Returns a [String] representing the device's unique identifier. + String getDeviceId() { + return Store.tryGet(StoreKey.deviceId) ?? createDeviceId(); + } +} diff --git a/mobile/lib/services/user.service.dart b/mobile/lib/services/user.service.dart index 4c2b3cbbd0..13adcc4e7a 100644 --- a/mobile/lib/services/user.service.dart +++ b/mobile/lib/services/user.service.dart @@ -35,8 +35,9 @@ class UserService { this._syncService, ); - Future<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 { diff --git a/mobile/lib/widgets/common/app_bar_dialog/app_bar_dialog.dart b/mobile/lib/widgets/common/app_bar_dialog/app_bar_dialog.dart index 471014608a..a83afc00b3 100644 --- a/mobile/lib/widgets/common/app_bar_dialog/app_bar_dialog.dart +++ b/mobile/lib/widgets/common/app_bar_dialog/app_bar_dialog.dart @@ -7,7 +7,7 @@ import 'package:immich_mobile/extensions/build_context_extensions.dart'; import 'package:immich_mobile/models/backup/backup_state.model.dart'; import 'package:immich_mobile/providers/backup/backup.provider.dart'; import 'package:immich_mobile/providers/backup/manual_upload.provider.dart'; -import 'package:immich_mobile/providers/authentication.provider.dart'; +import 'package:immich_mobile/providers/auth.provider.dart'; import 'package:immich_mobile/routing/router.dart'; import 'package:immich_mobile/providers/asset.provider.dart'; import 'package:immich_mobile/providers/user.provider.dart'; @@ -128,7 +128,7 @@ class ImmichAppBarDialog extends HookConsumerWidget { onOk: () async { isLoggingOut.value = true; await ref - .read(authenticationProvider.notifier) + .read(authProvider.notifier) .logout() .whenComplete(() => isLoggingOut.value = false); diff --git a/mobile/lib/widgets/common/app_bar_dialog/app_bar_profile_info.dart b/mobile/lib/widgets/common/app_bar_dialog/app_bar_profile_info.dart index a40dcf914e..f0006d1ada 100644 --- a/mobile/lib/widgets/common/app_bar_dialog/app_bar_profile_info.dart +++ b/mobile/lib/widgets/common/app_bar_dialog/app_bar_profile_info.dart @@ -7,8 +7,7 @@ import 'package:immich_mobile/providers/upload_profile_image.provider.dart'; import 'package:immich_mobile/entities/store.entity.dart'; import 'package:immich_mobile/providers/user.provider.dart'; import 'package:immich_mobile/widgets/common/user_circle_avatar.dart'; -import 'package:immich_mobile/models/authentication/authentication_state.model.dart'; -import 'package:immich_mobile/providers/authentication.provider.dart'; +import 'package:immich_mobile/providers/auth.provider.dart'; import 'package:immich_mobile/widgets/common/immich_loading_indicator.dart'; class AppBarProfileInfoBox extends HookConsumerWidget { @@ -18,7 +17,7 @@ class AppBarProfileInfoBox extends HookConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { - AuthenticationState authState = ref.watch(authenticationProvider); + final authState = ref.watch(authProvider); final uploadProfileImageStatus = ref.watch(uploadProfileImageProvider).status; final user = Store.tryGet(StoreKey.currentUser); @@ -63,7 +62,7 @@ class AppBarProfileInfoBox extends HookConsumerWidget { if (success) { final profileImagePath = ref.read(uploadProfileImageProvider).profileImagePath; - ref.watch(authenticationProvider.notifier).updateUserProfileImagePath( + ref.watch(authProvider.notifier).updateUserProfileImagePath( profileImagePath, ); if (user != null) { diff --git a/mobile/lib/widgets/forms/change_password_form.dart b/mobile/lib/widgets/forms/change_password_form.dart index 98ce66d2d1..fbb8fd927b 100644 --- a/mobile/lib/widgets/forms/change_password_form.dart +++ b/mobile/lib/widgets/forms/change_password_form.dart @@ -7,7 +7,7 @@ import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:immich_mobile/extensions/build_context_extensions.dart'; import 'package:immich_mobile/providers/backup/backup.provider.dart'; import 'package:immich_mobile/providers/backup/manual_upload.provider.dart'; -import 'package:immich_mobile/providers/authentication.provider.dart'; +import 'package:immich_mobile/providers/auth.provider.dart'; import 'package:immich_mobile/providers/asset.provider.dart'; import 'package:immich_mobile/providers/websocket.provider.dart'; import 'package:immich_mobile/widgets/common/immich_toast.dart'; @@ -21,7 +21,7 @@ class ChangePasswordForm extends HookConsumerWidget { useTextEditingController.fromValue(TextEditingValue.empty); final confirmPasswordController = useTextEditingController.fromValue(TextEditingValue.empty); - final authState = ref.watch(authenticationProvider); + final authState = ref.watch(authProvider); final formKey = GlobalKey<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) diff --git a/mobile/lib/widgets/forms/login/login_form.dart b/mobile/lib/widgets/forms/login/login_form.dart index 51383fe195..30b6a74bb1 100644 --- a/mobile/lib/widgets/forms/login/login_form.dart +++ b/mobile/lib/widgets/forms/login/login_form.dart @@ -11,9 +11,7 @@ import 'package:immich_mobile/providers/oauth.provider.dart'; import 'package:immich_mobile/providers/gallery_permission.provider.dart'; import 'package:immich_mobile/routing/router.dart'; import 'package:immich_mobile/entities/store.entity.dart'; -import 'package:immich_mobile/providers/api.provider.dart'; -import 'package:immich_mobile/providers/asset.provider.dart'; -import 'package:immich_mobile/providers/authentication.provider.dart'; +import 'package:immich_mobile/providers/auth.provider.dart'; import 'package:immich_mobile/providers/backup/backup.provider.dart'; import 'package:immich_mobile/providers/server_info.provider.dart'; import 'package:immich_mobile/utils/provider_utils.dart'; @@ -40,13 +38,12 @@ class LoginForm extends HookConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { - final usernameController = + final emailController = useTextEditingController.fromValue(TextEditingValue.empty); final passwordController = useTextEditingController.fromValue(TextEditingValue.empty); final serverEndpointController = useTextEditingController.fromValue(TextEditingValue.empty); - final apiService = ref.watch(apiServiceProvider); final emailFocusNode = useFocusNode(); final passwordFocusNode = useFocusNode(); final serverEndpointFocusNode = useFocusNode(); @@ -85,7 +82,7 @@ class LoginForm extends HookConsumerWidget { /// Fetch the server login credential and enables oAuth login if necessary /// Returns true if successful, false otherwise - Future<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, ), diff --git a/mobile/openapi/lib/model/server_config_dto.dart b/mobile/openapi/lib/model/server_config_dto.dart index bd5c2405e2..01c82af4d9 100644 Binary files a/mobile/openapi/lib/model/server_config_dto.dart and b/mobile/openapi/lib/model/server_config_dto.dart differ diff --git a/mobile/openapi/lib/model/system_config_server_dto.dart b/mobile/openapi/lib/model/system_config_server_dto.dart index b1b92c9515..8099292dd0 100644 Binary files a/mobile/openapi/lib/model/system_config_server_dto.dart and b/mobile/openapi/lib/model/system_config_server_dto.dart differ diff --git a/mobile/pubspec.lock b/mobile/pubspec.lock index 8b43cf33de..9dc53e42b9 100644 --- a/mobile/pubspec.lock +++ b/mobile/pubspec.lock @@ -1862,4 +1862,4 @@ packages: version: "3.1.2" sdks: dart: ">=3.5.3 <4.0.0" - flutter: ">=3.24.4" + flutter: ">=3.24.5" diff --git a/mobile/pubspec.yaml b/mobile/pubspec.yaml index 931313aac3..235c58ce63 100644 --- a/mobile/pubspec.yaml +++ b/mobile/pubspec.yaml @@ -6,7 +6,7 @@ version: 1.121.0+168 environment: sdk: '>=3.3.0 <4.0.0' - flutter: 3.24.4 + flutter: 3.24.5 isar_version: &isar_version 3.1.8 # define the version to be used diff --git a/mobile/test/repository.mocks.dart b/mobile/test/repository.mocks.dart index c76a003eec..3dda932cac 100644 --- a/mobile/test/repository.mocks.dart +++ b/mobile/test/repository.mocks.dart @@ -3,6 +3,8 @@ import 'package:immich_mobile/interfaces/album_api.interface.dart'; import 'package:immich_mobile/interfaces/album_media.interface.dart'; import 'package:immich_mobile/interfaces/asset.interface.dart'; import 'package:immich_mobile/interfaces/asset_media.interface.dart'; +import 'package:immich_mobile/interfaces/auth.interface.dart'; +import 'package:immich_mobile/interfaces/auth_api.interface.dart'; import 'package:immich_mobile/interfaces/backup.interface.dart'; import 'package:immich_mobile/interfaces/etag.interface.dart'; import 'package:immich_mobile/interfaces/exif_info.interface.dart'; @@ -29,3 +31,7 @@ class MockAssetMediaRepository extends Mock implements IAssetMediaRepository {} class MockFileMediaRepository extends Mock implements IFileMediaRepository {} class MockAlbumApiRepository extends Mock implements IAlbumApiRepository {} + +class MockAuthApiRepository extends Mock implements IAuthApiRepository {} + +class MockAuthRepository extends Mock implements IAuthRepository {} diff --git a/mobile/test/services/auth.service_test.dart b/mobile/test/services/auth.service_test.dart new file mode 100644 index 0000000000..b864babb14 --- /dev/null +++ b/mobile/test/services/auth.service_test.dart @@ -0,0 +1,118 @@ +import 'package:flutter/widgets.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:immich_mobile/entities/store.entity.dart'; +import 'package:immich_mobile/services/auth.service.dart'; +import 'package:mocktail/mocktail.dart'; +import '../repository.mocks.dart'; +import '../service.mocks.dart'; +import '../test_utils.dart'; + +void main() { + late AuthService sut; + late MockAuthApiRepository authApiRepository; + late MockAuthRepository authRepository; + late MockApiService apiService; + + setUp(() async { + authApiRepository = MockAuthApiRepository(); + authRepository = MockAuthRepository(); + apiService = MockApiService(); + sut = AuthService(authApiRepository, authRepository, apiService); + }); + + group('validateServerUrl', () { + setUpAll(() async { + WidgetsFlutterBinding.ensureInitialized(); + final db = await TestUtils.initIsar(); + db.writeTxnSync(() => db.clearSync()); + Store.init(db); + }); + + test('Should resolve HTTP endpoint', () async { + const testUrl = 'http://ip:2283'; + const resolvedUrl = 'http://ip:2283/api'; + + when(() => apiService.resolveAndSetEndpoint(testUrl)) + .thenAnswer((_) async => resolvedUrl); + when(() => apiService.setDeviceInfoHeader()).thenAnswer((_) async => {}); + + final result = await sut.validateServerUrl(testUrl); + + expect(result, resolvedUrl); + + verify(() => apiService.resolveAndSetEndpoint(testUrl)).called(1); + verify(() => apiService.setDeviceInfoHeader()).called(1); + }); + + test('Should resolve HTTPS endpoint', () async { + const testUrl = 'https://immich.domain.com'; + const resolvedUrl = 'https://immich.domain.com/api'; + + when(() => apiService.resolveAndSetEndpoint(testUrl)) + .thenAnswer((_) async => resolvedUrl); + when(() => apiService.setDeviceInfoHeader()).thenAnswer((_) async => {}); + + final result = await sut.validateServerUrl(testUrl); + + expect(result, resolvedUrl); + + verify(() => apiService.resolveAndSetEndpoint(testUrl)).called(1); + verify(() => apiService.setDeviceInfoHeader()).called(1); + }); + + test('Should throw error on invalid URL', () async { + const testUrl = 'invalid-url'; + + when(() => apiService.resolveAndSetEndpoint(testUrl)) + .thenThrow(Exception('Invalid URL')); + + expect( + () async => await sut.validateServerUrl(testUrl), + throwsA(isA<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); + }); + }); +} diff --git a/open-api/immich-openapi-specs.json b/open-api/immich-openapi-specs.json index 162d7db514..1f6ca5438c 100644 --- a/open-api/immich-openapi-specs.json +++ b/open-api/immich-openapi-specs.json @@ -10825,6 +10825,9 @@ "oauthButtonText": { "type": "string" }, + "publicUsers": { + "type": "boolean" + }, "trashDays": { "type": "integer" }, @@ -10840,6 +10843,7 @@ "mapDarkStyleUrl", "mapLightStyleUrl", "oauthButtonText", + "publicUsers", "trashDays", "userDeleteDelay" ], @@ -12018,11 +12022,15 @@ }, "loginPageMessage": { "type": "string" + }, + "publicUsers": { + "type": "boolean" } }, "required": [ "externalDomain", - "loginPageMessage" + "loginPageMessage", + "publicUsers" ], "type": "object" }, diff --git a/open-api/typescript-sdk/src/fetch-client.ts b/open-api/typescript-sdk/src/fetch-client.ts index 025298f302..0263bb0e91 100644 --- a/open-api/typescript-sdk/src/fetch-client.ts +++ b/open-api/typescript-sdk/src/fetch-client.ts @@ -928,6 +928,7 @@ export type ServerConfigDto = { mapDarkStyleUrl: string; mapLightStyleUrl: string; oauthButtonText: string; + publicUsers: boolean; trashDays: number; userDeleteDelay: number; }; @@ -1236,6 +1237,7 @@ export type SystemConfigReverseGeocodingDto = { export type SystemConfigServerDto = { externalDomain: string; loginPageMessage: string; + publicUsers: boolean; }; export type SystemConfigStorageTemplateDto = { enabled: boolean; diff --git a/server/src/config.ts b/server/src/config.ts index 7ab25d474f..bc0d15a7dd 100644 --- a/server/src/config.ts +++ b/server/src/config.ts @@ -151,6 +151,7 @@ export interface SystemConfig { server: { externalDomain: string; loginPageMessage: string; + publicUsers: boolean; }; user: { deleteDelay: number; @@ -310,6 +311,7 @@ export const defaults = Object.freeze<SystemConfig>({ server: { externalDomain: '', loginPageMessage: '', + publicUsers: true, }, notifications: { smtp: { diff --git a/server/src/controllers/user.controller.ts b/server/src/controllers/user.controller.ts index 10076098d6..15bb1913db 100644 --- a/server/src/controllers/user.controller.ts +++ b/server/src/controllers/user.controller.ts @@ -39,8 +39,8 @@ export class UserController { @Get() @Authenticated() - searchUsers(): Promise<UserResponseDto[]> { - return this.service.search(); + searchUsers(@Auth() auth: AuthDto): Promise<UserResponseDto[]> { + return this.service.search(auth); } @Get('me') diff --git a/server/src/dtos/server.dto.ts b/server/src/dtos/server.dto.ts index cbabfa7aed..e1f94dbaa5 100644 --- a/server/src/dtos/server.dto.ts +++ b/server/src/dtos/server.dto.ts @@ -144,6 +144,7 @@ export class ServerConfigDto { isInitialized!: boolean; isOnboarded!: boolean; externalDomain!: string; + publicUsers!: boolean; mapDarkStyleUrl!: string; mapLightStyleUrl!: string; } diff --git a/server/src/dtos/system-config.dto.ts b/server/src/dtos/system-config.dto.ts index e1f7c34cc3..7276a7ed8d 100644 --- a/server/src/dtos/system-config.dto.ts +++ b/server/src/dtos/system-config.dto.ts @@ -404,6 +404,9 @@ class SystemConfigServerDto { @IsString() loginPageMessage!: string; + + @IsBoolean() + publicUsers!: boolean; } class SystemConfigSmtpTransportDto { diff --git a/server/src/services/server.service.spec.ts b/server/src/services/server.service.spec.ts index 475d1d6193..3f7fafcebf 100644 --- a/server/src/services/server.service.spec.ts +++ b/server/src/services/server.service.spec.ts @@ -169,6 +169,7 @@ describe(ServerService.name, () => { isInitialized: undefined, isOnboarded: false, externalDomain: '', + publicUsers: true, mapDarkStyleUrl: 'https://tiles.immich.cloud/v1/style/dark.json', mapLightStyleUrl: 'https://tiles.immich.cloud/v1/style/light.json', }); diff --git a/server/src/services/server.service.ts b/server/src/services/server.service.ts index 7df322a84e..e9dd908a7c 100644 --- a/server/src/services/server.service.ts +++ b/server/src/services/server.service.ts @@ -110,6 +110,7 @@ export class ServerService extends BaseService { isInitialized, isOnboarded: onboarding?.isOnboarded || false, externalDomain: config.server.externalDomain, + publicUsers: config.server.publicUsers, mapDarkStyleUrl: config.map.darkStyle, mapLightStyleUrl: config.map.lightStyle, }; diff --git a/server/src/services/system-config.service.spec.ts b/server/src/services/system-config.service.spec.ts index f9ee42eb03..4d5a29e8a8 100644 --- a/server/src/services/system-config.service.spec.ts +++ b/server/src/services/system-config.service.spec.ts @@ -133,6 +133,7 @@ const updatedConfig = Object.freeze<SystemConfig>({ server: { externalDomain: '', loginPageMessage: '', + publicUsers: true, }, storageTemplate: { enabled: false, diff --git a/server/src/services/user.service.spec.ts b/server/src/services/user.service.spec.ts index 767d8d8954..08b663046b 100644 --- a/server/src/services/user.service.spec.ts +++ b/server/src/services/user.service.spec.ts @@ -38,9 +38,9 @@ describe(UserService.name, () => { }); describe('getAll', () => { - it('should get all users', async () => { + it('admin should get all users', async () => { userMock.getList.mockResolvedValue([userStub.admin]); - await expect(sut.search()).resolves.toEqual([ + await expect(sut.search(authStub.admin)).resolves.toEqual([ expect.objectContaining({ id: authStub.admin.user.id, email: authStub.admin.user.email, @@ -48,6 +48,29 @@ describe(UserService.name, () => { ]); expect(userMock.getList).toHaveBeenCalledWith({ withDeleted: false }); }); + + it('non-admin should get all users when publicUsers enabled', async () => { + userMock.getList.mockResolvedValue([userStub.user1]); + await expect(sut.search(authStub.user1)).resolves.toEqual([ + expect.objectContaining({ + id: authStub.user1.user.id, + email: authStub.user1.user.email, + }), + ]); + expect(userMock.getList).toHaveBeenCalledWith({ withDeleted: false }); + }); + + it('non-admin user should only receive itself when publicUsers is disabled', async () => { + userMock.getList.mockResolvedValue([userStub.user1]); + systemMock.get.mockResolvedValue(systemConfigStub.publicUsersDisabled); + await expect(sut.search(authStub.user1)).resolves.toEqual([ + expect.objectContaining({ + id: authStub.user1.user.id, + email: authStub.user1.user.email, + }), + ]); + expect(userMock.getList).not.toHaveBeenCalledWith({ withDeleted: false }); + }); }); describe('get', () => { diff --git a/server/src/services/user.service.ts b/server/src/services/user.service.ts index 926482fb9c..f4ae42b5ed 100644 --- a/server/src/services/user.service.ts +++ b/server/src/services/user.service.ts @@ -19,8 +19,14 @@ import { getPreferences, getPreferencesPartial, mergePreferences } from 'src/uti @Injectable() export class UserService extends BaseService { - async search(): Promise<UserResponseDto[]> { - const users = await this.userRepository.getList({ withDeleted: false }); + async search(auth: AuthDto): Promise<UserResponseDto[]> { + const config = await this.getConfig({ withCache: false }); + + let users: UserEntity[] = [auth.user]; + if (auth.user.isAdmin || config.server.publicUsers) { + users = await this.userRepository.getList({ withDeleted: false }); + } + return users.map((user) => mapUser(user)); } diff --git a/server/test/fixtures/system-config.stub.ts b/server/test/fixtures/system-config.stub.ts index 10a0de77b0..ed8cc8694a 100644 --- a/server/test/fixtures/system-config.stub.ts +++ b/server/test/fixtures/system-config.stub.ts @@ -117,4 +117,9 @@ export const systemConfigStub = { }, }, }, + publicUsersDisabled: { + server: { + publicUsers: false, + }, + }, } satisfies Record<string, DeepPartial<SystemConfig>>; diff --git a/web/src/app.d.ts b/web/src/app.d.ts index ccec3f33d6..d0d25443c9 100644 --- a/web/src/app.d.ts +++ b/web/src/app.d.ts @@ -28,7 +28,7 @@ interface Element { requestFullscreen?(options?: FullscreenOptions): Promise<void>; } -import type en from '$lib/en.json'; +import type en from '$i18n/en.json'; import 'svelte-i18n'; type NestedKeys<T, K = keyof T> = K extends keyof T & string diff --git a/web/src/lib/__mocks__/visual-viewport.mock.ts b/web/src/lib/__mocks__/visual-viewport.mock.ts new file mode 100644 index 0000000000..23903d56cd --- /dev/null +++ b/web/src/lib/__mocks__/visual-viewport.mock.ts @@ -0,0 +1,9 @@ +export const getVisualViewportMock = () => ({ + height: window.innerHeight, + width: window.innerWidth, + scale: 1, + offsetLeft: 0, + offsetTop: 0, + addEventListener: vi.fn(), + removeEventListener: vi.fn(), +}); diff --git a/web/src/lib/actions/scroll-memory.ts b/web/src/lib/actions/scroll-memory.ts new file mode 100644 index 0000000000..1c19fdd8ab --- /dev/null +++ b/web/src/lib/actions/scroll-memory.ts @@ -0,0 +1,87 @@ +import { navigating } from '$app/stores'; +import { AppRoute, SessionStorageKey } from '$lib/constants'; +import { handlePromiseError } from '$lib/utils'; + +interface Options { + /** + * {@link AppRoute} for subpages that scroll state should be kept while visiting. + * + * This must be kept the same in all subpages of this route for the scroll memory clearer to work. + */ + routeStartsWith: AppRoute; + /** + * Function to clear additional data/state before scrolling (ex infinite scroll). + */ + beforeClear?: () => void; +} + +interface PageOptions extends Options { + /** + * Function to save additional data/state before scrolling (ex infinite scroll). + */ + beforeSave?: () => void; + /** + * Function to load additional data/state before scrolling (ex infinite scroll). + */ + beforeScroll?: () => Promise<void>; +} + +/** + * @param node The scroll slot element, typically from {@link UserPageLayout} + */ +export function scrollMemory( + node: HTMLElement, + { routeStartsWith, beforeSave, beforeClear, beforeScroll }: PageOptions, +) { + const unsubscribeNavigating = navigating.subscribe((navigation) => { + const existingScroll = sessionStorage.getItem(SessionStorageKey.SCROLL_POSITION); + if (navigation?.to && !existingScroll) { + // Save current scroll information when going into a subpage. + if (navigation.to.url.pathname.startsWith(routeStartsWith)) { + beforeSave?.(); + sessionStorage.setItem(SessionStorageKey.SCROLL_POSITION, node.scrollTop.toString()); + } else { + beforeClear?.(); + sessionStorage.removeItem(SessionStorageKey.SCROLL_POSITION); + } + } + }); + + handlePromiseError( + (async () => { + await beforeScroll?.(); + + const newScroll = sessionStorage.getItem(SessionStorageKey.SCROLL_POSITION); + if (newScroll) { + node.scroll({ + top: Number.parseFloat(newScroll), + behavior: 'instant', + }); + } + beforeClear?.(); + sessionStorage.removeItem(SessionStorageKey.SCROLL_POSITION); + })(), + ); + + return { + destroy() { + unsubscribeNavigating(); + }, + }; +} + +export function scrollMemoryClearer(_node: HTMLElement, { routeStartsWith, beforeClear }: Options) { + const unsubscribeNavigating = navigating.subscribe((navigation) => { + // Forget scroll position from main page if going somewhere else. + if (navigation?.to && !navigation?.to.url.pathname.startsWith(routeStartsWith)) { + beforeClear?.(); + sessionStorage.removeItem(SessionStorageKey.SCROLL_POSITION); + } + }); + + return { + destroy() { + unsubscribeNavigating(); + }, + }; +} diff --git a/web/src/lib/actions/use-actions.ts b/web/src/lib/actions/use-actions.ts new file mode 100644 index 0000000000..762cfdccf7 --- /dev/null +++ b/web/src/lib/actions/use-actions.ts @@ -0,0 +1,67 @@ +/** + * @license Apache-2.0 + * https://github.com/hperrin/svelte-material-ui/blob/master/packages/common/src/internal/useActions.ts + */ + +export type SvelteActionReturnType<P> = { + update?: (newParams?: P) => void; + destroy?: () => void; +} | void; + +export type SvelteHTMLActionType<P> = (node: HTMLElement, params?: P) => SvelteActionReturnType<P>; + +// eslint-disable-next-line @typescript-eslint/no-explicit-any +export type HTMLActionEntry<P = any> = SvelteHTMLActionType<P> | [SvelteHTMLActionType<P>, P]; + +export type HTMLActionArray = HTMLActionEntry[]; + +export type SvelteSVGActionType<P> = (node: SVGElement, params?: P) => SvelteActionReturnType<P>; + +// eslint-disable-next-line @typescript-eslint/no-explicit-any +export type SVGActionEntry<P = any> = SvelteSVGActionType<P> | [SvelteSVGActionType<P>, P]; + +export type SVGActionArray = SVGActionEntry[]; + +export type ActionArray = HTMLActionArray | SVGActionArray; + +export function useActions(node: HTMLElement | SVGElement, actions: ActionArray) { + const actionReturns: SvelteActionReturnType<unknown>[] = []; + + if (actions) { + for (const actionEntry of actions) { + const action = Array.isArray(actionEntry) ? actionEntry[0] : actionEntry; + if (Array.isArray(actionEntry) && actionEntry.length > 1) { + actionReturns.push(action(node as HTMLElement & SVGElement, actionEntry[1])); + } else { + actionReturns.push(action(node as HTMLElement & SVGElement)); + } + } + } + + return { + update(actions: ActionArray) { + if ((actions?.length || 0) != actionReturns.length) { + throw new Error('You must not change the length of an actions array.'); + } + + if (actions) { + for (const [i, returnEntry] of actionReturns.entries()) { + if (returnEntry && returnEntry.update) { + const actionEntry = actions[i]; + if (Array.isArray(actionEntry) && actionEntry.length > 1) { + returnEntry.update(actionEntry[1]); + } else { + returnEntry.update(); + } + } + } + } + }, + + destroy() { + for (const returnEntry of actionReturns) { + returnEntry?.destroy?.(); + } + }, + }; +} diff --git a/web/src/lib/components/admin-page/settings/server/server-settings.svelte b/web/src/lib/components/admin-page/settings/server/server-settings.svelte index 14d5624c5f..b9134d1e50 100644 --- a/web/src/lib/components/admin-page/settings/server/server-settings.svelte +++ b/web/src/lib/components/admin-page/settings/server/server-settings.svelte @@ -5,6 +5,7 @@ import type { SettingsResetEvent, SettingsSaveEvent } from '../admin-settings'; import SettingInputField from '$lib/components/shared-components/settings/setting-input-field.svelte'; import SettingButtonsRow from '$lib/components/shared-components/settings/setting-buttons-row.svelte'; + import SettingSwitch from '$lib/components/shared-components/settings/setting-switch.svelte'; import { t } from 'svelte-i18n'; import { SettingInputFieldType } from '$lib/constants'; @@ -44,6 +45,13 @@ isEdited={config.server.loginPageMessage !== savedConfig.server.loginPageMessage} /> + <SettingSwitch + title={$t('admin.server_public_users')} + subtitle={$t('admin.server_public_users_description')} + {disabled} + bind:checked={config.server.publicUsers} + /> + <div class="ml-4"> <SettingButtonsRow onReset={(options) => onReset({ ...options, configKeys: ['server'] })} diff --git a/web/src/lib/components/admin-page/settings/theme/theme-settings.svelte b/web/src/lib/components/admin-page/settings/theme/theme-settings.svelte index 0de9ba2173..d3175eb4d6 100644 --- a/web/src/lib/components/admin-page/settings/theme/theme-settings.svelte +++ b/web/src/lib/components/admin-page/settings/theme/theme-settings.svelte @@ -61,7 +61,6 @@ label={$t('admin.theme_custom_css_settings')} description={$t('admin.theme_custom_css_settings_description')} bind:value={config.theme.customCss} - required={true} isEdited={config.theme.customCss !== savedConfig.theme.customCss} /> diff --git a/web/src/lib/components/asset-viewer/asset-viewer.svelte b/web/src/lib/components/asset-viewer/asset-viewer.svelte index 5e7d1a699b..69acc5bb0a 100644 --- a/web/src/lib/components/asset-viewer/asset-viewer.svelte +++ b/web/src/lib/components/asset-viewer/asset-viewer.svelte @@ -394,6 +394,7 @@ let isFullScreen = $derived(fullscreenElement !== null); $effect(() => { if (asset) { + previewStackedAsset = undefined; handlePromiseError(refreshStack()); } }); diff --git a/web/src/lib/components/layouts/user-page-layout.svelte b/web/src/lib/components/layouts/user-page-layout.svelte index 9be2db2691..6822035b19 100644 --- a/web/src/lib/components/layouts/user-page-layout.svelte +++ b/web/src/lib/components/layouts/user-page-layout.svelte @@ -7,6 +7,7 @@ import NavigationBar from '../shared-components/navigation-bar/navigation-bar.svelte'; import SideBar from '../shared-components/side-bar/side-bar.svelte'; import AdminSideBar from '../shared-components/side-bar/admin-side-bar.svelte'; + import { useActions, type ActionArray } from '$lib/actions/use-actions'; import type { Snippet } from 'svelte'; interface Props { @@ -16,6 +17,7 @@ description?: string | undefined; scrollbar?: boolean; admin?: boolean; + use?: ActionArray; header?: Snippet; sidebar?: Snippet; buttons?: Snippet; @@ -29,6 +31,7 @@ description = undefined, scrollbar = true, admin = false, + use = [], header, sidebar, buttons, @@ -73,7 +76,7 @@ </div> {/if} - <div class="{scrollbarClass} scrollbar-stable absolute {hasTitleClass} w-full overflow-y-auto"> + <div class="{scrollbarClass} scrollbar-stable absolute {hasTitleClass} w-full overflow-y-auto" use:useActions={use}> {@render children?.()} </div> </section> diff --git a/web/src/lib/components/shared-components/__test__/combobox.spec.ts b/web/src/lib/components/shared-components/__test__/combobox.spec.ts new file mode 100644 index 0000000000..e1518809b4 --- /dev/null +++ b/web/src/lib/components/shared-components/__test__/combobox.spec.ts @@ -0,0 +1,30 @@ +import { getIntersectionObserverMock } from '$lib/__mocks__/intersection-observer.mock'; +import { getVisualViewportMock } from '$lib/__mocks__/visual-viewport.mock'; +import Combobox from '$lib/components/shared-components/combobox.svelte'; +import { render, screen } from '@testing-library/svelte'; + +describe('Combobox component', () => { + beforeAll(() => { + vi.stubGlobal('IntersectionObserver', getIntersectionObserverMock()); + vi.stubGlobal('visualViewport', getVisualViewportMock()); + }); + + it('shows selected option', () => { + render(Combobox, { + label: 'test', + selectedOption: { label: 'option-1', value: 'option-1' }, + }); + + expect(screen.getByRole('combobox')).toHaveValue('option-1'); + }); + + it('clears the selected option when set to undefined', async () => { + const { rerender } = render(Combobox, { + label: 'test', + selectedOption: { label: 'option-1', value: 'option-1' }, + }); + + await rerender({ selectedOption: undefined }); + expect(screen.getByRole('combobox')).toHaveValue(''); + }); +}); diff --git a/web/src/lib/components/shared-components/change-date.spec.ts b/web/src/lib/components/shared-components/change-date.spec.ts index 815acac5ab..38c72838e5 100644 --- a/web/src/lib/components/shared-components/change-date.spec.ts +++ b/web/src/lib/components/shared-components/change-date.spec.ts @@ -1,4 +1,5 @@ import { getIntersectionObserverMock } from '$lib/__mocks__/intersection-observer.mock'; +import { getVisualViewportMock } from '$lib/__mocks__/visual-viewport.mock'; import { fireEvent, render, screen } from '@testing-library/svelte'; import { DateTime } from 'luxon'; import ChangeDate from './change-date.svelte'; @@ -16,16 +17,7 @@ describe('ChangeDate component', () => { beforeEach(() => { vi.stubGlobal('IntersectionObserver', getIntersectionObserverMock()); - - vi.stubGlobal('visualViewport', { - height: window.innerHeight, - width: window.innerWidth, - scale: 1, - offsetLeft: 0, - offsetTop: 0, - addEventListener: vi.fn(), - removeEventListener: vi.fn(), - }); + vi.stubGlobal('visualViewport', getVisualViewportMock()); }); afterEach(() => { diff --git a/web/src/lib/components/shared-components/combobox.svelte b/web/src/lib/components/shared-components/combobox.svelte index b17644f137..9dcb4d8f25 100644 --- a/web/src/lib/components/shared-components/combobox.svelte +++ b/web/src/lib/components/shared-components/combobox.svelte @@ -218,7 +218,7 @@ const getInputPosition = () => input?.getBoundingClientRect(); $effect(() => { - // searchQuery = selectedOption ? selectedOption.label : ''; + searchQuery = selectedOption ? selectedOption.label : ''; }); let filteredOptions = $derived( diff --git a/web/src/lib/components/shared-components/drag-and-drop-upload-overlay.svelte b/web/src/lib/components/shared-components/drag-and-drop-upload-overlay.svelte index 620064ca1e..d0620de5b8 100644 --- a/web/src/lib/components/shared-components/drag-and-drop-upload-overlay.svelte +++ b/web/src/lib/components/shared-components/drag-and-drop-upload-overlay.svelte @@ -96,13 +96,25 @@ }); }; + const readEntriesAsync = (reader: FileSystemDirectoryReader) => { + return new Promise<FileSystemEntry[]>((resolve, reject) => { + reader.readEntries(resolve, reject); + }); + }; + const getContentsFromFileSystemDirectoryEntry = async ( fileSystemDirectoryEntry: FileSystemDirectoryEntry, ): Promise<FileSystemEntry[]> => { - return new Promise((resolve, reject) => { - const reader = fileSystemDirectoryEntry.createReader(); - reader.readEntries(resolve, reject); - }); + const reader = fileSystemDirectoryEntry.createReader(); + const files: FileSystemEntry[] = []; + let entries: FileSystemEntry[]; + + do { + entries = await readEntriesAsync(reader); + files.push(...entries); + } while (entries.length > 0); + + return files; }; const handleFiles = async (files?: FileList | File[]) => { diff --git a/web/src/lib/components/shared-components/side-bar/more-information-albums.svelte b/web/src/lib/components/shared-components/side-bar/more-information-albums.svelte deleted file mode 100644 index 58ce0c8574..0000000000 --- a/web/src/lib/components/shared-components/side-bar/more-information-albums.svelte +++ /dev/null @@ -1,27 +0,0 @@ -<script lang="ts"> - import { type AlbumStatisticsResponseDto, getAlbumStatistics } from '@immich/sdk'; - import LoadingSpinner from '$lib/components/shared-components/loading-spinner.svelte'; - import { t } from 'svelte-i18n'; - - interface Props { - albumType: keyof AlbumStatisticsResponseDto; - } - - let { albumType }: Props = $props(); - - const handleAlbumCount = async () => { - try { - return await getAlbumStatistics(); - } catch { - return { owned: 0, shared: 0, notShared: 0 }; - } - }; -</script> - -{#await handleAlbumCount()} - <LoadingSpinner /> -{:then data} - <div> - <p>{$t('albums_count', { values: { count: data[albumType] } })}</p> - </div> -{/await} diff --git a/web/src/lib/components/shared-components/side-bar/more-information-assets.svelte b/web/src/lib/components/shared-components/side-bar/more-information-assets.svelte deleted file mode 100644 index 5e4589be18..0000000000 --- a/web/src/lib/components/shared-components/side-bar/more-information-assets.svelte +++ /dev/null @@ -1,20 +0,0 @@ -<script lang="ts"> - import { getAssetStatistics } from '@immich/sdk'; - import LoadingSpinner from '$lib/components/shared-components/loading-spinner.svelte'; - import { t } from 'svelte-i18n'; - - interface Props { - assetStats: NonNullable<Parameters<typeof getAssetStatistics>[0]>; - } - - let { assetStats }: Props = $props(); -</script> - -{#await getAssetStatistics(assetStats)} - <LoadingSpinner /> -{:then data} - <div> - <p>{$t('videos_count', { values: { count: data.videos } })}</p> - <p>{$t('photos_count', { values: { count: data.images } })}</p> - </div> -{/await} diff --git a/web/src/lib/components/shared-components/side-bar/side-bar-link.svelte b/web/src/lib/components/shared-components/side-bar/side-bar-link.svelte index d3fd94ae08..13f08533c5 100644 --- a/web/src/lib/components/shared-components/side-bar/side-bar-link.svelte +++ b/web/src/lib/components/shared-components/side-bar/side-bar-link.svelte @@ -1,10 +1,7 @@ <script lang="ts"> - import { fade } from 'svelte/transition'; import Icon from '$lib/components/elements/icon.svelte'; - import { mdiInformationOutline } from '@mdi/js'; import { resolveRoute } from '$app/paths'; import { page } from '$app/stores'; - import type { Snippet } from 'svelte'; interface Props { title: string; @@ -13,7 +10,6 @@ flippedLogo?: boolean; isSelected?: boolean; preloadData?: boolean; - moreInformation?: Snippet; } let { @@ -23,10 +19,8 @@ flippedLogo = false, isSelected = $bindable(false), preloadData = true, - moreInformation, }: Props = $props(); - let showMoreInformation = $state(false); let routePath = $derived(resolveRoute(routeId, {})); $effect(() => { @@ -39,7 +33,7 @@ data-sveltekit-preload-data={preloadData ? 'hover' : 'off'} draggable="false" aria-current={isSelected ? 'page' : undefined} - class="flex w-full place-items-center justify-between gap-4 rounded-r-full py-3 transition-[padding] delay-100 duration-100 hover:cursor-pointer hover:bg-immich-gray hover:text-immich-primary dark:text-immich-dark-fg dark:hover:bg-immich-dark-gray dark:hover:text-immich-dark-primary + class="flex w-full place-items-center gap-4 rounded-r-full py-3 transition-[padding] delay-100 duration-100 hover:cursor-pointer hover:bg-immich-gray hover:text-immich-primary dark:text-immich-dark-fg dark:hover:bg-immich-dark-gray dark:hover:text-immich-dark-primary {isSelected ? 'bg-immich-primary/10 text-immich-primary hover:bg-immich-primary/10 dark:bg-immich-dark-primary/10 dark:text-immich-dark-primary' : ''} @@ -50,33 +44,5 @@ <Icon path={icon} size="1.5em" class="shrink-0" flipped={flippedLogo} ariaHidden /> <span class="text-sm font-medium">{title}</span> </div> - - <div - class="h-0 overflow-hidden transition-[height] delay-1000 duration-100 sm:group-hover:h-auto group-hover:sm:overflow-visible md:h-auto md:overflow-visible" - > - {#if moreInformation} - <!-- svelte-ignore a11y_no_static_element_interactions --> - <div - class="relative flex cursor-default select-none justify-center" - onmouseenter={() => (showMoreInformation = true)} - onmouseleave={() => (showMoreInformation = false)} - > - <div class="p-1 text-gray-600 hover:cursor-help dark:text-gray-400"> - <Icon path={mdiInformationOutline} /> - </div> - - {#if showMoreInformation} - <div class="absolute right-6 top-0"> - <div - class="flex place-content-center place-items-center whitespace-nowrap rounded-3xl border bg-immich-bg px-6 py-3 text-xs text-immich-fg shadow-lg dark:border-immich-dark-gray dark:bg-gray-600 dark:text-immich-dark-fg" - class:hidden={!showMoreInformation} - transition:fade={{ duration: 200 }} - > - {@render moreInformation?.()} - </div> - </div> - {/if} - </div> - {/if} - </div> + <div></div> </a> diff --git a/web/src/lib/components/shared-components/side-bar/side-bar.svelte b/web/src/lib/components/shared-components/side-bar/side-bar.svelte index 54607e1779..000afa5d1a 100644 --- a/web/src/lib/components/shared-components/side-bar/side-bar.svelte +++ b/web/src/lib/components/shared-components/side-bar/side-bar.svelte @@ -24,8 +24,6 @@ } from '@mdi/js'; import SideBarSection from './side-bar-section.svelte'; import SideBarLink from './side-bar-link.svelte'; - import MoreInformationAssets from '$lib/components/shared-components/side-bar/more-information-assets.svelte'; - import MoreInformationAlbums from '$lib/components/shared-components/side-bar/more-information-albums.svelte'; import { t } from 'svelte-i18n'; import BottomInfo from '$lib/components/shared-components/side-bar/bottom-info.svelte'; import { preferences } from '$lib/stores/user.store'; @@ -47,11 +45,7 @@ routeId="/(user)/photos" bind:isSelected={isPhotosSelected} icon={isPhotosSelected ? mdiImageMultiple : mdiImageMultipleOutline} - > - {#snippet moreInformation()} - <MoreInformationAssets assetStats={{ isArchived: false }} /> - {/snippet} - </SideBarLink> + ></SideBarLink> {#if $featureFlags.search} <SideBarLink title={$t('explore')} routeId="/(user)/explore" icon={mdiMagnify} /> @@ -80,11 +74,7 @@ routeId="/(user)/sharing" icon={isSharingSelected ? mdiAccountMultiple : mdiAccountMultipleOutline} bind:isSelected={isSharingSelected} - > - {#snippet moreInformation()} - <MoreInformationAlbums albumType="shared" /> - {/snippet} - </SideBarLink> + ></SideBarLink> <div class="text-xs transition-all duration-200 dark:text-immich-dark-fg"> <p class="hidden p-6 group-hover:sm:block md:block">{$t('library').toUpperCase()}</p> @@ -96,17 +86,9 @@ routeId="/(user)/favorites" icon={isFavoritesSelected ? mdiHeart : mdiHeartOutline} bind:isSelected={isFavoritesSelected} - > - {#snippet moreInformation()} - <MoreInformationAssets assetStats={{ isFavorite: true }} /> - {/snippet} - </SideBarLink> + ></SideBarLink> - <SideBarLink title={$t('albums')} routeId="/(user)/albums" icon={mdiImageAlbum} flippedLogo> - {#snippet moreInformation()} - <MoreInformationAlbums albumType="owned" /> - {/snippet} - </SideBarLink> + <SideBarLink title={$t('albums')} routeId="/(user)/albums" icon={mdiImageAlbum} flippedLogo></SideBarLink> {#if $preferences.tags.enabled && $preferences.tags.sidebarWeb} <SideBarLink title={$t('tags')} routeId="/(user)/tags" icon={mdiTagMultipleOutline} flippedLogo /> @@ -128,11 +110,7 @@ routeId="/(user)/archive" bind:isSelected={isArchiveSelected} icon={isArchiveSelected ? mdiArchiveArrowDown : mdiArchiveArrowDownOutline} - > - {#snippet moreInformation()} - <MoreInformationAssets assetStats={{ isArchived: true }} /> - {/snippet} - </SideBarLink> + ></SideBarLink> {#if $featureFlags.trash} <SideBarLink @@ -140,11 +118,7 @@ routeId="/(user)/trash" bind:isSelected={isTrashSelected} icon={isTrashSelected ? mdiTrashCan : mdiTrashCanOutline} - > - {#snippet moreInformation()} - <MoreInformationAssets assetStats={{ isTrashed: true }} /> - {/snippet} - </SideBarLink> + ></SideBarLink> {/if} </nav> diff --git a/web/src/lib/components/user-settings-page/user-settings-list.svelte b/web/src/lib/components/user-settings-page/user-settings-list.svelte index 6f8a0ce4dc..5fdcc4a6a0 100644 --- a/web/src/lib/components/user-settings-page/user-settings-list.svelte +++ b/web/src/lib/components/user-settings-page/user-settings-list.svelte @@ -30,8 +30,10 @@ mdiFeatureSearchOutline, mdiKeyOutline, mdiOnepassword, + mdiServerOutline, mdiTwoFactorAuthentication, } from '@mdi/js'; + import UserUsageStatistic from '$lib/components/user-settings-page/user-usage-statistic.svelte'; interface Props { keys?: ApiKeyResponseDto[]; @@ -59,6 +61,15 @@ <UserProfileSettings /> </SettingAccordion> + <SettingAccordion + icon={mdiServerOutline} + key="user-usage-info" + title={$t('user_usage_stats')} + subtitle={$t('user_usage_stats_description')} + > + <UserUsageStatistic /> + </SettingAccordion> + <SettingAccordion icon={mdiApi} key="api-keys" title={$t('api_keys')} subtitle={$t('manage_your_api_keys')}> <UserAPIKeyList bind:keys /> </SettingAccordion> diff --git a/web/src/lib/components/user-settings-page/user-usage-statistic.svelte b/web/src/lib/components/user-settings-page/user-usage-statistic.svelte new file mode 100644 index 0000000000..f7de1d8f64 --- /dev/null +++ b/web/src/lib/components/user-settings-page/user-usage-statistic.svelte @@ -0,0 +1,114 @@ +<script lang="ts"> + import { locale } from '$lib/stores/preferences.store'; + import { + getAlbumStatistics, + getAssetStatistics, + type AlbumStatisticsResponseDto, + type AssetStatsResponseDto, + } from '@immich/sdk'; + import { onMount } from 'svelte'; + import { t } from 'svelte-i18n'; + + let timelineStats: AssetStatsResponseDto = $state({ + videos: 0, + images: 0, + total: 0, + }); + + let favoriteStats: AssetStatsResponseDto = $state({ + videos: 0, + images: 0, + total: 0, + }); + + let archiveStats: AssetStatsResponseDto = $state({ + videos: 0, + images: 0, + total: 0, + }); + + let trashStats: AssetStatsResponseDto = $state({ + videos: 0, + images: 0, + total: 0, + }); + + let albumStats: AlbumStatisticsResponseDto = $state({ + owned: 0, + shared: 0, + notShared: 0, + }); + + const getUsage = async () => { + [timelineStats, favoriteStats, archiveStats, trashStats, albumStats] = await Promise.all([ + getAssetStatistics({ isArchived: false }), + getAssetStatistics({ isFavorite: true }), + getAssetStatistics({ isArchived: true }), + getAssetStatistics({ isTrashed: true }), + getAlbumStatistics(), + ]); + }; + + onMount(async () => { + await getUsage(); + }); +</script> + +{#snippet row(viewName: string, stats: AssetStatsResponseDto)} + <tr + class="flex h-14 w-full place-items-center text-center dark:text-immich-dark-fg odd:bg-immich-bg even:bg-immich-gray odd:dark:bg-immich-dark-gray/50 even:dark:bg-immich-dark-gray/75" + > + <td class="w-1/4 px-4 text-sm">{viewName}</td> + <td class="w-1/4 px-4 text-sm">{stats.images.toLocaleString($locale)}</td> + <td class="w-1/4 px-4 text-sm">{stats.videos.toLocaleString($locale)}</td> + <td class="w-1/4 px-4">{stats.total.toLocaleString($locale)}</td> + </tr> +{/snippet} + +<section class="my-6"> + <p class="text-xs dark:text-white uppercase">{$t('photos_and_videos')}</p> + <div class="overflow-x-auto"> + <table class="w-full text-left mt-4"> + <thead + class="mb-4 flex h-12 w-full rounded-md border bg-gray-50 text-immich-primary dark:border-immich-dark-gray dark:bg-immich-dark-gray dark:text-immich-dark-primary" + > + <tr class="flex w-full place-items-center text-sm font-medium text-center"> + <th class="w-1/4">{$t('view_name')}</th> + <th class="w-1/4">{$t('photos')}</th> + <th class="w-1/4">{$t('videos')}</th> + <th class="w-1/4">{$t('total')}</th> + </tr> + </thead> + <tbody class="block w-full overflow-y-auto rounded-md border dark:border-immich-dark-gray"> + {@render row($t('timeline'), timelineStats)} + {@render row($t('favorites'), favoriteStats)} + {@render row($t('archive'), archiveStats)} + {@render row($t('trash'), trashStats)} + </tbody> + </table> + </div> + + <div class="mt-6"> + <p class="text-xs dark:text-white uppercase">{$t('albums')}</p> + </div> + <div class="overflow-x-auto"> + <table class="w-full text-left mt-4"> + <thead + class="mb-4 flex h-12 w-full rounded-md border bg-gray-50 text-immich-primary dark:border-immich-dark-gray dark:bg-immich-dark-gray dark:text-immich-dark-primary" + > + <tr class="flex w-full place-items-center text-sm font-medium text-center"> + <th class="w-1/2">{$t('owned')}</th> + <th class="w-1/2">{$t('shared')}</th> + </tr> + </thead> + <tbody class="block w-full overflow-y-auto rounded-md border dark:border-immich-dark-gray"> + <tr + class="flex h-14 w-full place-items-center text-center dark:text-immich-dark-fg bg-immich-bg dark:bg-immich-dark-gray/50" + > + <td class="w-1/2 px-4 text-sm">{albumStats.owned.toLocaleString($locale)}</td> + <td class="w-1/2 px-4 text-sm">{albumStats.shared.toLocaleString($locale)}</td> + </tr> + </tbody> + </table> + </div> +</section> diff --git a/web/src/lib/constants.ts b/web/src/lib/constants.ts index f47d4a8c87..8d4fb809a5 100644 --- a/web/src/lib/constants.ts +++ b/web/src/lib/constants.ts @@ -84,6 +84,11 @@ export enum QueryParameter { PATH = 'path', } +export enum SessionStorageKey { + INFINITE_SCROLL_PAGE = 'infiniteScrollPage', + SCROLL_POSITION = 'scrollPosition', +} + export enum OpenSettingQueryParameterValue { OAUTH = 'oauth', JOB = 'job', diff --git a/web/src/lib/stores/folders.store.ts b/web/src/lib/stores/folders.store.ts deleted file mode 100644 index 2e491374e2..0000000000 --- a/web/src/lib/stores/folders.store.ts +++ /dev/null @@ -1,69 +0,0 @@ -import { - getAssetsByOriginalPath, - getUniqueOriginalPaths, - /** - * TODO: Incorrect type - */ - type AssetResponseDto, -} from '@immich/sdk'; -import { get, writable } from 'svelte/store'; - -type AssetCache = { - [path: string]: AssetResponseDto[]; -}; - -type FoldersStore = { - uniquePaths: string[] | null; - assets: AssetCache; -}; - -function createFoldersStore() { - const initialState: FoldersStore = { - uniquePaths: null, - assets: {}, - }; - - const { subscribe, set, update } = writable(initialState); - - async function fetchUniquePaths() { - const state = get(foldersStore); - - if (state.uniquePaths !== null) { - return; - } - - const uniquePaths = await getUniqueOriginalPaths(); - if (uniquePaths) { - update((state) => ({ ...state, uniquePaths })); - } - } - - async function fetchAssetsByPath(path: string) { - const state = get(foldersStore); - - if (state.assets[path]) { - return; - } - - const assets = await getAssetsByOriginalPath({ path }); - if (assets) { - update((state) => ({ - ...state, - assets: { ...state.assets, [path]: assets }, - })); - } - } - - function clearCache() { - set(initialState); - } - - return { - subscribe, - fetchUniquePaths, - fetchAssetsByPath, - clearCache, - }; -} - -export const foldersStore = createFoldersStore(); diff --git a/web/src/lib/stores/folders.svelte.ts b/web/src/lib/stores/folders.svelte.ts new file mode 100644 index 0000000000..cff7aea455 --- /dev/null +++ b/web/src/lib/stores/folders.svelte.ts @@ -0,0 +1,45 @@ +import { + getAssetsByOriginalPath, + getUniqueOriginalPaths, + /** + * TODO: Incorrect type + */ + type AssetResponseDto, +} from '@immich/sdk'; + +type AssetCache = { + [path: string]: AssetResponseDto[]; +}; + +class FoldersStore { + private initialized = false; + uniquePaths = $state<string[]>([]); + assets = $state<AssetCache>({}); + + async fetchUniquePaths() { + if (this.initialized) { + return; + } + this.initialized = true; + + const uniquePaths = await getUniqueOriginalPaths(); + this.uniquePaths.push(...uniquePaths); + this.uniquePaths.sort(); + } + + async fetchAssetsByPath(path: string) { + if (this.assets[path]) { + return; + } + + this.assets[path] = await getAssetsByOriginalPath({ path }); + } + + clearCache() { + this.initialized = false; + this.uniquePaths = []; + this.assets = {}; + } +} + +export const foldersStore = new FoldersStore(); diff --git a/web/src/lib/stores/server-config.store.ts b/web/src/lib/stores/server-config.store.ts index 358765fe0b..254db71946 100644 --- a/web/src/lib/stores/server-config.store.ts +++ b/web/src/lib/stores/server-config.store.ts @@ -34,6 +34,7 @@ export const serverConfig = writable<ServerConfig>({ externalDomain: '', mapDarkStyleUrl: '', mapLightStyleUrl: '', + publicUsers: true, }); export const retrieveServerConfig = async () => { diff --git a/web/src/lib/utils/auth.ts b/web/src/lib/utils/auth.ts index 0ac1658948..fe0a4b42d4 100644 --- a/web/src/lib/utils/auth.ts +++ b/web/src/lib/utils/auth.ts @@ -1,6 +1,6 @@ import { browser } from '$app/environment'; import { goto } from '$app/navigation'; -import { foldersStore } from '$lib/stores/folders.store'; +import { foldersStore } from '$lib/stores/folders.svelte'; import { purchaseStore } from '$lib/stores/purchase.store'; import { serverInfo } from '$lib/stores/server-info.store'; import { preferences as preferences$, resetSavedUser, user as user$ } from '$lib/stores/user.store'; diff --git a/web/src/lib/utils/tree-utils.ts b/web/src/lib/utils/tree-utils.ts index 13fb6c1605..5a6e917079 100644 --- a/web/src/lib/utils/tree-utils.ts +++ b/web/src/lib/utils/tree-utils.ts @@ -7,8 +7,6 @@ export const normalizeTreePath = (path: string) => path.replace(/^\//, '').repla export function buildTree(paths: string[]) { const root: RecursiveObject = {}; - paths.sort(); - for (const path of paths) { const parts = path.split('/'); let current = root; diff --git a/web/src/routes/(user)/albums/+page.svelte b/web/src/routes/(user)/albums/+page.svelte index 29079a48b8..239c6cc38a 100644 --- a/web/src/routes/(user)/albums/+page.svelte +++ b/web/src/routes/(user)/albums/+page.svelte @@ -1,5 +1,6 @@ <script lang="ts"> import type { PageData } from './$types'; + import { scrollMemory } from '$lib/actions/scroll-memory'; import { AlbumFilter, albumViewSettings } from '$lib/stores/preferences.store'; import { createAlbumAndRedirect } from '$lib/utils/album-utils'; import UserPageLayout from '$lib/components/layouts/user-page-layout.svelte'; @@ -8,6 +9,7 @@ import EmptyPlaceholder from '$lib/components/shared-components/empty-placeholder.svelte'; import GroupTab from '$lib/components/elements/group-tab.svelte'; import SearchBar from '$lib/components/elements/search-bar.svelte'; + import { AppRoute } from '$lib/constants'; import { t } from 'svelte-i18n'; interface Props { @@ -20,7 +22,7 @@ let albumGroups: string[] = $state([]); </script> -<UserPageLayout title={data.meta.title}> +<UserPageLayout title={data.meta.title} use={[[scrollMemory, { routeStartsWith: AppRoute.ALBUMS }]]}> {#snippet buttons()} <div class="flex place-items-center gap-2"> <AlbumsControls {albumGroups} bind:searchQuery /> diff --git a/web/src/routes/(user)/albums/[albumId=id]/[[photos=photos]]/[[assetId=id]]/+page.svelte b/web/src/routes/(user)/albums/[albumId=id]/[[photos=photos]]/[[assetId=id]]/+page.svelte index bf3f3509c4..5c63d8e1a3 100644 --- a/web/src/routes/(user)/albums/[albumId=id]/[[photos=photos]]/[[assetId=id]]/+page.svelte +++ b/web/src/routes/(user)/albums/[albumId=id]/[[photos=photos]]/[[assetId=id]]/+page.svelte @@ -1,5 +1,6 @@ <script lang="ts"> import { afterNavigate, goto, onNavigate } from '$app/navigation'; + import { scrollMemoryClearer } from '$lib/actions/scroll-memory'; import AlbumDescription from '$lib/components/album-page/album-description.svelte'; import AlbumOptions from '$lib/components/album-page/album-options.svelte'; import AlbumSummary from '$lib/components/album-page/album-summary.svelte'; @@ -430,7 +431,7 @@ }); </script> -<div class="flex overflow-hidden"> +<div class="flex overflow-hidden" use:scrollMemoryClearer={{ routeStartsWith: AppRoute.ALBUMS }}> <div class="relative w-full shrink"> {#if $isMultiSelectState} <AssetSelectControlBar assets={$selectedAssets} clearSelect={() => assetInteractionStore.clearMultiselect()}> diff --git a/web/src/routes/(user)/folders/[[photos=photos]]/[[assetId=id]]/+page.svelte b/web/src/routes/(user)/folders/[[photos=photos]]/[[assetId=id]]/+page.svelte index 728387753c..065b28c674 100644 --- a/web/src/routes/(user)/folders/[[photos=photos]]/[[assetId=id]]/+page.svelte +++ b/web/src/routes/(user)/folders/[[photos=photos]]/[[assetId=id]]/+page.svelte @@ -9,7 +9,7 @@ import TreeItems from '$lib/components/shared-components/tree/tree-items.svelte'; import { AppRoute, QueryParameter } from '$lib/constants'; import type { Viewport } from '$lib/stores/assets.store'; - import { foldersStore } from '$lib/stores/folders.store'; + import { foldersStore } from '$lib/stores/folders.svelte'; import { buildTree, normalizeTreePath } from '$lib/utils/tree-utils'; import { mdiFolder, mdiFolderHome, mdiFolderOutline } from '@mdi/js'; import { onMount } from 'svelte'; @@ -27,7 +27,7 @@ const viewport: Viewport = $state({ width: 0, height: 0 }); let pathSegments = $derived(data.path ? data.path.split('/') : []); - let tree = $derived(buildTree($foldersStore?.uniquePaths || [])); + let tree = $derived(buildTree(foldersStore.uniquePaths)); let currentPath = $derived($page.url.searchParams.get(QueryParameter.PATH) || ''); let currentTreeItems = $derived(currentPath ? data.currentFolders : Object.keys(tree)); diff --git a/web/src/routes/(user)/folders/[[photos=photos]]/[[assetId=id]]/+page.ts b/web/src/routes/(user)/folders/[[photos=photos]]/[[assetId=id]]/+page.ts index 41800c1a7d..d6fc683c08 100644 --- a/web/src/routes/(user)/folders/[[photos=photos]]/[[assetId=id]]/+page.ts +++ b/web/src/routes/(user)/folders/[[photos=photos]]/[[assetId=id]]/+page.ts @@ -1,10 +1,9 @@ import { QueryParameter } from '$lib/constants'; -import { foldersStore } from '$lib/stores/folders.store'; +import { foldersStore } from '$lib/stores/folders.svelte'; import { authenticate } from '$lib/utils/auth'; import { getFormatter } from '$lib/utils/i18n'; import { getAssetInfoFromParam } from '$lib/utils/navigation'; import { buildTree, normalizeTreePath } from '$lib/utils/tree-utils'; -import { get } from 'svelte/store'; import type { PageLoad } from './$types'; export const load = (async ({ params, url }) => { @@ -13,18 +12,16 @@ export const load = (async ({ params, url }) => { const $t = await getFormatter(); await foldersStore.fetchUniquePaths(); - const { uniquePaths } = get(foldersStore); let pathAssets = null; const path = url.searchParams.get(QueryParameter.PATH); if (path) { await foldersStore.fetchAssetsByPath(path); - const { assets } = get(foldersStore); - pathAssets = assets[path] || null; + pathAssets = foldersStore.assets[path] || null; } - let tree = buildTree(uniquePaths || []); + let tree = buildTree(foldersStore.uniquePaths); const parts = normalizeTreePath(path || '').split('/'); for (const part of parts) { tree = tree?.[part]; diff --git a/web/src/routes/(user)/people/+page.svelte b/web/src/routes/(user)/people/+page.svelte index 13dac30691..0b51a7e240 100644 --- a/web/src/routes/(user)/people/+page.svelte +++ b/web/src/routes/(user)/people/+page.svelte @@ -2,6 +2,7 @@ import { goto } from '$app/navigation'; import { page } from '$app/stores'; import { focusTrap } from '$lib/actions/focus-trap'; + import { scrollMemory } from '$lib/actions/scroll-memory'; import Button from '$lib/components/elements/buttons/button.svelte'; import LinkButton from '$lib/components/elements/buttons/link-button.svelte'; import Icon from '$lib/components/elements/icon.svelte'; @@ -17,7 +18,7 @@ notificationController, NotificationType, } from '$lib/components/shared-components/notification/notification'; - import { ActionQueryParameterValue, AppRoute, QueryParameter } from '$lib/constants'; + import { ActionQueryParameterValue, AppRoute, QueryParameter, SessionStorageKey } from '$lib/constants'; import { locale } from '$lib/stores/preferences.store'; import { websocketEvents } from '$lib/stores/websocket'; import { handlePromiseError } from '$lib/utils'; @@ -50,6 +51,7 @@ let showSetBirthDateModal = $state(false); let showMergeModal = $state(false); let personName = $state(''); + let currentPage = $state(1); let nextPage = $state(data.people.hasNextPage ? 2 : null); let personMerge1 = $state<PersonResponseDto>(); let personMerge2 = $state<PersonResponseDto>(); @@ -68,6 +70,7 @@ handlePromiseError(searchPeopleElement.searchPeople(true, searchName)); } } + return websocketEvents.on('on_person_thumbnail', (personId: string) => { for (const person of people) { if (person.id === personId) { @@ -77,6 +80,36 @@ }); }); + const loadInitialScroll = () => + new Promise<void>((resolve) => { + // Load up to previously loaded page when returning. + let newNextPage = sessionStorage.getItem(SessionStorageKey.INFINITE_SCROLL_PAGE); + if (newNextPage && nextPage) { + let startingPage = nextPage, + pagesToLoad = Number.parseInt(newNextPage) - nextPage; + + if (pagesToLoad) { + handlePromiseError( + Promise.all( + Array.from({ length: pagesToLoad }).map((_, i) => { + return getAllPeople({ withHidden: true, page: startingPage + i }); + }), + ).then((pages) => { + for (const page of pages) { + people = people.concat(page.people); + } + currentPage = startingPage + pagesToLoad - 1; + nextPage = pages.at(-1)?.hasNextPage ? startingPage + pagesToLoad : null; + resolve(); // wait until extra pages are loaded + }), + ); + } else { + resolve(); + } + sessionStorage.removeItem(SessionStorageKey.INFINITE_SCROLL_PAGE); + } + }); + const loadNextPage = async () => { if (!nextPage) { return; @@ -85,6 +118,9 @@ try { const { people: newPeople, hasNextPage } = await getAllPeople({ withHidden: true, page: nextPage }); people = people.concat(newPeople); + if (nextPage !== null) { + currentPage = nextPage; + } nextPage = hasNextPage ? nextPage + 1 : null; } catch (error) { handleError(error, $t('errors.failed_to_load_people')); @@ -323,6 +359,23 @@ <UserPageLayout title={$t('people')} description={countVisiblePeople === 0 && !searchName ? undefined : `(${countVisiblePeople.toLocaleString($locale)})`} + use={[ + [ + scrollMemory, + { + routeStartsWith: AppRoute.PEOPLE, + beforeSave: () => { + if (currentPage) { + sessionStorage.setItem(SessionStorageKey.INFINITE_SCROLL_PAGE, currentPage.toString()); + } + }, + beforeClear: () => { + sessionStorage.removeItem(SessionStorageKey.INFINITE_SCROLL_PAGE); + }, + beforeLoad: loadInitialScroll, + }, + ], + ]} > {#snippet buttons()} {#if people.length > 0} diff --git a/web/src/routes/(user)/people/[personId]/[[photos=photos]]/[[assetId=id]]/+page.svelte b/web/src/routes/(user)/people/[personId]/[[photos=photos]]/[[assetId=id]]/+page.svelte index d9b7c6a08f..48e194dda4 100644 --- a/web/src/routes/(user)/people/[personId]/[[photos=photos]]/[[assetId=id]]/+page.svelte +++ b/web/src/routes/(user)/people/[personId]/[[photos=photos]]/[[assetId=id]]/+page.svelte @@ -1,6 +1,7 @@ <script lang="ts"> import { afterNavigate, goto } from '$app/navigation'; import { page } from '$app/stores'; + import { scrollMemoryClearer } from '$lib/actions/scroll-memory'; import ImageThumbnail from '$lib/components/assets/thumbnail/image-thumbnail.svelte'; import EditNameInput from '$lib/components/faces-page/edit-name-input.svelte'; import MergeFaceSelector from '$lib/components/faces-page/merge-face-selector.svelte'; @@ -25,7 +26,7 @@ NotificationType, notificationController, } from '$lib/components/shared-components/notification/notification'; - import { AppRoute, PersonPageViewMode, QueryParameter } from '$lib/constants'; + import { AppRoute, PersonPageViewMode, QueryParameter, SessionStorageKey } from '$lib/constants'; import { createAssetInteractionStore } from '$lib/stores/asset-interaction.store'; import { assetViewingStore } from '$lib/stores/asset-viewing.store'; import { AssetStore } from '$lib/stores/assets.store'; @@ -62,14 +63,17 @@ data: PageData; } - let { data = $bindable() }: Props = $props(); + let { data }: Props = $props(); let numberOfAssets = $state(data.statistics.assets); let { isViewing: showAssetViewer } = assetViewingStore; - let assetStore = new AssetStore({ - isArchived: false, - personId: data.person.id, + const assetStoreOptions = { isArchived: false, personId: data.person.id }; + const assetStore = new AssetStore(assetStoreOptions); + + $effect(() => { + assetStoreOptions.personId = data.person.id; + handlePromiseError(assetStore.updateOptions(assetStoreOptions)); }); const assetInteractionStore = createAssetInteractionStore(); @@ -164,7 +168,7 @@ type: NotificationType.Info, }); - await goto(previousRoute, { replaceState: true }); + await goto(previousRoute); } catch (error) { handleError(error, $t('errors.unable_to_hide_person')); } @@ -328,7 +332,6 @@ $effect(() => { if (person) { handlePromiseError(updateAssetCount()); - handlePromiseError(assetStore.updateOptions({ personId: person.id })); } }); @@ -431,7 +434,15 @@ {/if} </header> -<main class="relative h-screen overflow-hidden bg-immich-bg tall:ml-4 pt-[var(--navbar-height)] dark:bg-immich-dark-bg"> +<main + class="relative h-screen overflow-hidden bg-immich-bg tall:ml-4 pt-[var(--navbar-height)] dark:bg-immich-dark-bg" + use:scrollMemoryClearer={{ + routeStartsWith: AppRoute.PEOPLE, + beforeClear: () => { + sessionStorage.removeItem(SessionStorageKey.INFINITE_SCROLL_PAGE); + }, + }} +> {#key person.id} <AssetGrid enableRouting={true} diff --git a/web/tsconfig.json b/web/tsconfig.json index 63e16e7976..31aef23e31 100644 --- a/web/tsconfig.json +++ b/web/tsconfig.json @@ -10,7 +10,7 @@ "skipLibCheck": true, "sourceMap": true, "strict": true, - "target": "es2020", + "target": "es2022", "types": ["vitest/globals"] }, "extends": "./.svelte-kit/tsconfig.json"