1
0
Fork 0
mirror of https://github.com/immich-app/immich.git synced 2025-01-27 22:22:45 +01:00

Merge branch 'main' into feat/theme-customizer

This commit is contained in:
Tim Van Onckelen 2024-11-29 16:30:45 +01:00 committed by GitHub
commit fc43b15821
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
81 changed files with 1234 additions and 642 deletions
docker
docs/docs/developer
e2e/src/api/specs
i18n
mobile
open-api
server
web

View file

@ -125,26 +125,23 @@ services:
ports: ports:
- 5432:5432 - 5432:5432
healthcheck: 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 interval: 5m
start_interval: 30s start_interval: 30s
start_period: 5m start_period: 5m
command: command: >-
[ postgres
'postgres', -c shared_preload_libraries=vectors.so
'-c', -c 'search_path="$$user", public, vectors'
'shared_preload_libraries=vectors.so', -c logging_collector=on
'-c', -c max_wal_size=2GB
'search_path="$$user", public, vectors', -c shared_buffers=512MB
'-c', -c wal_compression=on
'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 # set IMMICH_TELEMETRY_INCLUDE=all in .env to enable metrics
# immich-prometheus: # immich-prometheus:

View file

@ -67,26 +67,23 @@ services:
ports: ports:
- 5432:5432 - 5432:5432
healthcheck: 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 interval: 5m
start_interval: 30s start_interval: 30s
start_period: 5m start_period: 5m
command: command: >-
[ postgres
'postgres', -c shared_preload_libraries=vectors.so
'-c', -c 'search_path="$$user", public, vectors'
'shared_preload_libraries=vectors.so', -c logging_collector=on
'-c', -c max_wal_size=2GB
'search_path="$$user", public, vectors', -c shared_buffers=512MB
'-c', -c wal_compression=on
'logging_collector=on',
'-c',
'max_wal_size=2GB',
'-c',
'shared_buffers=512MB',
'-c',
'wal_compression=on',
]
restart: always restart: always
# set IMMICH_TELEMETRY_INCLUDE=all in .env to enable metrics # set IMMICH_TELEMETRY_INCLUDE=all in .env to enable metrics

View file

@ -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 # 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 - ${DB_DATA_LOCATION}:/var/lib/postgresql/data
healthcheck: 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 interval: 5m
start_interval: 30s start_interval: 30s
start_period: 5m start_period: 5m
command: command: >-
[ postgres
'postgres', -c shared_preload_libraries=vectors.so
'-c', -c 'search_path="$$user", public, vectors'
'shared_preload_libraries=vectors.so', -c logging_collector=on
'-c', -c max_wal_size=2GB
'search_path="$$user", public, vectors', -c shared_buffers=512MB
'-c', -c wal_compression=on
'logging_collector=on',
'-c',
'max_wal_size=2GB',
'-c',
'shared_buffers=512MB',
'-c',
'wal_compression=on',
]
restart: always restart: always
volumes: volumes:

View file

@ -11,6 +11,7 @@ When contributing code through a pull request, please check the following:
- [ ] `npm run lint` (linting via ESLint) - [ ] `npm run lint` (linting via ESLint)
- [ ] `npm run format` (formatting via Prettier) - [ ] `npm run format` (formatting via Prettier)
- [ ] `npm run check:svelte` (Type checking via SvelteKit) - [ ] `npm run check:svelte` (Type checking via SvelteKit)
- [ ] `npm run check:typescript` (check typescript)
- [ ] `npm test` (unit tests) - [ ] `npm test` (unit tests)
## Documentation ## Documentation

View file

@ -133,6 +133,7 @@ describe('/server', () => {
userDeleteDelay: 7, userDeleteDelay: 7,
isInitialized: true, isInitialized: true,
externalDomain: '', externalDomain: '',
publicUsers: true,
isOnboarded: false, isOnboarded: false,
mapDarkStyleUrl: 'https://tiles.immich.cloud/v1/style/dark.json', mapDarkStyleUrl: 'https://tiles.immich.cloud/v1/style/dark.json',
mapLightStyleUrl: 'https://tiles.immich.cloud/v1/style/light.json', mapLightStyleUrl: 'https://tiles.immich.cloud/v1/style/light.json',

View file

@ -1,4 +1,6 @@
{ {
"user_usage_stats": "Account usage statistics",
"user_usage_stats_description": "View account usage statistics",
"about": "Refresh", "about": "Refresh",
"account": "Account", "account": "Account",
"account_settings": "Account Settings", "account_settings": "Account Settings",
@ -222,6 +224,8 @@
"send_welcome_email": "Send welcome email", "send_welcome_email": "Send welcome email",
"server_external_domain_settings": "External domain", "server_external_domain_settings": "External domain",
"server_external_domain_settings_description": "Domain for public shared links, including http(s)://", "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": "Server Settings",
"server_settings_description": "Manage server settings", "server_settings_description": "Manage server settings",
"server_welcome_message": "Welcome message", "server_welcome_message": "Welcome message",
@ -1311,6 +1315,7 @@
"view_all_users": "View all users", "view_all_users": "View all users",
"view_in_timeline": "View in timeline", "view_in_timeline": "View in timeline",
"view_links": "View links", "view_links": "View links",
"view_name": "View",
"view_next_asset": "View next asset", "view_next_asset": "View next asset",
"view_previous_asset": "View previous asset", "view_previous_asset": "View previous asset",
"view_stack": "View Stack", "view_stack": "View Stack",
@ -1324,5 +1329,7 @@
"years_ago": "{years, plural, one {# year} other {# years}} ago", "years_ago": "{years, plural, one {# year} other {# years}} ago",
"yes": "Yes", "yes": "Yes",
"you_dont_have_any_shared_links": "You don't have any shared links", "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"
} }

View file

@ -1,3 +1,3 @@
{ {
"flutter": "3.24.4" "flutter": "3.24.5"
} }

View file

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

View file

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

View file

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

View file

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

View file

@ -0,0 +1,30 @@
class LoginResponse {
final String accessToken;
final bool isAdmin;
final String name;
final String profileImagePath;
final bool shouldChangePassword;
final String userEmail;
final String userId;
LoginResponse({
required this.accessToken,
required this.isAdmin,
required this.name,
required this.profileImagePath,
required this.shouldChangePassword,
required this.userEmail,
required this.userId,
});
@override
String toString() {
return 'LoginResponse[accessToken=$accessToken, isAdmin=$isAdmin, name=$name, profileImagePath=$profileImagePath, shouldChangePassword=$shouldChangePassword, userEmail=$userEmail, userId=$userId]';
}
}

View file

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

View file

@ -15,7 +15,7 @@ import 'package:immich_mobile/utils/immich_loading_overlay.dart';
import 'package:immich_mobile/widgets/album/album_action_filled_button.dart'; import 'package:immich_mobile/widgets/album/album_action_filled_button.dart';
import 'package:immich_mobile/widgets/album/album_viewer_editable_title.dart'; import 'package:immich_mobile/widgets/album/album_viewer_editable_title.dart';
import 'package:immich_mobile/providers/multiselect.provider.dart'; import 'package:immich_mobile/providers/multiselect.provider.dart';
import 'package:immich_mobile/providers/authentication.provider.dart'; import 'package:immich_mobile/providers/auth.provider.dart';
import 'package:immich_mobile/widgets/album/album_viewer_appbar.dart'; import 'package:immich_mobile/widgets/album/album_viewer_appbar.dart';
import 'package:immich_mobile/routing/router.dart'; import 'package:immich_mobile/routing/router.dart';
import 'package:immich_mobile/entities/album.entity.dart'; import 'package:immich_mobile/entities/album.entity.dart';
@ -42,7 +42,7 @@ class AlbumViewerPage extends HookConsumerWidget {
() => ref.read(currentAlbumProvider.notifier).set(value), () => ref.read(currentAlbumProvider.notifier).set(value),
), ),
); );
final userId = ref.watch(authenticationProvider).userId; final userId = ref.watch(authProvider).userId;
final isProcessing = useProcessingOverlay(); final isProcessing = useProcessingOverlay();
Future<bool> onRemoveFromAlbumPressed(Iterable<Asset> assets) async { Future<bool> onRemoveFromAlbumPressed(Iterable<Asset> assets) async {

View file

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

View file

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

View file

@ -0,0 +1,164 @@
import 'package:flutter/foundation.dart';
import 'package:flutter_udid/flutter_udid.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/entities/store.entity.dart';
import 'package:immich_mobile/models/auth/login_response.model.dart';
import 'package:immich_mobile/models/auth/auth_state.model.dart';
import 'package:immich_mobile/entities/user.entity.dart';
import 'package:immich_mobile/providers/api.provider.dart';
import 'package:immich_mobile/services/api.service.dart';
import 'package:immich_mobile/services/auth.service.dart';
import 'package:immich_mobile/utils/hash.dart';
import 'package:logging/logging.dart';
import 'package:openapi/api.dart';
final authProvider = StateNotifierProvider<AuthNotifier, AuthState>((ref) {
return AuthNotifier(
ref.watch(authServiceProvider),
ref.watch(apiServiceProvider),
);
});
class AuthNotifier extends StateNotifier<AuthState> {
final AuthService _authService;
final ApiService _apiService;
final _log = Logger("AuthenticationNotifier");
static const Duration _timeoutDuration = Duration(seconds: 7);
AuthNotifier(
this._authService,
this._apiService,
) : super(
AuthState(
deviceId: "",
userId: "",
userEmail: "",
name: '',
profileImagePath: '',
isAdmin: false,
isAuthenticated: false,
),
);
Future<String> validateServerUrl(String url) {
return _authService.validateServerUrl(url);
}
Future<LoginResponse> login(String email, String password) async {
final response = await _authService.login(email, password);
await saveAuthInfo(accessToken: response.accessToken);
return response;
}
Future<void> logout() async {
try {
await _authService.logout();
} finally {
await _cleanUp();
}
}
Future<void> _cleanUp() async {
state = AuthState(
deviceId: "",
userId: "",
userEmail: "",
name: '',
profileImagePath: '',
isAdmin: false,
isAuthenticated: false,
);
}
void updateUserProfileImagePath(String path) {
state = state.copyWith(profileImagePath: path);
}
Future<bool> changePassword(String newPassword) async {
try {
await _authService.changePassword(newPassword);
return true;
} catch (_) {
return false;
}
}
Future<bool> saveAuthInfo({
required String accessToken,
}) async {
_apiService.setAccessToken(accessToken);
// Get the deviceid from the store if it exists, otherwise generate a new one
String deviceId =
Store.tryGet(StoreKey.deviceId) ?? await FlutterUdid.consistentUdid;
User? user = Store.tryGet(StoreKey.currentUser);
UserAdminResponseDto? userResponse;
UserPreferencesResponseDto? userPreferences;
try {
final responses = await Future.wait([
_apiService.usersApi.getMyUser().timeout(_timeoutDuration),
_apiService.usersApi.getMyPreferences().timeout(_timeoutDuration),
]);
userResponse = responses[0] as UserAdminResponseDto;
userPreferences = responses[1] as UserPreferencesResponseDto;
} on ApiException catch (error, stackTrace) {
if (error.code == 401) {
_log.severe("Unauthorized access, token likely expired. Logging out.");
return false;
}
_log.severe(
"Error getting user information from the server [API EXCEPTION]",
stackTrace,
);
} catch (error, stackTrace) {
_log.severe(
"Error getting user information from the server [CATCH ALL]",
error,
stackTrace,
);
if (kDebugMode) {
debugPrint(
"Error getting user information from the server [CATCH ALL] $error $stackTrace",
);
}
}
// If the user information is successfully retrieved, update the store
// Due to the flow of the code, this will always happen on first login
if (userResponse != null) {
Store.put(StoreKey.deviceId, deviceId);
Store.put(StoreKey.deviceIdHash, fastHash(deviceId));
Store.put(
StoreKey.currentUser,
User.fromUserDto(userResponse, userPreferences),
);
Store.put(StoreKey.accessToken, accessToken);
user = User.fromUserDto(userResponse, userPreferences);
} else {
_log.severe("Unable to get user information from the server.");
}
// If the user is null, the login was not successful
// and we don't have a local copy of the user from a prior successful login
if (user == null) {
return false;
}
state = state.copyWith(
isAuthenticated: true,
userId: user.id,
userEmail: user.email,
name: user.name,
profileImagePath: user.profileImagePath,
isAdmin: user.isAdmin,
deviceId: deviceId,
);
return true;
}
}

View file

@ -1,245 +0,0 @@
import 'dart:io';
import 'package:device_info_plus/device_info_plus.dart';
import 'package:flutter/material.dart';
import 'package:flutter_udid/flutter_udid.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/providers/album/album.provider.dart';
import 'package:immich_mobile/entities/store.entity.dart';
import 'package:immich_mobile/models/authentication/authentication_state.model.dart';
import 'package:immich_mobile/entities/user.entity.dart';
import 'package:immich_mobile/providers/api.provider.dart';
import 'package:immich_mobile/providers/db.provider.dart';
import 'package:immich_mobile/services/api.service.dart';
import 'package:immich_mobile/utils/db.dart';
import 'package:immich_mobile/utils/hash.dart';
import 'package:isar/isar.dart';
import 'package:logging/logging.dart';
import 'package:openapi/api.dart';
class AuthenticationNotifier extends StateNotifier<AuthenticationState> {
AuthenticationNotifier(
this._apiService,
this._db,
this._ref,
) : super(
AuthenticationState(
deviceId: "",
userId: "",
userEmail: "",
name: '',
profileImagePath: '',
isAdmin: false,
shouldChangePassword: false,
isAuthenticated: false,
),
);
final ApiService _apiService;
final Isar _db;
final StateNotifierProviderRef<AuthenticationNotifier, AuthenticationState>
_ref;
final _log = Logger("AuthenticationNotifier");
static const Duration _timeoutDuration = Duration(seconds: 7);
Future<bool> login(
String email,
String password,
String serverUrl,
) async {
try {
// Resolve API server endpoint from user provided serverUrl
await _apiService.resolveAndSetEndpoint(serverUrl);
await _apiService.serverInfoApi.pingServer();
} catch (e) {
debugPrint('Invalid Server Endpoint Url $e');
return false;
}
// Make sign-in request
DeviceInfoPlugin deviceInfoPlugin = DeviceInfoPlugin();
if (Platform.isIOS) {
var iosInfo = await deviceInfoPlugin.iosInfo;
_apiService.authenticationApi.apiClient
.addDefaultHeader('deviceModel', iosInfo.utsname.machine);
_apiService.authenticationApi.apiClient
.addDefaultHeader('deviceType', 'iOS');
} else {
var androidInfo = await deviceInfoPlugin.androidInfo;
_apiService.authenticationApi.apiClient
.addDefaultHeader('deviceModel', androidInfo.model);
_apiService.authenticationApi.apiClient
.addDefaultHeader('deviceType', 'Android');
}
try {
var loginResponse = await _apiService.authenticationApi.login(
LoginCredentialDto(
email: email,
password: password,
),
);
if (loginResponse == null) {
debugPrint('Login Response is null');
return false;
}
return setSuccessLoginInfo(
accessToken: loginResponse.accessToken,
serverUrl: serverUrl,
);
} catch (e) {
debugPrint("Error logging in $e");
return false;
}
}
Future<void> logout() async {
var log = Logger('AuthenticationNotifier');
try {
String? userEmail = Store.tryGet(StoreKey.currentUser)?.email;
await _apiService.authenticationApi
.logout()
.timeout(_timeoutDuration)
.then((_) => log.info("Logout was successful for $userEmail"))
.onError(
(error, stackTrace) =>
log.severe("Logout failed for $userEmail", error, stackTrace),
);
} catch (e, stack) {
log.severe('Logout failed', e, stack);
} finally {
await Future.wait([
clearAssetsAndAlbums(_db),
Store.delete(StoreKey.currentUser),
Store.delete(StoreKey.accessToken),
]);
_ref.invalidate(albumProvider);
state = state.copyWith(
deviceId: "",
userId: "",
userEmail: "",
name: '',
profileImagePath: '',
isAdmin: false,
shouldChangePassword: false,
isAuthenticated: false,
);
}
}
updateUserProfileImagePath(String path) {
state = state.copyWith(profileImagePath: path);
}
Future<bool> changePassword(String newPassword) async {
try {
await _apiService.usersApi.updateMyUser(
UserUpdateMeDto(
password: newPassword,
),
);
state = state.copyWith(shouldChangePassword: false);
return true;
} catch (e) {
debugPrint("Error changing password $e");
return false;
}
}
Future<bool> setSuccessLoginInfo({
required String accessToken,
required String serverUrl,
}) async {
_apiService.setAccessToken(accessToken);
// Get the deviceid from the store if it exists, otherwise generate a new one
String deviceId =
Store.tryGet(StoreKey.deviceId) ?? await FlutterUdid.consistentUdid;
bool shouldChangePassword = false;
User? user = Store.tryGet(StoreKey.currentUser);
UserAdminResponseDto? userResponse;
UserPreferencesResponseDto? userPreferences;
try {
final responses = await Future.wait([
_apiService.usersApi.getMyUser().timeout(_timeoutDuration),
_apiService.usersApi.getMyPreferences().timeout(_timeoutDuration),
]);
userResponse = responses[0] as UserAdminResponseDto;
userPreferences = responses[1] as UserPreferencesResponseDto;
} on ApiException catch (error, stackTrace) {
if (error.code == 401) {
_log.severe("Unauthorized access, token likely expired. Logging out.");
return false;
}
_log.severe(
"Error getting user information from the server [API EXCEPTION]",
stackTrace,
);
} catch (error, stackTrace) {
_log.severe(
"Error getting user information from the server [CATCH ALL]",
error,
stackTrace,
);
debugPrint(
"Error getting user information from the server [CATCH ALL] $error $stackTrace",
);
}
// If the user information is successfully retrieved, update the store
// Due to the flow of the code, this will always happen on first login
if (userResponse != null) {
Store.put(StoreKey.deviceId, deviceId);
Store.put(StoreKey.deviceIdHash, fastHash(deviceId));
Store.put(
StoreKey.currentUser,
User.fromUserDto(userResponse, userPreferences),
);
Store.put(StoreKey.serverUrl, serverUrl);
Store.put(StoreKey.accessToken, accessToken);
shouldChangePassword = userResponse.shouldChangePassword;
user = User.fromUserDto(userResponse, userPreferences);
} else {
_log.severe("Unable to get user information from the server.");
}
// If the user is null, the login was not successful
// and we don't have a local copy of the user from a prior successful login
if (user == null) {
return false;
}
state = state.copyWith(
isAuthenticated: true,
userId: user.id,
userEmail: user.email,
name: user.name,
profileImagePath: user.profileImagePath,
isAdmin: user.isAdmin,
shouldChangePassword: shouldChangePassword,
deviceId: deviceId,
);
return true;
}
}
final authenticationProvider =
StateNotifierProvider<AuthenticationNotifier, AuthenticationState>((ref) {
return AuthenticationNotifier(
ref.watch(apiServiceProvider),
ref.watch(dbProvider),
ref,
);
});

View file

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

View file

@ -35,7 +35,7 @@ class BackupVerification extends _$BackupVerification {
return; return;
} }
final connection = await Connectivity().checkConnectivity(); final connection = await Connectivity().checkConnectivity();
if (connection.contains(ConnectivityResult.wifi)) { if (!connection.contains(ConnectivityResult.wifi)) {
if (context.mounted) { if (context.mounted) {
ImmichToast.show( ImmichToast.show(
context: context, context: context,

View file

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

View file

@ -0,0 +1,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(),
]);
});
}
}

View file

@ -0,0 +1,56 @@
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/interfaces/auth_api.interface.dart';
import 'package:immich_mobile/models/auth/login_response.model.dart';
import 'package:immich_mobile/providers/api.provider.dart';
import 'package:immich_mobile/repositories/api.repository.dart';
import 'package:immich_mobile/services/api.service.dart';
import 'package:openapi/api.dart';
final authApiRepositoryProvider =
Provider((ref) => AuthApiRepository(ref.watch(apiServiceProvider)));
class AuthApiRepository extends ApiRepository implements IAuthApiRepository {
final ApiService _apiService;
AuthApiRepository(this._apiService);
@override
Future<void> changePassword(String newPassword) async {
await _apiService.usersApi.updateMyUser(
UserUpdateMeDto(
password: newPassword,
),
);
}
@override
Future<LoginResponse> login(String email, String password) async {
final loginResponseDto = await checkNull(
_apiService.authenticationApi.login(
LoginCredentialDto(
email: email,
password: password,
),
),
);
return _mapLoginReponse(loginResponseDto);
}
@override
Future<void> logout() async {
await _apiService.authenticationApi.logout().timeout(Duration(seconds: 7));
}
_mapLoginReponse(LoginResponseDto dto) {
return LoginResponse(
accessToken: dto.accessToken,
isAdmin: dto.isAdmin,
name: dto.name,
profileImagePath: dto.profileImagePath,
shouldChangePassword: dto.shouldChangePassword,
userEmail: dto.userEmail,
userId: dto.userId,
);
}
}

View file

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

View file

@ -2,6 +2,7 @@ import 'dart:async';
import 'dart:convert'; import 'dart:convert';
import 'dart:io'; import 'dart:io';
import 'package:device_info_plus/device_info_plus.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:immich_mobile/entities/store.entity.dart'; import 'package:immich_mobile/entities/store.entity.dart';
import 'package:immich_mobile/utils/url_helper.dart'; import 'package:immich_mobile/utils/url_helper.dart';
@ -69,7 +70,7 @@ class ApiService implements Authentication {
final endpoint = await _resolveEndpoint(serverUrl); final endpoint = await _resolveEndpoint(serverUrl);
setEndpoint(endpoint); setEndpoint(endpoint);
// Save in hivebox for next startup // Save in local database for next startup
Store.put(StoreKey.serverEndpoint, endpoint); Store.put(StoreKey.serverEndpoint, endpoint);
return endpoint; return endpoint;
} }
@ -148,11 +149,27 @@ class ApiService implements Authentication {
return ""; return "";
} }
setAccessToken(String accessToken) { void setAccessToken(String accessToken) {
_accessToken = accessToken; _accessToken = accessToken;
Store.put(StoreKey.accessToken, accessToken); Store.put(StoreKey.accessToken, accessToken);
} }
Future<void> setDeviceInfoHeader() async {
DeviceInfoPlugin deviceInfoPlugin = DeviceInfoPlugin();
if (Platform.isIOS) {
final iosInfo = await deviceInfoPlugin.iosInfo;
authenticationApi.apiClient
.addDefaultHeader('deviceModel', iosInfo.utsname.machine);
authenticationApi.apiClient.addDefaultHeader('deviceType', 'iOS');
} else {
final androidInfo = await deviceInfoPlugin.androidInfo;
authenticationApi.apiClient
.addDefaultHeader('deviceModel', androidInfo.model);
authenticationApi.apiClient.addDefaultHeader('deviceType', 'Android');
}
}
static Map<String, String> getRequestHeaders() { static Map<String, String> getRequestHeaders() {
var accessToken = Store.get(StoreKey.accessToken, ""); var accessToken = Store.get(StoreKey.accessToken, "");
var customHeadersStr = Store.get(StoreKey.customHeaders, ""); var customHeadersStr = Store.get(StoreKey.customHeaders, "");

View file

@ -0,0 +1,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;
}
}
}

