1
0
Fork 0
mirror of https://github.com/immich-app/immich.git synced 2025-01-16 00:36:47 +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

View file

@ -125,26 +125,23 @@ services:
ports:
- 5432:5432
healthcheck:
test: pg_isready --dbname='${DB_DATABASE_NAME}' --username='${DB_USERNAME}' || exit 1; Chksum="$$(psql --dbname='${DB_DATABASE_NAME}' --username='${DB_USERNAME}' --tuples-only --no-align --command='SELECT COALESCE(SUM(checksum_failures), 0) FROM pg_stat_database')"; echo "checksum failure count is $$Chksum"; [ "$$Chksum" = '0' ] || exit 1
test: >-
pg_isready --dbname="$${POSTGRES_DB}" --username="$${POSTGRES_USER}" || exit 1;
Chksum="$$(psql --dbname="$${POSTGRES_DB}" --username="$${POSTGRES_USER}" --tuples-only --no-align
--command='SELECT COALESCE(SUM(checksum_failures), 0) FROM pg_stat_database')";
echo "checksum failure count is $$Chksum";
[ "$$Chksum" = '0' ] || exit 1
interval: 5m
start_interval: 30s
start_period: 5m
command:
[
'postgres',
'-c',
'shared_preload_libraries=vectors.so',
'-c',
'search_path="$$user", public, vectors',
'-c',
'logging_collector=on',
'-c',
'max_wal_size=2GB',
'-c',
'shared_buffers=512MB',
'-c',
'wal_compression=on',
]
command: >-
postgres
-c shared_preload_libraries=vectors.so
-c 'search_path="$$user", public, vectors'
-c logging_collector=on
-c max_wal_size=2GB
-c shared_buffers=512MB
-c wal_compression=on
# set IMMICH_TELEMETRY_INCLUDE=all in .env to enable metrics
# immich-prometheus:

View file

