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"