View file

@ -0,0 +1,24 @@
import 'package:flutter_udid/flutter_udid.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/entities/store.entity.dart';
final deviceServiceProvider = Provider((ref) => DeviceService());
class DeviceService {
DeviceService();
createDeviceId() {
return FlutterUdid.consistentUdid;
}
/// Returns the device ID from local storage or creates a new one if not found.
///
/// This method first attempts to retrieve the device ID from the local store using
/// [StoreKey.deviceId]. If no device ID is found (returns null), it generates a
/// new device ID by calling [createDeviceId].
///
/// Returns a [String] representing the device's unique identifier.
String getDeviceId() {
return Store.tryGet(StoreKey.deviceId) ?? createDeviceId();
}
}

View file

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

View file

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

View file

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

View file

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

View file

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

Binary file not shown.

View file

@ -1862,4 +1862,4 @@ packages:
version: "3.1.2" version: "3.1.2"
sdks: sdks:
dart: ">=3.5.3 <4.0.0" dart: ">=3.5.3 <4.0.0"
flutter: ">=3.24.4" flutter: ">=3.24.5"

View file

@ -6,7 +6,7 @@ version: 1.121.0+168
environment: environment:
sdk: '>=3.3.0 <4.0.0' 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 isar_version: &isar_version 3.1.8 # define the version to be used