@ -67,26 +67,23 @@ services:
ports:
- 5432:5432
healthcheck:
test: pg_isready --dbname='${DB_DATABASE_NAME}' --username='${DB_USERNAME}' || exit 1; Chksum="$$(psql --dbname='${DB_DATABASE_NAME}' --username='${DB_USERNAME}' --tuples-only --no-align --command='SELECT COALESCE(SUM(checksum_failures), 0) FROM pg_stat_database')"; echo "checksum failure count is $$Chksum"; [ "$$Chksum" = '0' ] || exit 1
test: >-
pg_isready --dbname="$${POSTGRES_DB}" --username="$${POSTGRES_USER}" || exit 1;
Chksum="$$(psql --dbname="$${POSTGRES_DB}" --username="$${POSTGRES_USER}" --tuples-only --no-align
--command='SELECT COALESCE(SUM(checksum_failures), 0) FROM pg_stat_database')";
echo "checksum failure count is $$Chksum";
[ "$$Chksum" = '0' ] || exit 1
interval: 5m
start_interval: 30s
start_period: 5m
command:
[
'postgres',
'-c',
'shared_preload_libraries=vectors.so',
'-c',
'search_path="$$user", public, vectors',
'-c',
'logging_collector=on',
'-c',
'max_wal_size=2GB',
'-c',
'shared_buffers=512MB',
'-c',
'wal_compression=on',
]
command: >-
postgres
-c shared_preload_libraries=vectors.so
-c 'search_path="$$user", public, vectors'
-c logging_collector=on
-c max_wal_size=2GB
-c shared_buffers=512MB
-c wal_compression=on
restart: always
# set IMMICH_TELEMETRY_INCLUDE=all in .env to enable metrics

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
- ${DB_DATA_LOCATION}:/var/lib/postgresql/data
healthcheck:
test: pg_isready --dbname='${DB_DATABASE_NAME}' --username='${DB_USERNAME}' || exit 1; Chksum="$$(psql --dbname='${DB_DATABASE_NAME}' --username='${DB_USERNAME}' --tuples-only --no-align --command='SELECT COALESCE(SUM(checksum_failures), 0) FROM pg_stat_database')"; echo "checksum failure count is $$Chksum"; [ "$$Chksum" = '0' ] || exit 1
test: >-
pg_isready --dbname="$${POSTGRES_DB}" --username="$${POSTGRES_USER}" || exit 1;
Chksum="$$(psql --dbname="$${POSTGRES_DB}" --username="$${POSTGRES_USER}" --tuples-only --no-align
--command='SELECT COALESCE(SUM(checksum_failures), 0) FROM pg_stat_database')";
echo "checksum failure count is $$Chksum";
[ "$$Chksum" = '0' ] || exit 1
interval: 5m
start_interval: 30s
start_period: 5m
command:
[
'postgres',
'-c',
'shared_preload_libraries=vectors.so',
'-c',
'search_path="$$user", public, vectors',
'-c',
'logging_collector=on',
'-c',
'max_wal_size=2GB',
'-c',
'shared_buffers=512MB',
'-c',
'wal_compression=on',
]
command: >-
postgres
-c shared_preload_libraries=vectors.so
-c 'search_path="$$user", public, vectors'
-c logging_collector=on
-c max_wal_size=2GB
-c shared_buffers=512MB
-c wal_compression=on
restart: always
volumes:

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 format` (formatting via Prettier)
- [ ] `npm run check:svelte` (Type checking via SvelteKit)
- [ ] `npm run check:typescript` (check typescript)
- [ ] `npm test` (unit tests)
## Documentation

View file

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

View file

@ -1,4 +1,6 @@
{
"user_usage_stats": "Account usage statistics",
"user_usage_stats_description": "View account usage statistics",
"about": "Refresh",
"account": "Account",
"account_settings": "Account Settings",
@ -222,6 +224,8 @@
"send_welcome_email": "Send welcome email",
"server_external_domain_settings": "External domain",
"server_external_domain_settings_description": "Domain for public shared links, including http(s)://",
"server_public_users": "Public Users",
"server_public_users_description": "All users (name and email) are listed when adding a user to shared albums. When disabled, the user list will only be available to admin users.",
"server_settings": "Server Settings",
"server_settings_description": "Manage server settings",
"server_welcome_message": "Welcome message",
@ -1311,6 +1315,7 @@
"view_all_users": "View all users",
"view_in_timeline": "View in timeline",
"view_links": "View links",
"view_name": "View",
"view_next_asset": "View next asset",
"view_previous_asset": "View previous asset",
"view_stack": "View Stack",
@ -1324,5 +1329,7 @@
"years_ago": "{years, plural, one {# year} other {# years}} ago",
"yes": "Yes",
"you_dont_have_any_shared_links": "You don't have any shared links",
"zoom_image": "Zoom Image"
"zoom_image": "Zoom Image",
"timeline": "Timeline",
"total": "Total"
}

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/shared_link/shared_link.model.dart
- lib/providers/asset_viewer/asset_people.provider.dart
- lib/providers/authentication.provider.dart
- lib/providers/auth.provider.dart
- lib/providers/image/immich_remote_{image,thumbnail}_provider.dart
- lib/providers/map/map_state.provider.dart
- lib/providers/search/{search,search_filter}.provider.dart

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

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

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

View file

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

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

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

View file

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

View file

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

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 'package:immich_mobile/interfaces/database.interface.dart';
import 'package:isar/isar.dart';

View file

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

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,
);
Future<List<User>> getUsers({bool self = false}) =>
_userRepository.getAll(self: self);
Future<List<User>> getUsers({bool self = false}) {
return _userRepository.getAll(self: self);
}
Future<({String profileImagePath})?> uploadProfileImage(XFile image) async {
try {

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

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

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

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

Binary file not shown.

View file

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

View file

@ -6,7 +6,7 @@ version: 1.121.0+168
environment:
sdk: '>=3.3.0 <4.0.0'
flutter: 3.24.4
flutter: 3.24.5
isar_version: &isar_version 3.1.8 # define the version to be used

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

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -38,9 +38,9 @@ describe(UserService.name, () => {
});
describe('getAll', () => {
it('should get all users', async () => {
it('admin should get all users', async () => {
userMock.getList.mockResolvedValue([userStub.admin]);
await expect(sut.search()).resolves.toEqual([
await expect(sut.search(authStub.admin)).resolves.toEqual([
expect.objectContaining({
id: authStub.admin.user.id,
email: authStub.admin.user.email,
@ -48,6 +48,29 @@ describe(UserService.name, () => {
]);
expect(userMock.getList).toHaveBeenCalledWith({ withDeleted: false });
});
it('non-admin should get all users when publicUsers enabled', async () => {
userMock.getList.mockResolvedValue([userStub.user1]);
await expect(sut.search(authStub.user1)).resolves.toEqual([
expect.objectContaining({
id: authStub.user1.user.id,
email: authStub.user1.user.email,
}),
]);
expect(userMock.getList).toHaveBeenCalledWith({ withDeleted: false });
});
it('non-admin user should only receive itself when publicUsers is disabled', async () => {
userMock.getList.mockResolvedValue([userStub.user1]);
systemMock.get.mockResolvedValue(systemConfigStub.publicUsersDisabled);
await expect(sut.search(authStub.user1)).resolves.toEqual([
expect.objectContaining({
id: authStub.user1.user.id,
email: authStub.user1.user.email,
}),
]);
expect(userMock.getList).not.toHaveBeenCalledWith({ withDeleted: false });
});
});
describe('get', () => {

View file

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

View file

@ -117,4 +117,9 @@ export const systemConfigStub = {
},
},
},
publicUsersDisabled: {
server: {
publicUsers: false,
},
},
} 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>;
}
import type en from '$lib/en.json';
import type en from '$i18n/en.json';
import 'svelte-i18n';
type NestedKeys<T, K = keyof T> = K extends keyof T & string

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 SettingInputField from '$lib/components/shared-components/settings/setting-input-field.svelte';
import SettingButtonsRow from '$lib/components/shared-components/settings/setting-buttons-row.svelte';
import SettingSwitch from '$lib/components/shared-components/settings/setting-switch.svelte';
import { t } from 'svelte-i18n';
import { SettingInputFieldType } from '$lib/constants';
@ -44,6 +45,13 @@
isEdited={config.server.loginPageMessage !== savedConfig.server.loginPageMessage}
/>
<SettingSwitch
title={$t('admin.server_public_users')}
subtitle={$t('admin.server_public_users_description')}
{disabled}
bind:checked={config.server.publicUsers}
/>
<div class="ml-4">
<SettingButtonsRow
onReset={(options) => onReset({ ...options, configKeys: ['server'] })}

View file

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

View file

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

View file

@ -7,6 +7,7 @@
import NavigationBar from '../shared-components/navigation-bar/navigation-bar.svelte';
import SideBar from '../shared-components/side-bar/side-bar.svelte';
import AdminSideBar from '../shared-components/side-bar/admin-side-bar.svelte';
import { useActions, type ActionArray } from '$lib/actions/use-actions';
import type { Snippet } from 'svelte';
interface Props {
@ -16,6 +17,7 @@
description?: string | undefined;
scrollbar?: boolean;
admin?: boolean;
use?: ActionArray;
header?: Snippet;
sidebar?: Snippet;
buttons?: Snippet;
@ -29,6 +31,7 @@
description = undefined,
scrollbar = true,
admin = false,
use = [],
header,
sidebar,
buttons,
@ -73,7 +76,7 @@
</div>
{/if}
<div class="{scrollbarClass} scrollbar-stable absolute {hasTitleClass} w-full overflow-y-auto">
<div class="{scrollbarClass} scrollbar-stable absolute {hasTitleClass} w-full overflow-y-auto" use:useActions={use}>
{@render children?.()}
</div>
</section>

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

View file

@ -218,7 +218,7 @@
const getInputPosition = () => input?.getBoundingClientRect();
$effect(() => {
// searchQuery = selectedOption ? selectedOption.label : '';
searchQuery = selectedOption ? selectedOption.label : '';
});
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 (
fileSystemDirectoryEntry: FileSystemDirectoryEntry,
): Promise<FileSystemEntry[]> => {
return new Promise((resolve, reject) => {
const reader = fileSystemDirectoryEntry.createReader();
reader.readEntries(resolve, reject);
});
const reader = fileSystemDirectoryEntry.createReader();
const files: FileSystemEntry[] = [];
let entries: FileSystemEntry[];
do {
entries = await readEntriesAsync(reader);
files.push(...entries);
} while (entries.length > 0);
return files;
};
const handleFiles = async (files?: FileList | File[]) => {

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">
import { fade } from 'svelte/transition';
import Icon from '$lib/components/elements/icon.svelte';
import { mdiInformationOutline } from '@mdi/js';
import { resolveRoute } from '$app/paths';
import { page } from '$app/stores';
import type { Snippet } from 'svelte';
interface Props {
title: string;
@ -13,7 +10,6 @@
flippedLogo?: boolean;
isSelected?: boolean;
preloadData?: boolean;
moreInformation?: Snippet;
}
let {
@ -23,10 +19,8 @@
flippedLogo = false,
isSelected = $bindable(false),
preloadData = true,
moreInformation,
}: Props = $props();
let showMoreInformation = $state(false);
let routePath = $derived(resolveRoute(routeId, {}));
$effect(() => {
@ -39,7 +33,7 @@
data-sveltekit-preload-data={preloadData ? 'hover' : 'off'}
draggable="false"
aria-current={isSelected ? 'page' : undefined}
class="flex w-full place-items-center justify-between gap-4 rounded-r-full py-3 transition-[padding] delay-100 duration-100 hover:cursor-pointer hover:bg-immich-gray hover:text-immich-primary dark:text-immich-dark-fg dark:hover:bg-immich-dark-gray dark:hover:text-immich-dark-primary
class="flex w-full place-items-center gap-4 rounded-r-full py-3 transition-[padding] delay-100 duration-100 hover:cursor-pointer hover:bg-immich-gray hover:text-immich-primary dark:text-immich-dark-fg dark:hover:bg-immich-dark-gray dark:hover:text-immich-dark-primary
{isSelected
? 'bg-immich-primary/10 text-immich-primary hover:bg-immich-primary/10 dark:bg-immich-dark-primary/10 dark:text-immich-dark-primary'
: ''}
@ -50,33 +44,5 @@
<Icon path={icon} size="1.5em" class="shrink-0" flipped={flippedLogo} ariaHidden />
<span class="text-sm font-medium">{title}</span>
</div>
<div
class="h-0 overflow-hidden transition-[height] delay-1000 duration-100 sm:group-hover:h-auto group-hover:sm:overflow-visible md:h-auto md:overflow-visible"
>
{#if moreInformation}
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div
class="relative flex cursor-default select-none justify-center"
onmouseenter={() => (showMoreInformation = true)}
onmouseleave={() => (showMoreInformation = false)}
>
<div class="p-1 text-gray-600 hover:cursor-help dark:text-gray-400">
<Icon path={mdiInformationOutline} />
</div>
{#if showMoreInformation}
<div class="absolute right-6 top-0">
<div
class="flex place-content-center place-items-center whitespace-nowrap rounded-3xl border bg-immich-bg px-6 py-3 text-xs text-immich-fg shadow-lg dark:border-immich-dark-gray dark:bg-gray-600 dark:text-immich-dark-fg"
class:hidden={!showMoreInformation}
transition:fade={{ duration: 200 }}
>
{@render moreInformation?.()}
</div>
</div>
{/if}
</div>
{/if}
</div>
<div></div>
</a>

View file

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

View file

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

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',
}
export enum SessionStorageKey {
INFINITE_SCROLL_PAGE = 'infiniteScrollPage',
SCROLL_POSITION = 'scrollPosition',
}
export enum OpenSettingQueryParameterValue {
OAUTH = 'oauth',
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: '',
mapDarkStyleUrl: '',
mapLightStyleUrl: '',
publicUsers: true,
});
export const retrieveServerConfig = async () => {

View file

@ -1,6 +1,6 @@
import { browser } from '$app/environment';
import { goto } from '$app/navigation';
import { foldersStore } from '$lib/stores/folders.store';
import { foldersStore } from '$lib/stores/folders.svelte';
import { purchaseStore } from '$lib/stores/purchase.store';
import { serverInfo } from '$lib/stores/server-info.store';
import { preferences as preferences$, resetSavedUser, user as user$ } from '$lib/stores/user.store';

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -2,6 +2,7 @@
import { goto } from '$app/navigation';
import { page } from '$app/stores';
import { focusTrap } from '$lib/actions/focus-trap';
import { scrollMemory } from '$lib/actions/scroll-memory';
import Button from '$lib/components/elements/buttons/button.svelte';
import LinkButton from '$lib/components/elements/buttons/link-button.svelte';
import Icon from '$lib/components/elements/icon.svelte';
@ -17,7 +18,7 @@
notificationController,
NotificationType,
} from '$lib/components/shared-components/notification/notification';
import { ActionQueryParameterValue, AppRoute, QueryParameter } from '$lib/constants';
import { ActionQueryParameterValue, AppRoute, QueryParameter, SessionStorageKey } from '$lib/constants';
import { locale } from '$lib/stores/preferences.store';
import { websocketEvents } from '$lib/stores/websocket';
import { handlePromiseError } from '$lib/utils';
@ -50,6 +51,7 @@
let showSetBirthDateModal = $state(false);
let showMergeModal = $state(false);
let personName = $state('');
let currentPage = $state(1);
let nextPage = $state(data.people.hasNextPage ? 2 : null);
let personMerge1 = $state<PersonResponseDto>();
let personMerge2 = $state<PersonResponseDto>();
@ -68,6 +70,7 @@
handlePromiseError(searchPeopleElement.searchPeople(true, searchName));
}
}
return websocketEvents.on('on_person_thumbnail', (personId: string) => {
for (const person of people) {
if (person.id === personId) {
@ -77,6 +80,36 @@
});
});
const loadInitialScroll = () =>
new Promise<void>((resolve) => {
// Load up to previously loaded page when returning.
let newNextPage = sessionStorage.getItem(SessionStorageKey.INFINITE_SCROLL_PAGE);
if (newNextPage && nextPage) {
let startingPage = nextPage,
pagesToLoad = Number.parseInt(newNextPage) - nextPage;
if (pagesToLoad) {
handlePromiseError(
Promise.all(
Array.from({ length: pagesToLoad }).map((_, i) => {
return getAllPeople({ withHidden: true, page: startingPage + i });
}),
).then((pages) => {
for (const page of pages) {
people = people.concat(page.people);
}
currentPage = startingPage + pagesToLoad - 1;
nextPage = pages.at(-1)?.hasNextPage ? startingPage + pagesToLoad : null;
resolve(); // wait until extra pages are loaded
}),
);
} else {
resolve();
}
sessionStorage.removeItem(SessionStorageKey.INFINITE_SCROLL_PAGE);
}
});
const loadNextPage = async () => {
if (!nextPage) {
return;
@ -85,6 +118,9 @@
try {
const { people: newPeople, hasNextPage } = await getAllPeople({ withHidden: true, page: nextPage });
people = people.concat(newPeople);
if (nextPage !== null) {
currentPage = nextPage;
}
nextPage = hasNextPage ? nextPage + 1 : null;
} catch (error) {
handleError(error, $t('errors.failed_to_load_people'));
@ -323,6 +359,23 @@
<UserPageLayout
title={$t('people')}
description={countVisiblePeople === 0 && !searchName ? undefined : `(${countVisiblePeople.toLocaleString($locale)})`}
use={[
[
scrollMemory,
{
routeStartsWith: AppRoute.PEOPLE,
beforeSave: () => {
if (currentPage) {
sessionStorage.setItem(SessionStorageKey.INFINITE_SCROLL_PAGE, currentPage.toString());
}
},
beforeClear: () => {
sessionStorage.removeItem(SessionStorageKey.INFINITE_SCROLL_PAGE);
},
beforeLoad: loadInitialScroll,
},
],
]}
>
{#snippet buttons()}
{#if people.length > 0}

View file

@ -1,6 +1,7 @@
<script lang="ts">
import { afterNavigate, goto } from '$app/navigation';
import { page } from '$app/stores';
import { scrollMemoryClearer } from '$lib/actions/scroll-memory';
import ImageThumbnail from '$lib/components/assets/thumbnail/image-thumbnail.svelte';
import EditNameInput from '$lib/components/faces-page/edit-name-input.svelte';
import MergeFaceSelector from '$lib/components/faces-page/merge-face-selector.svelte';
@ -25,7 +26,7 @@
NotificationType,
notificationController,
} from '$lib/components/shared-components/notification/notification';
import { AppRoute, PersonPageViewMode, QueryParameter } from '$lib/constants';
import { AppRoute, PersonPageViewMode, QueryParameter, SessionStorageKey } from '$lib/constants';
import { createAssetInteractionStore } from '$lib/stores/asset-interaction.store';
import { assetViewingStore } from '$lib/stores/asset-viewing.store';
import { AssetStore } from '$lib/stores/assets.store';
@ -62,14 +63,17 @@
data: PageData;
}
let { data = $bindable() }: Props = $props();
let { data }: Props = $props();
let numberOfAssets = $state(data.statistics.assets);
let { isViewing: showAssetViewer } = assetViewingStore;
let assetStore = new AssetStore({
isArchived: false,
personId: data.person.id,
const assetStoreOptions = { isArchived: false, personId: data.person.id };
const assetStore = new AssetStore(assetStoreOptions);
$effect(() => {
assetStoreOptions.personId = data.person.id;
handlePromiseError(assetStore.updateOptions(assetStoreOptions));
});
const assetInteractionStore = createAssetInteractionStore();
@ -164,7 +168,7 @@
type: NotificationType.Info,
});
await goto(previousRoute, { replaceState: true });
await goto(previousRoute);
} catch (error) {
handleError(error, $t('errors.unable_to_hide_person'));
}
@ -328,7 +332,6 @@
$effect(() => {
if (person) {
handlePromiseError(updateAssetCount());
handlePromiseError(assetStore.updateOptions({ personId: person.id }));
}
});
@ -431,7 +434,15 @@
{/if}
</header>
<main class="relative h-screen overflow-hidden bg-immich-bg tall:ml-4 pt-[var(--navbar-height)] dark:bg-immich-dark-bg">
<main
class="relative h-screen overflow-hidden bg-immich-bg tall:ml-4 pt-[var(--navbar-height)] dark:bg-immich-dark-bg"
use:scrollMemoryClearer={{
routeStartsWith: AppRoute.PEOPLE,
beforeClear: () => {
sessionStorage.removeItem(SessionStorageKey.INFINITE_SCROLL_PAGE);
},
}}
>
{#key person.id}
<AssetGrid
enableRouting={true}

View file

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