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:
commit
fc43b15821
81 changed files with 1234 additions and 642 deletions
|
@ -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:
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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:
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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',
|
||||
|
|
|
@ -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"
|
||||
}
|
||||
|
|
|
@ -1,3 +1,3 @@
|
|||
{
|
||||
"flutter": "3.24.4"
|
||||
"flutter": "3.24.5"
|
||||
}
|
||||
|
|
|
@ -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
|
||||
|
|
5
mobile/lib/interfaces/auth.interface.dart
Normal file
5
mobile/lib/interfaces/auth.interface.dart
Normal file
|
@ -0,0 +1,5 @@
|
|||
import 'package:immich_mobile/interfaces/database.interface.dart';
|
||||
|
||||
abstract interface class IAuthRepository implements IDatabaseRepository {
|
||||
Future<void> clearLocalData();
|
||||
}
|
9
mobile/lib/interfaces/auth_api.interface.dart
Normal file
9
mobile/lib/interfaces/auth_api.interface.dart
Normal file
|
@ -0,0 +1,9 @@
|
|||
import 'package:immich_mobile/models/auth/login_response.model.dart';
|
||||
|
||||
abstract interface class IAuthApiRepository {
|
||||
Future<LoginResponse> login(String email, String password);
|
||||
|
||||
Future<void> logout();
|
||||
|
||||
Future<void> changePassword(String newPassword);
|
||||
}
|
|
@ -1,62 +1,58 @@
|
|||
class AuthenticationState {
|
||||
class AuthState {
|
||||
final String deviceId;
|
||||
final String 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;
|
||||
}
|
||||
}
|
30
mobile/lib/models/auth/login_response.model.dart
Normal file
30
mobile/lib/models/auth/login_response.model.dart
Normal file
|
@ -0,0 +1,30 @@
|
|||
class LoginResponse {
|
||||
final String accessToken;
|
||||
|
||||
final bool isAdmin;
|
||||
|
||||
final String name;
|
||||
|
||||
final String profileImagePath;
|
||||
|
||||
final bool shouldChangePassword;
|
||||
|
||||
final String userEmail;
|
||||
|
||||
final String userId;
|
||||
|
||||
LoginResponse({
|
||||
required this.accessToken,
|
||||
required this.isAdmin,
|
||||
required this.name,
|
||||
required this.profileImagePath,
|
||||
required this.shouldChangePassword,
|
||||
required this.userEmail,
|
||||
required this.userId,
|
||||
});
|
||||
|
||||
@override
|
||||
String toString() {
|
||||
return 'LoginResponse[accessToken=$accessToken, isAdmin=$isAdmin, name=$name, profileImagePath=$profileImagePath, shouldChangePassword=$shouldChangePassword, userEmail=$userEmail, userId=$userId]';
|
||||
}
|
||||
}
|
|
@ -7,7 +7,7 @@ import 'package:hooks_riverpod/hooks_riverpod.dart';
|
|||
import 'package:immich_mobile/extensions/build_context_extensions.dart';
|
||||
import 'package:immich_mobile/extensions/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;
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
|
|
|
@ -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) {
|
||||
|
|
164
mobile/lib/providers/auth.provider.dart
Normal file
164
mobile/lib/providers/auth.provider.dart
Normal file
|
@ -0,0 +1,164 @@
|
|||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter_udid/flutter_udid.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:immich_mobile/entities/store.entity.dart';
|
||||
import 'package:immich_mobile/models/auth/login_response.model.dart';
|
||||
import 'package:immich_mobile/models/auth/auth_state.model.dart';
|
||||
import 'package:immich_mobile/entities/user.entity.dart';
|
||||
import 'package:immich_mobile/providers/api.provider.dart';
|
||||
import 'package:immich_mobile/services/api.service.dart';
|
||||
import 'package:immich_mobile/services/auth.service.dart';
|
||||
import 'package:immich_mobile/utils/hash.dart';
|
||||
import 'package:logging/logging.dart';
|
||||
import 'package:openapi/api.dart';
|
||||
|
||||
final authProvider = StateNotifierProvider<AuthNotifier, AuthState>((ref) {
|
||||
return AuthNotifier(
|
||||
ref.watch(authServiceProvider),
|
||||
ref.watch(apiServiceProvider),
|
||||
);
|
||||
});
|
||||
|
||||
class AuthNotifier extends StateNotifier<AuthState> {
|
||||
final AuthService _authService;
|
||||
final ApiService _apiService;
|
||||
final _log = Logger("AuthenticationNotifier");
|
||||
|
||||
static const Duration _timeoutDuration = Duration(seconds: 7);
|
||||
|
||||
AuthNotifier(
|
||||
this._authService,
|
||||
this._apiService,
|
||||
) : super(
|
||||
AuthState(
|
||||
deviceId: "",
|
||||
userId: "",
|
||||
userEmail: "",
|
||||
name: '',
|
||||
profileImagePath: '',
|
||||
isAdmin: false,
|
||||
isAuthenticated: false,
|
||||
),
|
||||
);
|
||||
|
||||
Future<String> validateServerUrl(String url) {
|
||||
return _authService.validateServerUrl(url);
|
||||
}
|
||||
|
||||
Future<LoginResponse> login(String email, String password) async {
|
||||
final response = await _authService.login(email, password);
|
||||
await saveAuthInfo(accessToken: response.accessToken);
|
||||
return response;
|
||||
}
|
||||
|
||||
Future<void> logout() async {
|
||||
try {
|
||||
await _authService.logout();
|
||||
} finally {
|
||||
await _cleanUp();
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _cleanUp() async {
|
||||
state = AuthState(
|
||||
deviceId: "",
|
||||
userId: "",
|
||||
userEmail: "",
|
||||
name: '',
|
||||
profileImagePath: '',
|
||||
isAdmin: false,
|
||||
isAuthenticated: false,
|
||||
);
|
||||
}
|
||||
|
||||
void updateUserProfileImagePath(String path) {
|
||||
state = state.copyWith(profileImagePath: path);
|
||||
}
|
||||
|
||||
Future<bool> changePassword(String newPassword) async {
|
||||
try {
|
||||
await _authService.changePassword(newPassword);
|
||||
return true;
|
||||
} catch (_) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
Future<bool> saveAuthInfo({
|
||||
required String accessToken,
|
||||
}) async {
|
||||
_apiService.setAccessToken(accessToken);
|
||||
|
||||
// Get the deviceid from the store if it exists, otherwise generate a new one
|
||||
String deviceId =
|
||||
Store.tryGet(StoreKey.deviceId) ?? await FlutterUdid.consistentUdid;
|
||||
|
||||
User? user = Store.tryGet(StoreKey.currentUser);
|
||||
|
||||
UserAdminResponseDto? userResponse;
|
||||
UserPreferencesResponseDto? userPreferences;
|
||||
try {
|
||||
final responses = await Future.wait([
|
||||
_apiService.usersApi.getMyUser().timeout(_timeoutDuration),
|
||||
_apiService.usersApi.getMyPreferences().timeout(_timeoutDuration),
|
||||
]);
|
||||
userResponse = responses[0] as UserAdminResponseDto;
|
||||
userPreferences = responses[1] as UserPreferencesResponseDto;
|
||||
} on ApiException catch (error, stackTrace) {
|
||||
if (error.code == 401) {
|
||||
_log.severe("Unauthorized access, token likely expired. Logging out.");
|
||||
return false;
|
||||
}
|
||||
_log.severe(
|
||||
"Error getting user information from the server [API EXCEPTION]",
|
||||
stackTrace,
|
||||
);
|
||||
} catch (error, stackTrace) {
|
||||
_log.severe(
|
||||
"Error getting user information from the server [CATCH ALL]",
|
||||
error,
|
||||
stackTrace,
|
||||
);
|
||||
|
||||
if (kDebugMode) {
|
||||
debugPrint(
|
||||
"Error getting user information from the server [CATCH ALL] $error $stackTrace",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// If the user information is successfully retrieved, update the store
|
||||
// Due to the flow of the code, this will always happen on first login
|
||||
if (userResponse != null) {
|
||||
Store.put(StoreKey.deviceId, deviceId);
|
||||
Store.put(StoreKey.deviceIdHash, fastHash(deviceId));
|
||||
Store.put(
|
||||
StoreKey.currentUser,
|
||||
User.fromUserDto(userResponse, userPreferences),
|
||||
);
|
||||
Store.put(StoreKey.accessToken, accessToken);
|
||||
|
||||
user = User.fromUserDto(userResponse, userPreferences);
|
||||
} else {
|
||||
_log.severe("Unable to get user information from the server.");
|
||||
}
|
||||
|
||||
// If the user is null, the login was not successful
|
||||
// and we don't have a local copy of the user from a prior successful login
|
||||
if (user == null) {
|
||||
return false;
|
||||
}
|
||||
|
||||
state = state.copyWith(
|
||||
isAuthenticated: true,
|
||||
userId: user.id,
|
||||
userEmail: user.email,
|
||||
name: user.name,
|
||||
profileImagePath: user.profileImagePath,
|
||||
isAdmin: user.isAdmin,
|
||||
deviceId: deviceId,
|
||||
);
|
||||
|
||||
return true;
|
||||
}
|
||||
}
|
|
@ -1,245 +0,0 @@
|
|||
import 'dart:io';
|
||||
|
||||
import 'package:device_info_plus/device_info_plus.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_udid/flutter_udid.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:immich_mobile/providers/album/album.provider.dart';
|
||||
import 'package:immich_mobile/entities/store.entity.dart';
|
||||
import 'package:immich_mobile/models/authentication/authentication_state.model.dart';
|
||||
import 'package:immich_mobile/entities/user.entity.dart';
|
||||
import 'package:immich_mobile/providers/api.provider.dart';
|
||||
import 'package:immich_mobile/providers/db.provider.dart';
|
||||
import 'package:immich_mobile/services/api.service.dart';
|
||||
import 'package:immich_mobile/utils/db.dart';
|
||||
import 'package:immich_mobile/utils/hash.dart';
|
||||
import 'package:isar/isar.dart';
|
||||
import 'package:logging/logging.dart';
|
||||
import 'package:openapi/api.dart';
|
||||
|
||||
class AuthenticationNotifier extends StateNotifier<AuthenticationState> {
|
||||
AuthenticationNotifier(
|
||||
this._apiService,
|
||||
this._db,
|
||||
this._ref,
|
||||
) : super(
|
||||
AuthenticationState(
|
||||
deviceId: "",
|
||||
userId: "",
|
||||
userEmail: "",
|
||||
name: '',
|
||||
profileImagePath: '',
|
||||
isAdmin: false,
|
||||
shouldChangePassword: false,
|
||||
isAuthenticated: false,
|
||||
),
|
||||
);
|
||||
|
||||
final ApiService _apiService;
|
||||
final Isar _db;
|
||||
final StateNotifierProviderRef<AuthenticationNotifier, AuthenticationState>
|
||||
_ref;
|
||||
final _log = Logger("AuthenticationNotifier");
|
||||
|
||||
static const Duration _timeoutDuration = Duration(seconds: 7);
|
||||
|
||||
Future<bool> login(
|
||||
String email,
|
||||
String password,
|
||||
String serverUrl,
|
||||
) async {
|
||||
try {
|
||||
// Resolve API server endpoint from user provided serverUrl
|
||||
await _apiService.resolveAndSetEndpoint(serverUrl);
|
||||
await _apiService.serverInfoApi.pingServer();
|
||||
} catch (e) {
|
||||
debugPrint('Invalid Server Endpoint Url $e');
|
||||
return false;
|
||||
}
|
||||
|
||||
// Make sign-in request
|
||||
DeviceInfoPlugin deviceInfoPlugin = DeviceInfoPlugin();
|
||||
|
||||
if (Platform.isIOS) {
|
||||
var iosInfo = await deviceInfoPlugin.iosInfo;
|
||||
_apiService.authenticationApi.apiClient
|
||||
.addDefaultHeader('deviceModel', iosInfo.utsname.machine);
|
||||
_apiService.authenticationApi.apiClient
|
||||
.addDefaultHeader('deviceType', 'iOS');
|
||||
} else {
|
||||
var androidInfo = await deviceInfoPlugin.androidInfo;
|
||||
_apiService.authenticationApi.apiClient
|
||||
.addDefaultHeader('deviceModel', androidInfo.model);
|
||||
_apiService.authenticationApi.apiClient
|
||||
.addDefaultHeader('deviceType', 'Android');
|
||||
}
|
||||
|
||||
try {
|
||||
var loginResponse = await _apiService.authenticationApi.login(
|
||||
LoginCredentialDto(
|
||||
email: email,
|
||||
password: password,
|
||||
),
|
||||
);
|
||||
|
||||
if (loginResponse == null) {
|
||||
debugPrint('Login Response is null');
|
||||
return false;
|
||||
}
|
||||
|
||||
return setSuccessLoginInfo(
|
||||
accessToken: loginResponse.accessToken,
|
||||
serverUrl: serverUrl,
|
||||
);
|
||||
} catch (e) {
|
||||
debugPrint("Error logging in $e");
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> logout() async {
|
||||
var log = Logger('AuthenticationNotifier');
|
||||
try {
|
||||
String? userEmail = Store.tryGet(StoreKey.currentUser)?.email;
|
||||
|
||||
await _apiService.authenticationApi
|
||||
.logout()
|
||||
.timeout(_timeoutDuration)
|
||||
.then((_) => log.info("Logout was successful for $userEmail"))
|
||||
.onError(
|
||||
(error, stackTrace) =>
|
||||
log.severe("Logout failed for $userEmail", error, stackTrace),
|
||||
);
|
||||
} catch (e, stack) {
|
||||
log.severe('Logout failed', e, stack);
|
||||
} finally {
|
||||
await Future.wait([
|
||||
clearAssetsAndAlbums(_db),
|
||||
Store.delete(StoreKey.currentUser),
|
||||
Store.delete(StoreKey.accessToken),
|
||||
]);
|
||||
_ref.invalidate(albumProvider);
|
||||
|
||||
state = state.copyWith(
|
||||
deviceId: "",
|
||||
userId: "",
|
||||
userEmail: "",
|
||||
name: '',
|
||||
profileImagePath: '',
|
||||
isAdmin: false,
|
||||
shouldChangePassword: false,
|
||||
isAuthenticated: false,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
updateUserProfileImagePath(String path) {
|
||||
state = state.copyWith(profileImagePath: path);
|
||||
}
|
||||
|
||||
Future<bool> changePassword(String newPassword) async {
|
||||
try {
|
||||
await _apiService.usersApi.updateMyUser(
|
||||
UserUpdateMeDto(
|
||||
password: newPassword,
|
||||
),
|
||||
);
|
||||
|
||||
state = state.copyWith(shouldChangePassword: false);
|
||||
|
||||
return true;
|
||||
} catch (e) {
|
||||
debugPrint("Error changing password $e");
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
Future<bool> setSuccessLoginInfo({
|
||||
required String accessToken,
|
||||
required String serverUrl,
|
||||
}) async {
|
||||
_apiService.setAccessToken(accessToken);
|
||||
|
||||
// Get the deviceid from the store if it exists, otherwise generate a new one
|
||||
String deviceId =
|
||||
Store.tryGet(StoreKey.deviceId) ?? await FlutterUdid.consistentUdid;
|
||||
|
||||
bool shouldChangePassword = false;
|
||||
User? user = Store.tryGet(StoreKey.currentUser);
|
||||
|
||||
UserAdminResponseDto? userResponse;
|
||||
UserPreferencesResponseDto? userPreferences;
|
||||
try {
|
||||
final responses = await Future.wait([
|
||||
_apiService.usersApi.getMyUser().timeout(_timeoutDuration),
|
||||
_apiService.usersApi.getMyPreferences().timeout(_timeoutDuration),
|
||||
]);
|
||||
userResponse = responses[0] as UserAdminResponseDto;
|
||||
userPreferences = responses[1] as UserPreferencesResponseDto;
|
||||
} on ApiException catch (error, stackTrace) {
|
||||
if (error.code == 401) {
|
||||
_log.severe("Unauthorized access, token likely expired. Logging out.");
|
||||
return false;
|
||||
}
|
||||
_log.severe(
|
||||
"Error getting user information from the server [API EXCEPTION]",
|
||||
stackTrace,
|
||||
);
|
||||
} catch (error, stackTrace) {
|
||||
_log.severe(
|
||||
"Error getting user information from the server [CATCH ALL]",
|
||||
error,
|
||||
stackTrace,
|
||||
);
|
||||
debugPrint(
|
||||
"Error getting user information from the server [CATCH ALL] $error $stackTrace",
|
||||
);
|
||||
}
|
||||
|
||||
// If the user information is successfully retrieved, update the store
|
||||
// Due to the flow of the code, this will always happen on first login
|
||||
if (userResponse != null) {
|
||||
Store.put(StoreKey.deviceId, deviceId);
|
||||
Store.put(StoreKey.deviceIdHash, fastHash(deviceId));
|
||||
Store.put(
|
||||
StoreKey.currentUser,
|
||||
User.fromUserDto(userResponse, userPreferences),
|
||||
);
|
||||
Store.put(StoreKey.serverUrl, serverUrl);
|
||||
Store.put(StoreKey.accessToken, accessToken);
|
||||
|
||||
shouldChangePassword = userResponse.shouldChangePassword;
|
||||
user = User.fromUserDto(userResponse, userPreferences);
|
||||
} else {
|
||||
_log.severe("Unable to get user information from the server.");
|
||||
}
|
||||
|
||||
// If the user is null, the login was not successful
|
||||
// and we don't have a local copy of the user from a prior successful login
|
||||
if (user == null) {
|
||||
return false;
|
||||
}
|
||||
|
||||
state = state.copyWith(
|
||||
isAuthenticated: true,
|
||||
userId: user.id,
|
||||
userEmail: user.email,
|
||||
name: user.name,
|
||||
profileImagePath: user.profileImagePath,
|
||||
isAdmin: user.isAdmin,
|
||||
shouldChangePassword: shouldChangePassword,
|
||||
deviceId: deviceId,
|
||||
);
|
||||
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
final authenticationProvider =
|
||||
StateNotifierProvider<AuthenticationNotifier, AuthenticationState>((ref) {
|
||||
return AuthenticationNotifier(
|
||||
ref.watch(apiServiceProvider),
|
||||
ref.watch(dbProvider),
|
||||
ref,
|
||||
);
|
||||
});
|
|
@ -22,8 +22,8 @@ import 'package:immich_mobile/repositories/backup.repository.dart';
|
|||
import 'package:immich_mobile/repositories/file_media.repository.dart';
|
||||
import 'package:immich_mobile/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),
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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 {
|
||||
|
|
30
mobile/lib/repositories/auth.repository.dart
Normal file
30
mobile/lib/repositories/auth.repository.dart
Normal 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(),
|
||||
]);
|
||||
});
|
||||
}
|
||||
}
|
56
mobile/lib/repositories/auth_api.repository.dart
Normal file
56
mobile/lib/repositories/auth_api.repository.dart
Normal file
|
@ -0,0 +1,56 @@
|
|||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:immich_mobile/interfaces/auth_api.interface.dart';
|
||||
import 'package:immich_mobile/models/auth/login_response.model.dart';
|
||||
import 'package:immich_mobile/providers/api.provider.dart';
|
||||
import 'package:immich_mobile/repositories/api.repository.dart';
|
||||
import 'package:immich_mobile/services/api.service.dart';
|
||||
import 'package:openapi/api.dart';
|
||||
|
||||
final authApiRepositoryProvider =
|
||||
Provider((ref) => AuthApiRepository(ref.watch(apiServiceProvider)));
|
||||
|
||||
class AuthApiRepository extends ApiRepository implements IAuthApiRepository {
|
||||
final ApiService _apiService;
|
||||
|
||||
AuthApiRepository(this._apiService);
|
||||
|
||||
@override
|
||||
Future<void> changePassword(String newPassword) async {
|
||||
await _apiService.usersApi.updateMyUser(
|
||||
UserUpdateMeDto(
|
||||
password: newPassword,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<LoginResponse> login(String email, String password) async {
|
||||
final loginResponseDto = await checkNull(
|
||||
_apiService.authenticationApi.login(
|
||||
LoginCredentialDto(
|
||||
email: email,
|
||||
password: password,
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
return _mapLoginReponse(loginResponseDto);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> logout() async {
|
||||
await _apiService.authenticationApi.logout().timeout(Duration(seconds: 7));
|
||||
}
|
||||
|
||||
_mapLoginReponse(LoginResponseDto dto) {
|
||||
return LoginResponse(
|
||||
accessToken: dto.accessToken,
|
||||
isAdmin: dto.isAdmin,
|
||||
name: dto.name,
|
||||
profileImagePath: dto.profileImagePath,
|
||||
shouldChangePassword: dto.shouldChangePassword,
|
||||
userEmail: dto.userEmail,
|
||||
userId: dto.userId,
|
||||
);
|
||||
}
|
||||
}
|
|
@ -1,5 +1,4 @@
|
|||
import 'dart:async';
|
||||
|
||||
import 'package:immich_mobile/interfaces/database.interface.dart';
|
||||
import 'package:isar/isar.dart';
|
||||
|
||||
|
|
|
@ -2,6 +2,7 @@ import 'dart:async';
|
|||
import 'dart:convert';
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:device_info_plus/device_info_plus.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:immich_mobile/entities/store.entity.dart';
|
||||
import 'package:immich_mobile/utils/url_helper.dart';
|
||||
|
@ -69,7 +70,7 @@ class ApiService implements Authentication {
|
|||
final endpoint = await _resolveEndpoint(serverUrl);
|
||||
setEndpoint(endpoint);
|
||||
|
||||
// Save in hivebox for next startup
|
||||
// Save in local database for next startup
|
||||
Store.put(StoreKey.serverEndpoint, endpoint);
|
||||
return endpoint;
|
||||
}
|
||||
|
@ -148,11 +149,27 @@ class ApiService implements Authentication {
|
|||
return "";
|
||||
}
|
||||
|
||||
setAccessToken(String accessToken) {
|
||||
void setAccessToken(String accessToken) {
|
||||
_accessToken = accessToken;
|
||||
Store.put(StoreKey.accessToken, accessToken);
|
||||
}
|
||||
|
||||
Future<void> setDeviceInfoHeader() async {
|
||||
DeviceInfoPlugin deviceInfoPlugin = DeviceInfoPlugin();
|
||||
|
||||
if (Platform.isIOS) {
|
||||
final iosInfo = await deviceInfoPlugin.iosInfo;
|
||||
authenticationApi.apiClient
|
||||
.addDefaultHeader('deviceModel', iosInfo.utsname.machine);
|
||||
authenticationApi.apiClient.addDefaultHeader('deviceType', 'iOS');
|
||||
} else {
|
||||
final androidInfo = await deviceInfoPlugin.androidInfo;
|
||||
authenticationApi.apiClient
|
||||
.addDefaultHeader('deviceModel', androidInfo.model);
|
||||
authenticationApi.apiClient.addDefaultHeader('deviceType', 'Android');
|
||||
}
|
||||
}
|
||||
|
||||
static Map<String, String> getRequestHeaders() {
|
||||
var accessToken = Store.get(StoreKey.accessToken, "");
|
||||
var customHeadersStr = Store.get(StoreKey.customHeaders, "");
|
||||
|
|
98
mobile/lib/services/auth.service.dart
Normal file
98
mobile/lib/services/auth.service.dart
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
24
mobile/lib/services/device.service.dart
Normal file
24
mobile/lib/services/device.service.dart
Normal file
|
@ -0,0 +1,24 @@
|
|||
import 'package:flutter_udid/flutter_udid.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:immich_mobile/entities/store.entity.dart';
|
||||
|
||||
final deviceServiceProvider = Provider((ref) => DeviceService());
|
||||
|
||||
class DeviceService {
|
||||
DeviceService();
|
||||
|
||||
createDeviceId() {
|
||||
return FlutterUdid.consistentUdid;
|
||||
}
|
||||
|
||||
/// Returns the device ID from local storage or creates a new one if not found.
|
||||
///
|
||||
/// This method first attempts to retrieve the device ID from the local store using
|
||||
/// [StoreKey.deviceId]. If no device ID is found (returns null), it generates a
|
||||
/// new device ID by calling [createDeviceId].
|
||||
///
|
||||
/// Returns a [String] representing the device's unique identifier.
|
||||
String getDeviceId() {
|
||||
return Store.tryGet(StoreKey.deviceId) ?? createDeviceId();
|
||||
}
|
||||
}
|
|
@ -35,8 +35,9 @@ class UserService {
|
|||
this._syncService,
|
||||
);
|
||||
|
||||
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 {
|
||||
|
|
|
@ -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);
|
||||
|
||||
|
|
|
@ -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) {
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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,
|
||||
),
|
||||
|
|
BIN
mobile/openapi/lib/model/server_config_dto.dart
generated
BIN
mobile/openapi/lib/model/server_config_dto.dart
generated
Binary file not shown.
BIN
mobile/openapi/lib/model/system_config_server_dto.dart
generated
BIN
mobile/openapi/lib/model/system_config_server_dto.dart
generated
Binary file not shown.
|
@ -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"
|
||||
|
|
|
@ -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
|
||||
|
||||
|
|
|
@ -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 {}
|
||||
|
|
118
mobile/test/services/auth.service_test.dart
Normal file
118
mobile/test/services/auth.service_test.dart
Normal file
|
@ -0,0 +1,118 @@
|
|||
import 'package:flutter/widgets.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:immich_mobile/entities/store.entity.dart';
|
||||
import 'package:immich_mobile/services/auth.service.dart';
|
||||
import 'package:mocktail/mocktail.dart';
|
||||
import '../repository.mocks.dart';
|
||||
import '../service.mocks.dart';
|
||||
import '../test_utils.dart';
|
||||
|
||||
void main() {
|
||||
late AuthService sut;
|
||||
late MockAuthApiRepository authApiRepository;
|
||||
late MockAuthRepository authRepository;
|
||||
late MockApiService apiService;
|
||||
|
||||
setUp(() async {
|
||||
authApiRepository = MockAuthApiRepository();
|
||||
authRepository = MockAuthRepository();
|
||||
apiService = MockApiService();
|
||||
sut = AuthService(authApiRepository, authRepository, apiService);
|
||||
});
|
||||
|
||||
group('validateServerUrl', () {
|
||||
setUpAll(() async {
|
||||
WidgetsFlutterBinding.ensureInitialized();
|
||||
final db = await TestUtils.initIsar();
|
||||
db.writeTxnSync(() => db.clearSync());
|
||||
Store.init(db);
|
||||
});
|
||||
|
||||
test('Should resolve HTTP endpoint', () async {
|
||||
const testUrl = 'http://ip:2283';
|
||||
const resolvedUrl = 'http://ip:2283/api';
|
||||
|
||||
when(() => apiService.resolveAndSetEndpoint(testUrl))
|
||||
.thenAnswer((_) async => resolvedUrl);
|
||||
when(() => apiService.setDeviceInfoHeader()).thenAnswer((_) async => {});
|
||||
|
||||
final result = await sut.validateServerUrl(testUrl);
|
||||
|
||||
expect(result, resolvedUrl);
|
||||
|
||||
verify(() => apiService.resolveAndSetEndpoint(testUrl)).called(1);
|
||||
verify(() => apiService.setDeviceInfoHeader()).called(1);
|
||||
});
|
||||
|
||||
test('Should resolve HTTPS endpoint', () async {
|
||||
const testUrl = 'https://immich.domain.com';
|
||||
const resolvedUrl = 'https://immich.domain.com/api';
|
||||
|
||||
when(() => apiService.resolveAndSetEndpoint(testUrl))
|
||||
.thenAnswer((_) async => resolvedUrl);
|
||||
when(() => apiService.setDeviceInfoHeader()).thenAnswer((_) async => {});
|
||||
|
||||
final result = await sut.validateServerUrl(testUrl);
|
||||
|
||||
expect(result, resolvedUrl);
|
||||
|
||||
verify(() => apiService.resolveAndSetEndpoint(testUrl)).called(1);
|
||||
verify(() => apiService.setDeviceInfoHeader()).called(1);
|
||||
});
|
||||
|
||||
test('Should throw error on invalid URL', () async {
|
||||
const testUrl = 'invalid-url';
|
||||
|
||||
when(() => apiService.resolveAndSetEndpoint(testUrl))
|
||||
.thenThrow(Exception('Invalid URL'));
|
||||
|
||||
expect(
|
||||
() async => await sut.validateServerUrl(testUrl),
|
||||
throwsA(isA<Exception>()),
|
||||
);
|
||||
|
||||
verify(() => apiService.resolveAndSetEndpoint(testUrl)).called(1);
|
||||
verifyNever(() => apiService.setDeviceInfoHeader());
|
||||
});
|
||||
|
||||
test('Should throw error on unreachable server', () async {
|
||||
const testUrl = 'https://unreachable.server';
|
||||
|
||||
when(() => apiService.resolveAndSetEndpoint(testUrl))
|
||||
.thenThrow(Exception('Server is not reachable'));
|
||||
|
||||
expect(
|
||||
() async => await sut.validateServerUrl(testUrl),
|
||||
throwsA(isA<Exception>()),
|
||||
);
|
||||
|
||||
verify(() => apiService.resolveAndSetEndpoint(testUrl)).called(1);
|
||||
verifyNever(() => apiService.setDeviceInfoHeader());
|
||||
});
|
||||
});
|
||||
|
||||
group('logout', () {
|
||||
test('Should logout user', () async {
|
||||
when(() => authApiRepository.logout()).thenAnswer((_) async => {});
|
||||
when(() => authRepository.clearLocalData())
|
||||
.thenAnswer((_) => Future.value(null));
|
||||
|
||||
await sut.logout();
|
||||
|
||||
verify(() => authApiRepository.logout()).called(1);
|
||||
verify(() => authRepository.clearLocalData()).called(1);
|
||||
});
|
||||
|
||||
test('Should clear local data even on server error', () async {
|
||||
when(() => authApiRepository.logout())
|
||||
.thenThrow(Exception('Server error'));
|
||||
when(() => authRepository.clearLocalData())
|
||||
.thenAnswer((_) => Future.value(null));
|
||||
|
||||
await sut.logout();
|
||||
|
||||
verify(() => authApiRepository.logout()).called(1);
|
||||
verify(() => authRepository.clearLocalData()).called(1);
|
||||
});
|
||||
});
|
||||
}
|
|
@ -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"
|
||||
},
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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: {
|
||||
|
|
|
@ -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')
|
||||
|
|
|
@ -144,6 +144,7 @@ export class ServerConfigDto {
|
|||
isInitialized!: boolean;
|
||||
isOnboarded!: boolean;
|
||||
externalDomain!: string;
|
||||
publicUsers!: boolean;
|
||||
mapDarkStyleUrl!: string;
|
||||
mapLightStyleUrl!: string;
|
||||
}
|
||||
|
|
|
@ -404,6 +404,9 @@ class SystemConfigServerDto {
|
|||
|
||||
@IsString()
|
||||
loginPageMessage!: string;
|
||||
|
||||
@IsBoolean()
|
||||
publicUsers!: boolean;
|
||||
}
|
||||
|
||||
class SystemConfigSmtpTransportDto {
|
||||
|
|
|
@ -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',
|
||||
});
|
||||
|
|
|
@ -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,
|
||||
};
|
||||
|
|
|
@ -133,6 +133,7 @@ const updatedConfig = Object.freeze<SystemConfig>({
|
|||
server: {
|
||||
externalDomain: '',
|
||||
loginPageMessage: '',
|
||||
publicUsers: true,
|
||||
},
|
||||
storageTemplate: {
|
||||
enabled: false,
|
||||
|
|
|
@ -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', () => {
|
||||
|
|
|
@ -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));
|
||||
}
|
||||
|
||||
|
|
5
server/test/fixtures/system-config.stub.ts
vendored
5
server/test/fixtures/system-config.stub.ts
vendored
|
@ -117,4 +117,9 @@ export const systemConfigStub = {
|
|||
},
|
||||
},
|
||||
},
|
||||
publicUsersDisabled: {
|
||||
server: {
|
||||
publicUsers: false,
|
||||
},
|
||||
},
|
||||
} satisfies Record<string, DeepPartial<SystemConfig>>;
|
||||
|
|
2
web/src/app.d.ts
vendored
2
web/src/app.d.ts
vendored
|
@ -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
|
||||
|
|
9
web/src/lib/__mocks__/visual-viewport.mock.ts
Normal file
9
web/src/lib/__mocks__/visual-viewport.mock.ts
Normal 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(),
|
||||
});
|
87
web/src/lib/actions/scroll-memory.ts
Normal file
87
web/src/lib/actions/scroll-memory.ts
Normal 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();
|
||||
},
|
||||
};
|
||||
}
|
67
web/src/lib/actions/use-actions.ts
Normal file
67
web/src/lib/actions/use-actions.ts
Normal 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?.();
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
|
@ -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'] })}
|
||||
|
|
|
@ -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}
|
||||
/>
|
||||
|
||||
|
|
|
@ -394,6 +394,7 @@
|
|||
let isFullScreen = $derived(fullscreenElement !== null);
|
||||
$effect(() => {
|
||||
if (asset) {
|
||||
previewStackedAsset = undefined;
|
||||
handlePromiseError(refreshStack());
|
||||
}
|
||||
});
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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('');
|
||||
});
|
||||
});
|
|
@ -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(() => {
|
||||
|
|
|
@ -218,7 +218,7 @@
|
|||
const getInputPosition = () => input?.getBoundingClientRect();
|
||||
|
||||
$effect(() => {
|
||||
// searchQuery = selectedOption ? selectedOption.label : '';
|
||||
searchQuery = selectedOption ? selectedOption.label : '';
|
||||
});
|
||||
|
||||
let filteredOptions = $derived(
|
||||
|
|
|
@ -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[]) => {
|
||||
|
|
|
@ -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}
|
|
@ -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}
|
|
@ -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>
|
||||
|
|
|
@ -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>
|
||||
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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>
|
|
@ -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',
|
||||
|
|
|
@ -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();
|
45
web/src/lib/stores/folders.svelte.ts
Normal file
45
web/src/lib/stores/folders.svelte.ts
Normal 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();
|
|
@ -34,6 +34,7 @@ export const serverConfig = writable<ServerConfig>({
|
|||
externalDomain: '',
|
||||
mapDarkStyleUrl: '',
|
||||
mapLightStyleUrl: '',
|
||||
publicUsers: true,
|
||||
});
|
||||
|
||||
export const retrieveServerConfig = async () => {
|
||||
|
|
|
@ -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';
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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 />
|
||||
|
|
|
@ -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()}>
|
||||
|
|
|
@ -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));
|
||||
|
||||
|
|
|
@ -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];
|
||||
|
|
|
@ -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}
|
||||
|
|
|
@ -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}
|
||||
|
|
|
@ -10,7 +10,7 @@
|
|||
"skipLibCheck": true,
|
||||
"sourceMap": true,
|
||||
"strict": true,
|
||||
"target": "es2020",
|
||||
"target": "es2022",
|
||||
"types": ["vitest/globals"]
|
||||
},
|
||||
"extends": "./.svelte-kit/tsconfig.json"
|
||||
|
|
Loading…
Reference in a new issue