View file

@ -3,6 +3,8 @@ import 'package:immich_mobile/interfaces/album_api.interface.dart';
import 'package:immich_mobile/interfaces/album_media.interface.dart'; import 'package:immich_mobile/interfaces/album_media.interface.dart';
import 'package:immich_mobile/interfaces/asset.interface.dart'; import 'package:immich_mobile/interfaces/asset.interface.dart';
import 'package:immich_mobile/interfaces/asset_media.interface.dart'; import 'package:immich_mobile/interfaces/asset_media.interface.dart';
import 'package:immich_mobile/interfaces/auth.interface.dart';
import 'package:immich_mobile/interfaces/auth_api.interface.dart';
import 'package:immich_mobile/interfaces/backup.interface.dart'; import 'package:immich_mobile/interfaces/backup.interface.dart';
import 'package:immich_mobile/interfaces/etag.interface.dart'; import 'package:immich_mobile/interfaces/etag.interface.dart';
import 'package:immich_mobile/interfaces/exif_info.interface.dart'; import 'package:immich_mobile/interfaces/exif_info.interface.dart';
@ -29,3 +31,7 @@ class MockAssetMediaRepository extends Mock implements IAssetMediaRepository {}
class MockFileMediaRepository extends Mock implements IFileMediaRepository {} class MockFileMediaRepository extends Mock implements IFileMediaRepository {}
class MockAlbumApiRepository extends Mock implements IAlbumApiRepository {} class MockAlbumApiRepository extends Mock implements IAlbumApiRepository {}
class MockAuthApiRepository extends Mock implements IAuthApiRepository {}
class MockAuthRepository extends Mock implements IAuthRepository {}

View file

@ -0,0 +1,118 @@
import 'package:flutter/widgets.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:immich_mobile/entities/store.entity.dart';
import 'package:immich_mobile/services/auth.service.dart';
import 'package:mocktail/mocktail.dart';
import '../repository.mocks.dart';
import '../service.mocks.dart';
import '../test_utils.dart';
void main() {
late AuthService sut;
late MockAuthApiRepository authApiRepository;
late MockAuthRepository authRepository;
late MockApiService apiService;
setUp(() async {
authApiRepository = MockAuthApiRepository();
authRepository = MockAuthRepository();
apiService = MockApiService();
sut = AuthService(authApiRepository, authRepository, apiService);
});
group('validateServerUrl', () {
setUpAll(() async {
WidgetsFlutterBinding.ensureInitialized();
final db = await TestUtils.initIsar();
db.writeTxnSync(() => db.clearSync());
Store.init(db);
});
test('Should resolve HTTP endpoint', () async {
const testUrl = 'http://ip:2283';
const resolvedUrl = 'http://ip:2283/api';
when(() => apiService.resolveAndSetEndpoint(testUrl))
.thenAnswer((_) async => resolvedUrl);
when(() => apiService.setDeviceInfoHeader()).thenAnswer((_) async => {});
final result = await sut.validateServerUrl(testUrl);
expect(result, resolvedUrl);
verify(() => apiService.resolveAndSetEndpoint(testUrl)).called(1);
verify(() => apiService.setDeviceInfoHeader()).called(1);
});
test('Should resolve HTTPS endpoint', () async {
const testUrl = 'https://immich.domain.com';
const resolvedUrl = 'https://immich.domain.com/api';
when(() => apiService.resolveAndSetEndpoint(testUrl))
.thenAnswer((_) async => resolvedUrl);
when(() => apiService.setDeviceInfoHeader()).thenAnswer((_) async => {});
final result = await sut.validateServerUrl(testUrl);
expect(result, resolvedUrl);
verify(() => apiService.resolveAndSetEndpoint(testUrl)).called(1);
verify(() => apiService.setDeviceInfoHeader()).called(1);
});
test('Should throw error on invalid URL', () async {
const testUrl = 'invalid-url';
when(() => apiService.resolveAndSetEndpoint(testUrl))
.thenThrow(Exception('Invalid URL'));
expect(
() async => await sut.validateServerUrl(testUrl),
throwsA(isA<Exception>()),
);
verify(() => apiService.resolveAndSetEndpoint(testUrl)).called(1);
verifyNever(() => apiService.setDeviceInfoHeader());
});
test('Should throw error on unreachable server', () async {
const testUrl = 'https://unreachable.server';
when(() => apiService.resolveAndSetEndpoint(testUrl))
.thenThrow(Exception('Server is not reachable'));
expect(
() async => await sut.validateServerUrl(testUrl),
throwsA(isA<Exception>()),
);
verify(() => apiService.resolveAndSetEndpoint(testUrl)).called(1);
verifyNever(() => apiService.setDeviceInfoHeader());
});
});
group('logout', () {
test('Should logout user', () async {
when(() => authApiRepository.logout()).thenAnswer((_) async => {});
when(() => authRepository.clearLocalData())
.thenAnswer((_) => Future.value(null));
await sut.logout();
verify(() => authApiRepository.logout()).called(1);
verify(() => authRepository.clearLocalData()).called(1);
});
test('Should clear local data even on server error', () async {
when(() => authApiRepository.logout())
.thenThrow(Exception('Server error'));
when(() => authRepository.clearLocalData())
.thenAnswer((_) => Future.value(null));
await sut.logout();
verify(() => authApiRepository.logout()).called(1);
verify(() => authRepository.clearLocalData()).called(1);
});
});
}

View file

@ -10825,6 +10825,9 @@
"oauthButtonText": { "oauthButtonText": {
"type": "string" "type": "string"
}, },
"publicUsers": {
"type": "boolean"
},
"trashDays": { "trashDays": {
"type": "integer" "type": "integer"
}, },
@ -10840,6 +10843,7 @@
"mapDarkStyleUrl", "mapDarkStyleUrl",
"mapLightStyleUrl", "mapLightStyleUrl",
"oauthButtonText", "oauthButtonText",
"publicUsers",
"trashDays", "trashDays",
"userDeleteDelay" "userDeleteDelay"
], ],
@ -12018,11 +12022,15 @@
}, },
"loginPageMessage": { "loginPageMessage": {
"type": "string" "type": "string"
},
"publicUsers": {
"type": "boolean"
} }
}, },
"required": [ "required": [
"externalDomain", "externalDomain",
"loginPageMessage" "loginPageMessage",
"publicUsers"
], ],
"type": "object" "type": "object"
}, },

View file

@ -928,6 +928,7 @@ export type ServerConfigDto = {
mapDarkStyleUrl: string; mapDarkStyleUrl: string;
mapLightStyleUrl: string; mapLightStyleUrl: string;
oauthButtonText: string; oauthButtonText: string;
publicUsers: boolean;
trashDays: number; trashDays: number;
userDeleteDelay: number; userDeleteDelay: number;
}; };
@ -1236,6 +1237,7 @@ export type SystemConfigReverseGeocodingDto = {
export type SystemConfigServerDto = { export type SystemConfigServerDto = {
externalDomain: string; externalDomain: string;
loginPageMessage: string; loginPageMessage: string;
publicUsers: boolean;
}; };
export type SystemConfigStorageTemplateDto = { export type SystemConfigStorageTemplateDto = {
enabled: boolean; enabled: boolean;

View file

@ -151,6 +151,7 @@ export interface SystemConfig {
server: { server: {
externalDomain: string; externalDomain: string;
loginPageMessage: string; loginPageMessage: string;
publicUsers: boolean;
}; };
user: { user: {
deleteDelay: number; deleteDelay: number;
@ -310,6 +311,7 @@ export const defaults = Object.freeze<SystemConfig>({
server: { server: {
externalDomain: '', externalDomain: '',
loginPageMessage: '', loginPageMessage: '',
publicUsers: true,
}, },
notifications: { notifications: {
smtp: { smtp: {

View file

@ -39,8 +39,8 @@ export class UserController {
@Get() @Get()
@Authenticated() @Authenticated()
searchUsers(): Promise<UserResponseDto[]> { searchUsers(@Auth() auth: AuthDto): Promise<UserResponseDto[]> {
return this.service.search(); return this.service.search(auth);
} }
@Get('me') @Get('me')

View file

@ -144,6 +144,7 @@ export class ServerConfigDto {
isInitialized!: boolean; isInitialized!: boolean;
isOnboarded!: boolean; isOnboarded!: boolean;
externalDomain!: string; externalDomain!: string;
publicUsers!: boolean;
mapDarkStyleUrl!: string; mapDarkStyleUrl!: string;
mapLightStyleUrl!: string; mapLightStyleUrl!: string;
} }

View file

@ -404,6 +404,9 @@ class SystemConfigServerDto {
@IsString() @IsString()
loginPageMessage!: string; loginPageMessage!: string;
@IsBoolean()
publicUsers!: boolean;
} }
class SystemConfigSmtpTransportDto { class SystemConfigSmtpTransportDto {

View file

@ -169,6 +169,7 @@ describe(ServerService.name, () => {
isInitialized: undefined, isInitialized: undefined,
isOnboarded: false, isOnboarded: false,
externalDomain: '', externalDomain: '',
publicUsers: true,
mapDarkStyleUrl: 'https://tiles.immich.cloud/v1/style/dark.json', mapDarkStyleUrl: 'https://tiles.immich.cloud/v1/style/dark.json',
mapLightStyleUrl: 'https://tiles.immich.cloud/v1/style/light.json', mapLightStyleUrl: 'https://tiles.immich.cloud/v1/style/light.json',
}); });

View file

@ -110,6 +110,7 @@ export class ServerService extends BaseService {
isInitialized, isInitialized,
isOnboarded: onboarding?.isOnboarded || false, isOnboarded: onboarding?.isOnboarded || false,
externalDomain: config.server.externalDomain, externalDomain: config.server.externalDomain,
publicUsers: config.server.publicUsers,
mapDarkStyleUrl: config.map.darkStyle, mapDarkStyleUrl: config.map.darkStyle,
mapLightStyleUrl: config.map.lightStyle, mapLightStyleUrl: config.map.lightStyle,
}; };

View file

@ -133,6 +133,7 @@ const updatedConfig = Object.freeze<SystemConfig>({
server: { server: {
externalDomain: '', externalDomain: '',
loginPageMessage: '', loginPageMessage: '',
publicUsers: true,
}, },
storageTemplate: { storageTemplate: {
enabled: false, enabled: false,

View file

@ -38,9 +38,9 @@ describe(UserService.name, () => {
}); });
describe('getAll', () => { describe('getAll', () => {
it('should get all users', async () => { it('admin should get all users', async () => {
userMock.getList.mockResolvedValue([userStub.admin]); userMock.getList.mockResolvedValue([userStub.admin]);
await expect(sut.search()).resolves.toEqual([ await expect(sut.search(authStub.admin)).resolves.toEqual([
expect.objectContaining({ expect.objectContaining({
id: authStub.admin.user.id, id: authStub.admin.user.id,
email: authStub.admin.user.email, email: authStub.admin.user.email,
@ -48,6 +48,29 @@ describe(UserService.name, () => {
]); ]);
expect(userMock.getList).toHaveBeenCalledWith({ withDeleted: false }); 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', () => { describe('get', () => {

View file

@ -19,8 +19,14 @@ import { getPreferences, getPreferencesPartial, mergePreferences } from 'src/uti
@Injectable() @Injectable()
export class UserService extends BaseService { export class UserService extends BaseService {
async search(): Promise<UserResponseDto[]> { async search(auth: AuthDto): Promise<UserResponseDto[]> {
const users = await this.userRepository.getList({ withDeleted: false }); 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)); return users.map((user) => mapUser(user));
} }

View file

@ -117,4 +117,9 @@ export const systemConfigStub = {
}, },
}, },
}, },
publicUsersDisabled: {
server: {
publicUsers: false,
},
},
} satisfies Record<string, DeepPartial<SystemConfig>>; } satisfies Record<string, DeepPartial<SystemConfig>>;

2
web/src/app.d.ts vendored
View file

@ -28,7 +28,7 @@ interface Element {
requestFullscreen?(options?: FullscreenOptions): Promise<void>; requestFullscreen?(options?: FullscreenOptions): Promise<void>;
} }
import type en from '$lib/en.json'; import type en from '$i18n/en.json';
import 'svelte-i18n'; import 'svelte-i18n';
type NestedKeys<T, K = keyof T> = K extends keyof T & string type NestedKeys<T, K = keyof T> = K extends keyof T & string

View file

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

View file

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

View file

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

View file

@ -5,6 +5,7 @@
import type { SettingsResetEvent, SettingsSaveEvent } from '../admin-settings'; import type { SettingsResetEvent, SettingsSaveEvent } from '../admin-settings';
import SettingInputField from '$lib/components/shared-components/settings/setting-input-field.svelte'; 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 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 { t } from 'svelte-i18n';
import { SettingInputFieldType } from '$lib/constants'; import { SettingInputFieldType } from '$lib/constants';
@ -44,6 +45,13 @@
isEdited={config.server.loginPageMessage !== savedConfig.server.loginPageMessage} 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"> <div class="ml-4">
<SettingButtonsRow <SettingButtonsRow
onReset={(options) => onReset({ ...options, configKeys: ['server'] })} onReset={(options) => onReset({ ...options, configKeys: ['server'] })}

View file

@ -61,7 +61,6 @@
label={$t('admin.theme_custom_css_settings')} label={$t('admin.theme_custom_css_settings')}
description={$t('admin.theme_custom_css_settings_description')} description={$t('admin.theme_custom_css_settings_description')}
bind:value={config.theme.customCss} bind:value={config.theme.customCss}
required={true}
isEdited={config.theme.customCss !== savedConfig.theme.customCss} isEdited={config.theme.customCss !== savedConfig.theme.customCss}
/> />

View file

@ -394,6 +394,7 @@
let isFullScreen = $derived(fullscreenElement !== null); let isFullScreen = $derived(fullscreenElement !== null);
$effect(() => { $effect(() => {
if (asset) { if (asset) {
previewStackedAsset = undefined;
handlePromiseError(refreshStack()); handlePromiseError(refreshStack());
} }
}); });

View file

@ -7,6 +7,7 @@
import NavigationBar from '../shared-components/navigation-bar/navigation-bar.svelte'; import NavigationBar from '../shared-components/navigation-bar/navigation-bar.svelte';
import SideBar from '../shared-components/side-bar/side-bar.svelte'; import SideBar from '../shared-components/side-bar/side-bar.svelte';
import AdminSideBar from '../shared-components/side-bar/admin-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'; import type { Snippet } from 'svelte';
interface Props { interface Props {
@ -16,6 +17,7 @@
description?: string | undefined; description?: string | undefined;
scrollbar?: boolean; scrollbar?: boolean;
admin?: boolean; admin?: boolean;
use?: ActionArray;
header?: Snippet; header?: Snippet;
sidebar?: Snippet; sidebar?: Snippet;
buttons?: Snippet; buttons?: Snippet;
@ -29,6 +31,7 @@
description = undefined, description = undefined,
scrollbar = true, scrollbar = true,
admin = false, admin = false,
use = [],
header, header,
sidebar, sidebar,
buttons, buttons,
@ -73,7 +76,7 @@
</div> </div>
{/if} {/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?.()} {@render children?.()}
</div> </div>
</section> </section>

View file

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

View file

@ -1,4 +1,5 @@
import { getIntersectionObserverMock } from '$lib/__mocks__/intersection-observer.mock'; 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 { fireEvent, render, screen } from '@testing-library/svelte';
import { DateTime } from 'luxon'; import { DateTime } from 'luxon';
import ChangeDate from './change-date.svelte'; import ChangeDate from './change-date.svelte';
@ -16,16 +17,7 @@ describe('ChangeDate component', () => {
beforeEach(() => { beforeEach(() => {
vi.stubGlobal('IntersectionObserver', getIntersectionObserverMock()); vi.stubGlobal('IntersectionObserver', getIntersectionObserverMock());
vi.stubGlobal('visualViewport', getVisualViewportMock());
vi.stubGlobal('visualViewport', {
height: window.innerHeight,
width: window.innerWidth,
scale: 1,
offsetLeft: 0,
offsetTop: 0,
addEventListener: vi.fn(),
removeEventListener: vi.fn(),
});
}); });
afterEach(() => { afterEach(() => {

View file

@ -218,7 +218,7 @@
const getInputPosition = () => input?.getBoundingClientRect(); const getInputPosition = () => input?.getBoundingClientRect();
$effect(() => { $effect(() => {
// searchQuery = selectedOption ? selectedOption.label : ''; searchQuery = selectedOption ? selectedOption.label : '';
}); });
let filteredOptions = $derived( let filteredOptions = $derived(

View file

@ -96,13 +96,25 @@
}); });
}; };
const readEntriesAsync = (reader: FileSystemDirectoryReader) => {
return new Promise<FileSystemEntry[]>((resolve, reject) => {
reader.readEntries(resolve, reject);
});
};
const getContentsFromFileSystemDirectoryEntry = async ( const getContentsFromFileSystemDirectoryEntry = async (
fileSystemDirectoryEntry: FileSystemDirectoryEntry, fileSystemDirectoryEntry: FileSystemDirectoryEntry,
): Promise<FileSystemEntry[]> => { ): Promise<FileSystemEntry[]> => {
return new Promise((resolve, reject) => { const reader = fileSystemDirectoryEntry.createReader();
const reader = fileSystemDirectoryEntry.createReader(); const files: FileSystemEntry[] = [];
reader.readEntries(resolve, reject); let entries: FileSystemEntry[];
});
do {
entries = await readEntriesAsync(reader);
files.push(...entries);
} while (entries.length > 0);
return files;
}; };
const handleFiles = async (files?: FileList | File[]) => { const handleFiles = async (files?: FileList | File[]) => {

View file

@ -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}

View file

@ -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}

View file

@ -1,10 +1,7 @@
<script lang="ts"> <script lang="ts">
import { fade } from 'svelte/transition';
import Icon from '$lib/components/elements/icon.svelte'; import Icon from '$lib/components/elements/icon.svelte';
import { mdiInformationOutline } from '@mdi/js';
import { resolveRoute } from '$app/paths'; import { resolveRoute } from '$app/paths';
import { page } from '$app/stores'; import { page } from '$app/stores';
import type { Snippet } from 'svelte';
interface Props { interface Props {
title: string; title: string;
@ -13,7 +10,6 @@
flippedLogo?: boolean; flippedLogo?: boolean;
isSelected?: boolean; isSelected?: boolean;
preloadData?: boolean; preloadData?: boolean;
moreInformation?: Snippet;
} }
let { let {
@ -23,10 +19,8 @@
flippedLogo = false, flippedLogo = false,
isSelected = $bindable(false), isSelected = $bindable(false),
preloadData = true, preloadData = true,
moreInformation,
}: Props = $props(); }: Props = $props();
let showMoreInformation = $state(false);
let routePath = $derived(resolveRoute(routeId, {})); let routePath = $derived(resolveRoute(routeId, {}));
$effect(() => { $effect(() => {
@ -39,7 +33,7 @@
data-sveltekit-preload-data={preloadData ? 'hover' : 'off'} data-sveltekit-preload-data={preloadData ? 'hover' : 'off'}
draggable="false" draggable="false"
aria-current={isSelected ? 'page' : undefined} 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 {isSelected
? 'bg-immich-primary/10 text-immich-primary hover:bg-immich-primary/10 dark:bg-immich-dark-primary/10 dark:text-immich-dark-primary' ? '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 /> <Icon path={icon} size="1.5em" class="shrink-0" flipped={flippedLogo} ariaHidden />
<span class="text-sm font-medium">{title}</span> <span class="text-sm font-medium">{title}</span>
</div> </div>
<div></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>
</a> </a>

View file

@ -24,8 +24,6 @@
} from '@mdi/js'; } from '@mdi/js';
import SideBarSection from './side-bar-section.svelte'; import SideBarSection from './side-bar-section.svelte';
import SideBarLink from './side-bar-link.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 { t } from 'svelte-i18n';
import BottomInfo from '$lib/components/shared-components/side-bar/bottom-info.svelte'; import BottomInfo from '$lib/components/shared-components/side-bar/bottom-info.svelte';
import { preferences } from '$lib/stores/user.store'; import { preferences } from '$lib/stores/user.store';
@ -47,11 +45,7 @@
routeId="/(user)/photos" routeId="/(user)/photos"
bind:isSelected={isPhotosSelected} bind:isSelected={isPhotosSelected}
icon={isPhotosSelected ? mdiImageMultiple : mdiImageMultipleOutline} icon={isPhotosSelected ? mdiImageMultiple : mdiImageMultipleOutline}
> ></SideBarLink>
{#snippet moreInformation()}
<MoreInformationAssets assetStats={{ isArchived: false }} />
{/snippet}
</SideBarLink>
{#if $featureFlags.search} {#if $featureFlags.search}
<SideBarLink title={$t('explore')} routeId="/(user)/explore" icon={mdiMagnify} /> <SideBarLink title={$t('explore')} routeId="/(user)/explore" icon={mdiMagnify} />
@ -80,11 +74,7 @@
routeId="/(user)/sharing" routeId="/(user)/sharing"
icon={isSharingSelected ? mdiAccountMultiple : mdiAccountMultipleOutline} icon={isSharingSelected ? mdiAccountMultiple : mdiAccountMultipleOutline}
bind:isSelected={isSharingSelected} bind:isSelected={isSharingSelected}
> ></SideBarLink>
{#snippet moreInformation()}
<MoreInformationAlbums albumType="shared" />
{/snippet}
</SideBarLink>
<div class="text-xs transition-all duration-200 dark:text-immich-dark-fg"> <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> <p class="hidden p-6 group-hover:sm:block md:block">{$t('library').toUpperCase()}</p>
@ -96,17 +86,9 @@
routeId="/(user)/favorites" routeId="/(user)/favorites"
icon={isFavoritesSelected ? mdiHeart : mdiHeartOutline} icon={isFavoritesSelected ? mdiHeart : mdiHeartOutline}
bind:isSelected={isFavoritesSelected} bind:isSelected={isFavoritesSelected}
> ></SideBarLink>
{#snippet moreInformation()}
<MoreInformationAssets assetStats={{ isFavorite: true }} />
{/snippet}
</SideBarLink>
<SideBarLink title={$t('albums')} routeId="/(user)/albums" icon={mdiImageAlbum} flippedLogo> <SideBarLink title={$t('albums')} routeId="/(user)/albums" icon={mdiImageAlbum} flippedLogo></SideBarLink>
{#snippet moreInformation()}
<MoreInformationAlbums albumType="owned" />
{/snippet}
</SideBarLink>
{#if $preferences.tags.enabled && $preferences.tags.sidebarWeb} {#if $preferences.tags.enabled && $preferences.tags.sidebarWeb}
<SideBarLink title={$t('tags')} routeId="/(user)/tags" icon={mdiTagMultipleOutline} flippedLogo /> <SideBarLink title={$t('tags')} routeId="/(user)/tags" icon={mdiTagMultipleOutline} flippedLogo />
@ -128,11 +110,7 @@
routeId="/(user)/archive" routeId="/(user)/archive"
bind:isSelected={isArchiveSelected} bind:isSelected={isArchiveSelected}
icon={isArchiveSelected ? mdiArchiveArrowDown : mdiArchiveArrowDownOutline} icon={isArchiveSelected ? mdiArchiveArrowDown : mdiArchiveArrowDownOutline}
> ></SideBarLink>
{#snippet moreInformation()}
<MoreInformationAssets assetStats={{ isArchived: true }} />
{/snippet}
</SideBarLink>
{#if $featureFlags.trash} {#if $featureFlags.trash}
<SideBarLink <SideBarLink
@ -140,11 +118,7 @@
routeId="/(user)/trash" routeId="/(user)/trash"
bind:isSelected={isTrashSelected} bind:isSelected={isTrashSelected}
icon={isTrashSelected ? mdiTrashCan : mdiTrashCanOutline} icon={isTrashSelected ? mdiTrashCan : mdiTrashCanOutline}
> ></SideBarLink>
{#snippet moreInformation()}
<MoreInformationAssets assetStats={{ isTrashed: true }} />
{/snippet}
</SideBarLink>
{/if} {/if}
</nav> </nav>

View file

@ -30,8 +30,10 @@
mdiFeatureSearchOutline, mdiFeatureSearchOutline,
mdiKeyOutline, mdiKeyOutline,
mdiOnepassword, mdiOnepassword,
mdiServerOutline,
mdiTwoFactorAuthentication, mdiTwoFactorAuthentication,
} from '@mdi/js'; } from '@mdi/js';
import UserUsageStatistic from '$lib/components/user-settings-page/user-usage-statistic.svelte';
interface Props { interface Props {
keys?: ApiKeyResponseDto[]; keys?: ApiKeyResponseDto[];
@ -59,6 +61,15 @@
<UserProfileSettings /> <UserProfileSettings />
</SettingAccordion> </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')}> <SettingAccordion icon={mdiApi} key="api-keys" title={$t('api_keys')} subtitle={$t('manage_your_api_keys')}>
<UserAPIKeyList bind:keys /> <UserAPIKeyList bind:keys />
</SettingAccordion> </SettingAccordion>

View file

@ -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>

View file

@ -84,6 +84,11 @@ export enum QueryParameter {
PATH = 'path', PATH = 'path',
} }
export enum SessionStorageKey {
INFINITE_SCROLL_PAGE = 'infiniteScrollPage',
SCROLL_POSITION = 'scrollPosition',
}
export enum OpenSettingQueryParameterValue { export enum OpenSettingQueryParameterValue {
OAUTH = 'oauth', OAUTH = 'oauth',
JOB = 'job', JOB = 'job',

View file

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

View file

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

View file

@ -34,6 +34,7 @@ export const serverConfig = writable<ServerConfig>({
externalDomain: '', externalDomain: '',
mapDarkStyleUrl: '', mapDarkStyleUrl: '',
mapLightStyleUrl: '', mapLightStyleUrl: '',
publicUsers: true,
}); });
export const retrieveServerConfig = async () => { export const retrieveServerConfig = async () => {

View file

@ -1,6 +1,6 @@
import { browser } from '$app/environment'; import { browser } from '$app/environment';
import { goto } from '$app/navigation'; 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 { purchaseStore } from '$lib/stores/purchase.store';
import { serverInfo } from '$lib/stores/server-info.store'; import { serverInfo } from '$lib/stores/server-info.store';
import { preferences as preferences$, resetSavedUser, user as user$ } from '$lib/stores/user.store'; import { preferences as preferences$, resetSavedUser, user as user$ } from '$lib/stores/user.store';

View file

@ -7,8 +7,6 @@ export const normalizeTreePath = (path: string) => path.replace(/^\//, '').repla
export function buildTree(paths: string[]) { export function buildTree(paths: string[]) {
const root: RecursiveObject = {}; const root: RecursiveObject = {};
paths.sort();
for (const path of paths) { for (const path of paths) {
const parts = path.split('/'); const parts = path.split('/');
let current = root; let current = root;

View file

@ -1,5 +1,6 @@
<script lang="ts"> <script lang="ts">
import type { PageData } from './$types'; import type { PageData } from './$types';
import { scrollMemory } from '$lib/actions/scroll-memory';
import { AlbumFilter, albumViewSettings } from '$lib/stores/preferences.store'; import { AlbumFilter, albumViewSettings } from '$lib/stores/preferences.store';
import { createAlbumAndRedirect } from '$lib/utils/album-utils'; import { createAlbumAndRedirect } from '$lib/utils/album-utils';
import UserPageLayout from '$lib/components/layouts/user-page-layout.svelte'; 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 EmptyPlaceholder from '$lib/components/shared-components/empty-placeholder.svelte';
import GroupTab from '$lib/components/elements/group-tab.svelte'; import GroupTab from '$lib/components/elements/group-tab.svelte';
import SearchBar from '$lib/components/elements/search-bar.svelte'; import SearchBar from '$lib/components/elements/search-bar.svelte';
import { AppRoute } from '$lib/constants';
import { t } from 'svelte-i18n'; import { t } from 'svelte-i18n';
interface Props { interface Props {
@ -20,7 +22,7 @@
let albumGroups: string[] = $state([]); let albumGroups: string[] = $state([]);
</script> </script>
<UserPageLayout title={data.meta.title}> <UserPageLayout title={data.meta.title} use={[[scrollMemory, { routeStartsWith: AppRoute.ALBUMS }]]}>
{#snippet buttons()} {#snippet buttons()}
<div class="flex place-items-center gap-2"> <div class="flex place-items-center gap-2">
<AlbumsControls {albumGroups} bind:searchQuery /> <AlbumsControls {albumGroups} bind:searchQuery />

View file

@ -1,5 +1,6 @@
<script lang="ts"> <script lang="ts">
import { afterNavigate, goto, onNavigate } from '$app/navigation'; 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 AlbumDescription from '$lib/components/album-page/album-description.svelte';
import AlbumOptions from '$lib/components/album-page/album-options.svelte'; import AlbumOptions from '$lib/components/album-page/album-options.svelte';
import AlbumSummary from '$lib/components/album-page/album-summary.svelte'; import AlbumSummary from '$lib/components/album-page/album-summary.svelte';
@ -430,7 +431,7 @@
}); });
</script> </script>
<div class="flex overflow-hidden"> <div class="flex overflow-hidden" use:scrollMemoryClearer={{ routeStartsWith: AppRoute.ALBUMS }}>
<div class="relative w-full shrink"> <div class="relative w-full shrink">
{#if $isMultiSelectState} {#if $isMultiSelectState}
<AssetSelectControlBar assets={$selectedAssets} clearSelect={() => assetInteractionStore.clearMultiselect()}> <AssetSelectControlBar assets={$selectedAssets} clearSelect={() => assetInteractionStore.clearMultiselect()}>

View file

@ -9,7 +9,7 @@
import TreeItems from '$lib/components/shared-components/tree/tree-items.svelte'; import TreeItems from '$lib/components/shared-components/tree/tree-items.svelte';
import { AppRoute, QueryParameter } from '$lib/constants'; import { AppRoute, QueryParameter } from '$lib/constants';
import type { Viewport } from '$lib/stores/assets.store'; 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 { buildTree, normalizeTreePath } from '$lib/utils/tree-utils';
import { mdiFolder, mdiFolderHome, mdiFolderOutline } from '@mdi/js'; import { mdiFolder, mdiFolderHome, mdiFolderOutline } from '@mdi/js';
import { onMount } from 'svelte'; import { onMount } from 'svelte';
@ -27,7 +27,7 @@
const viewport: Viewport = $state({ width: 0, height: 0 }); const viewport: Viewport = $state({ width: 0, height: 0 });
let pathSegments = $derived(data.path ? data.path.split('/') : []); 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 currentPath = $derived($page.url.searchParams.get(QueryParameter.PATH) || '');
let currentTreeItems = $derived(currentPath ? data.currentFolders : Object.keys(tree)); let currentTreeItems = $derived(currentPath ? data.currentFolders : Object.keys(tree));

View file

@ -1,10 +1,9 @@
import { QueryParameter } from '$lib/constants'; 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 { authenticate } from '$lib/utils/auth';
import { getFormatter } from '$lib/utils/i18n'; import { getFormatter } from '$lib/utils/i18n';
import { getAssetInfoFromParam } from '$lib/utils/navigation'; import { getAssetInfoFromParam } from '$lib/utils/navigation';
import { buildTree, normalizeTreePath } from '$lib/utils/tree-utils'; import { buildTree, normalizeTreePath } from '$lib/utils/tree-utils';
import { get } from 'svelte/store';
import type { PageLoad } from './$types'; import type { PageLoad } from './$types';
export const load = (async ({ params, url }) => { export const load = (async ({ params, url }) => {
@ -13,18 +12,16 @@ export const load = (async ({ params, url }) => {
const $t = await getFormatter(); const $t = await getFormatter();
await foldersStore.fetchUniquePaths(); await foldersStore.fetchUniquePaths();
const { uniquePaths } = get(foldersStore);
let pathAssets = null; let pathAssets = null;
const path = url.searchParams.get(QueryParameter.PATH); const path = url.searchParams.get(QueryParameter.PATH);
if (path) { if (path) {
await foldersStore.fetchAssetsByPath(path); await foldersStore.fetchAssetsByPath(path);
const { assets } = get(foldersStore); pathAssets = foldersStore.assets[path] || null;
pathAssets = assets[path] || null;
} }
let tree = buildTree(uniquePaths || []); let tree = buildTree(foldersStore.uniquePaths);
const parts = normalizeTreePath(path || '').split('/'); const parts = normalizeTreePath(path || '').split('/');
for (const part of parts) { for (const part of parts) {
tree = tree?.[part]; tree = tree?.[part];

View file

@ -2,6 +2,7 @@
import { goto } from '$app/navigation'; import { goto } from '$app/navigation';
import { page } from '$app/stores'; import { page } from '$app/stores';
import { focusTrap } from '$lib/actions/focus-trap'; import { focusTrap } from '$lib/actions/focus-trap';
import { scrollMemory } from '$lib/actions/scroll-memory';
import Button from '$lib/components/elements/buttons/button.svelte'; import Button from '$lib/components/elements/buttons/button.svelte';
import LinkButton from '$lib/components/elements/buttons/link-button.svelte'; import LinkButton from '$lib/components/elements/buttons/link-button.svelte';
import Icon from '$lib/components/elements/icon.svelte'; import Icon from '$lib/components/elements/icon.svelte';
@ -17,7 +18,7 @@
notificationController, notificationController,
NotificationType, NotificationType,
} from '$lib/components/shared-components/notification/notification'; } 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 { locale } from '$lib/stores/preferences.store';
import { websocketEvents } from '$lib/stores/websocket'; import { websocketEvents } from '$lib/stores/websocket';
import { handlePromiseError } from '$lib/utils'; import { handlePromiseError } from '$lib/utils';
@ -50,6 +51,7 @@
let showSetBirthDateModal = $state(false); let showSetBirthDateModal = $state(false);
let showMergeModal = $state(false); let showMergeModal = $state(false);
let personName = $state(''); let personName = $state('');
let currentPage = $state(1);
let nextPage = $state(data.people.hasNextPage ? 2 : null); let nextPage = $state(data.people.hasNextPage ? 2 : null);
let personMerge1 = $state<PersonResponseDto>(); let personMerge1 = $state<PersonResponseDto>();
let personMerge2 = $state<PersonResponseDto>(); let personMerge2 = $state<PersonResponseDto>();
@ -68,6 +70,7 @@
handlePromiseError(searchPeopleElement.searchPeople(true, searchName)); handlePromiseError(searchPeopleElement.searchPeople(true, searchName));
} }
} }
return websocketEvents.on('on_person_thumbnail', (personId: string) => { return websocketEvents.on('on_person_thumbnail', (personId: string) => {
for (const person of people) { for (const person of people) {
if (person.id === personId) { 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 () => { const loadNextPage = async () => {
if (!nextPage) { if (!nextPage) {
return; return;
@ -85,6 +118,9 @@
try { try {
const { people: newPeople, hasNextPage } = await getAllPeople({ withHidden: true, page: nextPage }); const { people: newPeople, hasNextPage } = await getAllPeople({ withHidden: true, page: nextPage });
people = people.concat(newPeople); people = people.concat(newPeople);
if (nextPage !== null) {
currentPage = nextPage;
}
nextPage = hasNextPage ? nextPage + 1 : null; nextPage = hasNextPage ? nextPage + 1 : null;
} catch (error) { } catch (error) {
handleError(error, $t('errors.failed_to_load_people')); handleError(error, $t('errors.failed_to_load_people'));
@ -323,6 +359,23 @@
<UserPageLayout <UserPageLayout
title={$t('people')} title={$t('people')}
description={countVisiblePeople === 0 && !searchName ? undefined : `(${countVisiblePeople.toLocaleString($locale)})`} 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()} {#snippet buttons()}
{#if people.length > 0} {#if people.length > 0}

View file

@ -1,6 +1,7 @@
<script lang="ts"> <script lang="ts">
import { afterNavigate, goto } from '$app/navigation'; import { afterNavigate, goto } from '$app/navigation';
import { page } from '$app/stores'; import { page } from '$app/stores';
import { scrollMemoryClearer } from '$lib/actions/scroll-memory';
import ImageThumbnail from '$lib/components/assets/thumbnail/image-thumbnail.svelte'; import ImageThumbnail from '$lib/components/assets/thumbnail/image-thumbnail.svelte';
import EditNameInput from '$lib/components/faces-page/edit-name-input.svelte'; import EditNameInput from '$lib/components/faces-page/edit-name-input.svelte';
import MergeFaceSelector from '$lib/components/faces-page/merge-face-selector.svelte'; import MergeFaceSelector from '$lib/components/faces-page/merge-face-selector.svelte';
@ -25,7 +26,7 @@
NotificationType, NotificationType,
notificationController, notificationController,
} from '$lib/components/shared-components/notification/notification'; } 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 { createAssetInteractionStore } from '$lib/stores/asset-interaction.store';
import { assetViewingStore } from '$lib/stores/asset-viewing.store'; import { assetViewingStore } from '$lib/stores/asset-viewing.store';
import { AssetStore } from '$lib/stores/assets.store'; import { AssetStore } from '$lib/stores/assets.store';
@ -62,14 +63,17 @@
data: PageData; data: PageData;
} }
let { data = $bindable() }: Props = $props(); let { data }: Props = $props();
let numberOfAssets = $state(data.statistics.assets); let numberOfAssets = $state(data.statistics.assets);
let { isViewing: showAssetViewer } = assetViewingStore; let { isViewing: showAssetViewer } = assetViewingStore;
let assetStore = new AssetStore({ const assetStoreOptions = { isArchived: false, personId: data.person.id };
isArchived: false, const assetStore = new AssetStore(assetStoreOptions);
personId: data.person.id,
$effect(() => {
assetStoreOptions.personId = data.person.id;
handlePromiseError(assetStore.updateOptions(assetStoreOptions));
}); });
const assetInteractionStore = createAssetInteractionStore(); const assetInteractionStore = createAssetInteractionStore();
@ -164,7 +168,7 @@
type: NotificationType.Info, type: NotificationType.Info,
}); });
await goto(previousRoute, { replaceState: true }); await goto(previousRoute);
} catch (error) { } catch (error) {
handleError(error, $t('errors.unable_to_hide_person')); handleError(error, $t('errors.unable_to_hide_person'));
} }
@ -328,7 +332,6 @@
$effect(() => { $effect(() => {
if (person) { if (person) {
handlePromiseError(updateAssetCount()); handlePromiseError(updateAssetCount());
handlePromiseError(assetStore.updateOptions({ personId: person.id }));
} }
}); });
@ -431,7 +434,15 @@
{/if} {/if}
</header> </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} {#key person.id}
<AssetGrid <AssetGrid
enableRouting={true} enableRouting={true}

View file

@ -10,7 +10,7 @@
"skipLibCheck": true, "skipLibCheck": true,
"sourceMap": true, "sourceMap": true,
"strict": true, "strict": true,
"target": "es2020", "target": "es2022",
"types": ["vitest/globals"] "types": ["vitest/globals"]
}, },
"extends": "./.svelte-kit/tsconfig.json" "extends": "./.svelte-kit/tsconfig.json"