1
0
forked from Cutlery/immich

Compare commits

..

6 Commits

Author SHA1 Message Date
Alex The Bot 430561d692 Version v1.98.1 2024-03-08 23:44:13 +00:00
Alex e8fb529026 fix(server): getAllAssets doesn't return all assets (#7752)
* fix(server): getAllAssets doesn't return all assets

* try reverting

* fix: archive and remove unused method

* update sql

* remove unused code

* linting
2024-03-08 17:16:32 -06:00
Sam Holton 7a4ae7d142 feat(server,web): add force delete to immediately remove user (#7681)
* feat(server,web): add force delete to immediately remove user

* update wording on force delete confirmation

* fix force delete css

* PR feedback

* cleanup user service delete for force

* adding user status column

* some cleanup and tests

* more test fixes

* run npm run sql:generate

* chore: cleanup and websocket

* chore: linting

* userRepository.restore

* removed bad color class from delete-confirm-dialoge

* additional confirmation for user force delete

* shorten confirmation message

---------

Co-authored-by: Jason Rasmussen <jrasm91@gmail.com>
2024-03-08 17:49:39 -05:00
Andrew Roberts 9cb0a1ffbf fix(web): modal password reset modal in dark mode (#7748)
* Fixed dark mode password reset success

* Fixed prettier issue
2024-03-08 14:05:15 -05:00
Michel Heusschen fa32c6660c fix(web): album state after removing assets (#7745)
* fix(web): album state after removing assets

* refresh album on remove + simplify AlbumSummary
2024-03-08 14:03:37 -05:00
DeclanE fe8c6b17a6 chore: rename "Library" to "External Library" in system settings (#7744)
* Change "Library" > "External Library" under system settings

This is intended to assist with any confusion regarding standard libraries

* Changed key from "library" to "external-library"

* Updated "Encode Clip" to "Smart Search"
2024-03-08 16:49:44 +00:00
80 changed files with 795 additions and 398 deletions
+1 -1
View File
@@ -46,7 +46,7 @@
},
"../open-api/typescript-sdk": {
"name": "@immich/sdk",
"version": "1.98.0",
"version": "1.98.1",
"dev": true,
"license": "GNU Affero General Public License version 3",
"devDependencies": {
+1 -1
View File
@@ -79,7 +79,7 @@
},
"../open-api/typescript-sdk": {
"name": "@immich/sdk",
"version": "1.98.0",
"version": "1.98.1",
"dev": true,
"license": "GNU Affero General Public License version 3",
"devDependencies": {
+1
View File
@@ -76,6 +76,7 @@ export const signupResponseDto = {
memoriesEnabled: true,
quotaUsageInBytes: 0,
quotaSizeInBytes: null,
status: 'active',
},
};
+1 -1
View File
@@ -1,6 +1,6 @@
[tool.poetry]
name = "machine-learning"
version = "1.98.0"
version = "1.98.1"
description = ""
authors = ["Hau Tran <alex.tran1502@gmail.com>"]
readme = "README.md"
+2 -2
View File
@@ -35,8 +35,8 @@ platform :android do
task: 'bundle',
build_type: 'Release',
properties: {
"android.injected.version.code" => 126,
"android.injected.version.name" => "1.98.0",
"android.injected.version.code" => 127,
"android.injected.version.name" => "1.98.1",
}
)
upload_to_play_store(skip_upload_apk: true, skip_upload_images: true, skip_upload_screenshots: true, aab: '../build/app/outputs/bundle/release/app-release.aab')
+1 -1
View File
@@ -19,7 +19,7 @@ platform :ios do
desc "iOS Beta"
lane :beta do
increment_version_number(
version_number: "1.98.0"
version_number: "1.98.1"
)
increment_build_number(
build_number: latest_testflight_build_number + 1,
+7 -15
View File
@@ -49,23 +49,16 @@ class AssetService {
return changes;
}
/// Returns `(null, null, time)` if changes are invalid -> requires full sync
Future<(List<Asset>? toUpsert, List<String>? toDelete, DateTime? time)>
/// Returns `(null, null)` if changes are invalid -> requires full sync
Future<(List<Asset>? toUpsert, List<String>? toDelete)>
_getRemoteAssetChanges(User user, DateTime since) async {
final deleted = await _apiService.auditApi
.getAuditDeletes(since, EntityType.ASSET, userId: user.id);
if (deleted == null || deleted.needsFullSync) {
return (null, null, deleted?.requestedAt);
}
if (deleted == null || deleted.needsFullSync) return (null, null);
final assetDto = await _apiService.assetApi
.getAllAssets(userId: user.id, updatedAfter: since);
if (assetDto == null) return (null, null, deleted.requestedAt);
return (
assetDto.map(Asset.remote).toList(),
deleted.ids,
deleted.requestedAt
);
if (assetDto == null) return (null, null);
return (assetDto.map(Asset.remote).toList(), deleted.ids);
}
/// Returns the list of people of the given asset id.
@@ -90,11 +83,10 @@ class AssetService {
}
/// Returns `null` if the server state did not change, else list of assets
Future<List<Asset>?> _getRemoteAssets(User user, DateTime now) async {
Future<List<Asset>?> _getRemoteAssets(User user) async {
const int chunkSize = 10000;
try {
final DateTime now = DateTime.now().toUtc();
final List<Asset> allAssets = [];
for (int i = 0;; i += chunkSize) {
final List<AssetResponseDto>? assets =
+22 -28
View File
@@ -41,20 +41,17 @@ class SyncService {
/// Returns `true` if there were any changes
Future<bool> syncRemoteAssetsToDb(
User user,
Future<(List<Asset>? toUpsert, List<String>? toDelete, DateTime? time)>
Function(
Future<(List<Asset>? toUpsert, List<String>? toDelete)> Function(
User user,
DateTime since,
) getChangedAssets,
FutureOr<List<Asset>?> Function(User user, DateTime now) loadAssets,
FutureOr<List<Asset>?> Function(User user) loadAssets,
) =>
_lock.run(() async {
final (changes, serverTime) =
await _syncRemoteAssetChanges(user, getChangedAssets);
if (changes != null) return changes;
final time = serverTime ?? DateTime.now().toUtc();
return await _syncRemoteAssetsFull(user, time, loadAssets);
});
_lock.run(
() async =>
await _syncRemoteAssetChanges(user, getChangedAssets) ??
await _syncRemoteAssetsFull(user, loadAssets),
);
/// Syncs remote albums to the database
/// returns `true` if there were any changes
@@ -149,22 +146,19 @@ class SyncService {
return true;
}
/// Efficiently syncs assets via changes. Returns `(null, serverTime)` when a full sync is required.
Future<(bool?, DateTime? serverTime)> _syncRemoteAssetChanges(
/// Efficiently syncs assets via changes. Returns `null` when a full sync is required.
Future<bool?> _syncRemoteAssetChanges(
User user,
Future<(List<Asset>? toUpsert, List<String>? toDelete, DateTime? time)>
Function(
Future<(List<Asset>? toUpsert, List<String>? toDelete)> Function(
User user,
DateTime since,
) getChangedAssets,
) async {
final DateTime? since = _db.eTags.getByIdSync(user.id)?.time?.toUtc();
final DateTime now = DateTime.now().toUtc();
final (toUpsert, toDelete, serverTime) =
await getChangedAssets(user, since ?? now);
if (since == null || toUpsert == null || toDelete == null) {
return (null, serverTime);
}
if (since == null) return null;
final DateTime now = DateTime.now();
final (toUpsert, toDelete) = await getChangedAssets(user, since);
if (toUpsert == null || toDelete == null) return null;
try {
if (toDelete.isNotEmpty) {
await handleRemoteAssetRemoval(toDelete);
@@ -174,14 +168,14 @@ class SyncService {
await upsertAssetsWithExif(updated);
}
if (toUpsert.isNotEmpty || toDelete.isNotEmpty) {
await _updateUserAssetsETag(user, serverTime ?? now);
return (true, serverTime);
await _updateUserAssetsETag(user, now);
return true;
}
return (false, serverTime);
return false;
} on IsarError catch (e) {
_log.severe("Failed to sync remote assets to db", e);
}
return (null, serverTime);
return null;
}
/// Deletes remote-only assets, updates merged assets to be local-only
@@ -208,11 +202,11 @@ class SyncService {
/// Syncs assets by loading and comparing all assets from the server.
Future<bool> _syncRemoteAssetsFull(
final User user,
final DateTime now,
final FutureOr<List<Asset>?> Function(User user, DateTime now) loadAssets,
User user,
FutureOr<List<Asset>?> Function(User user) loadAssets,
) async {
final List<Asset>? remote = await loadAssets(user, now);
final DateTime now = DateTime.now().toUtc();
final List<Asset>? remote = await loadAssets(user);
if (remote == null) {
return false;
}
+6
View File
@@ -58,6 +58,7 @@ doc/CreateTagDto.md
doc/CreateUserDto.md
doc/CuratedLocationsResponseDto.md
doc/CuratedObjectsResponseDto.md
doc/DeleteUserDto.md
doc/DownloadApi.md
doc/DownloadArchiveInfo.md
doc/DownloadInfoDto.md
@@ -184,6 +185,7 @@ doc/UserApi.md
doc/UserAvatarColor.md
doc/UserDto.md
doc/UserResponseDto.md
doc/UserStatus.md
doc/ValidateAccessTokenResponseDto.md
doc/ValidateLibraryDto.md
doc/ValidateLibraryImportPathResponseDto.md
@@ -268,6 +270,7 @@ lib/model/create_tag_dto.dart
lib/model/create_user_dto.dart
lib/model/curated_locations_response_dto.dart
lib/model/curated_objects_response_dto.dart
lib/model/delete_user_dto.dart
lib/model/download_archive_info.dart
lib/model/download_info_dto.dart
lib/model/download_response_dto.dart
@@ -380,6 +383,7 @@ lib/model/usage_by_user_dto.dart
lib/model/user_avatar_color.dart
lib/model/user_dto.dart
lib/model/user_response_dto.dart
lib/model/user_status.dart
lib/model/validate_access_token_response_dto.dart
lib/model/validate_library_dto.dart
lib/model/validate_library_import_path_response_dto.dart
@@ -441,6 +445,7 @@ test/create_tag_dto_test.dart
test/create_user_dto_test.dart
test/curated_locations_response_dto_test.dart
test/curated_objects_response_dto_test.dart
test/delete_user_dto_test.dart
test/download_api_test.dart
test/download_archive_info_test.dart
test/download_info_dto_test.dart
@@ -567,6 +572,7 @@ test/user_api_test.dart
test/user_avatar_color_test.dart
test/user_dto_test.dart
test/user_response_dto_test.dart
test/user_status_test.dart
test/validate_access_token_response_dto_test.dart
test/validate_library_dto_test.dart
test/validate_library_import_path_response_dto_test.dart
+3 -1
View File
@@ -3,7 +3,7 @@ Immich API
This Dart package is automatically generated by the [OpenAPI Generator](https://openapi-generator.tech) project:
- API version: 1.98.0
- API version: 1.98.1
- Build package: org.openapitools.codegen.languages.DartClientCodegen
## Requirements
@@ -264,6 +264,7 @@ Class | Method | HTTP request | Description
- [CreateUserDto](doc//CreateUserDto.md)
- [CuratedLocationsResponseDto](doc//CuratedLocationsResponseDto.md)
- [CuratedObjectsResponseDto](doc//CuratedObjectsResponseDto.md)
- [DeleteUserDto](doc//DeleteUserDto.md)
- [DownloadArchiveInfo](doc//DownloadArchiveInfo.md)
- [DownloadInfoDto](doc//DownloadInfoDto.md)
- [DownloadResponseDto](doc//DownloadResponseDto.md)
@@ -376,6 +377,7 @@ Class | Method | HTTP request | Description
- [UserAvatarColor](doc//UserAvatarColor.md)
- [UserDto](doc//UserDto.md)
- [UserResponseDto](doc//UserResponseDto.md)
- [UserStatus](doc//UserStatus.md)
- [ValidateAccessTokenResponseDto](doc//ValidateAccessTokenResponseDto.md)
- [ValidateLibraryDto](doc//ValidateLibraryDto.md)
- [ValidateLibraryImportPathResponseDto](doc//ValidateLibraryImportPathResponseDto.md)
-1
View File
@@ -10,7 +10,6 @@ Name | Type | Description | Notes
------------ | ------------- | ------------- | -------------
**ids** | **List<String>** | | [default to const []]
**needsFullSync** | **bool** | |
**requestedAt** | [**DateTime**](DateTime.md) | | [optional]
[[Back to Model list]](../README.md#documentation-for-models) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to README]](../README.md)
+15
View File
@@ -0,0 +1,15 @@
# openapi.model.DeleteUserDto
## Load the model package
```dart
import 'package:openapi/api.dart';
```
## Properties
Name | Type | Description | Notes
------------ | ------------- | ------------- | -------------
**force** | **bool** | | [optional]
[[Back to Model list]](../README.md#documentation-for-models) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to README]](../README.md)
+1
View File
@@ -22,6 +22,7 @@ Name | Type | Description | Notes
**quotaSizeInBytes** | **int** | |
**quotaUsageInBytes** | **int** | |
**shouldChangePassword** | **bool** | |
**status** | [**UserStatus**](UserStatus.md) | |
**storageLabel** | **String** | |
**updatedAt** | [**DateTime**](DateTime.md) | |
+5 -3
View File
@@ -182,7 +182,7 @@ void (empty response body)
[[Back to top]](#) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to Model list]](../README.md#documentation-for-models) [[Back to README]](../README.md)
# **deleteUser**
> UserResponseDto deleteUser(id)
> UserResponseDto deleteUser(id, deleteUserDto)
@@ -206,9 +206,10 @@ import 'package:openapi/api.dart';
final api_instance = UserApi();
final id = 38400000-8cf0-11bd-b23e-10b96e4ef00d; // String |
final deleteUserDto = DeleteUserDto(); // DeleteUserDto |
try {
final result = api_instance.deleteUser(id);
final result = api_instance.deleteUser(id, deleteUserDto);
print(result);
} catch (e) {
print('Exception when calling UserApi->deleteUser: $e\n');
@@ -220,6 +221,7 @@ try {
Name | Type | Description | Notes
------------- | ------------- | ------------- | -------------
**id** | **String**| |
**deleteUserDto** | [**DeleteUserDto**](DeleteUserDto.md)| |
### Return type
@@ -231,7 +233,7 @@ Name | Type | Description | Notes
### HTTP request headers
- **Content-Type**: Not defined
- **Content-Type**: application/json
- **Accept**: application/json
[[Back to top]](#) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to Model list]](../README.md#documentation-for-models) [[Back to README]](../README.md)
+1
View File
@@ -21,6 +21,7 @@ Name | Type | Description | Notes
**quotaSizeInBytes** | **int** | |
**quotaUsageInBytes** | **int** | |
**shouldChangePassword** | **bool** | |
**status** | [**UserStatus**](UserStatus.md) | |
**storageLabel** | **String** | |
**updatedAt** | [**DateTime**](DateTime.md) | |
+14
View File
@@ -0,0 +1,14 @@
# openapi.model.UserStatus
## Load the model package
```dart
import 'package:openapi/api.dart';
```
## Properties
Name | Type | Description | Notes
------------ | ------------- | ------------- | -------------
[[Back to Model list]](../README.md#documentation-for-models) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to README]](../README.md)
+2
View File
@@ -99,6 +99,7 @@ part 'model/create_tag_dto.dart';
part 'model/create_user_dto.dart';
part 'model/curated_locations_response_dto.dart';
part 'model/curated_objects_response_dto.dart';
part 'model/delete_user_dto.dart';
part 'model/download_archive_info.dart';
part 'model/download_info_dto.dart';
part 'model/download_response_dto.dart';
@@ -211,6 +212,7 @@ part 'model/usage_by_user_dto.dart';
part 'model/user_avatar_color.dart';
part 'model/user_dto.dart';
part 'model/user_response_dto.dart';
part 'model/user_status.dart';
part 'model/validate_access_token_response_dto.dart';
part 'model/validate_library_dto.dart';
part 'model/validate_library_import_path_response_dto.dart';
+9 -5
View File
@@ -157,19 +157,21 @@ class UserApi {
/// Parameters:
///
/// * [String] id (required):
Future<Response> deleteUserWithHttpInfo(String id,) async {
///
/// * [DeleteUserDto] deleteUserDto (required):
Future<Response> deleteUserWithHttpInfo(String id, DeleteUserDto deleteUserDto,) async {
// ignore: prefer_const_declarations
final path = r'/user/{id}'
.replaceAll('{id}', id);
// ignore: prefer_final_locals
Object? postBody;
Object? postBody = deleteUserDto;
final queryParams = <QueryParam>[];
final headerParams = <String, String>{};
final formParams = <String, String>{};
const contentTypes = <String>[];
const contentTypes = <String>['application/json'];
return apiClient.invokeAPI(
@@ -186,8 +188,10 @@ class UserApi {
/// Parameters:
///
/// * [String] id (required):
Future<UserResponseDto?> deleteUser(String id,) async {
final response = await deleteUserWithHttpInfo(id,);
///
/// * [DeleteUserDto] deleteUserDto (required):
Future<UserResponseDto?> deleteUser(String id, DeleteUserDto deleteUserDto,) async {
final response = await deleteUserWithHttpInfo(id, deleteUserDto,);
if (response.statusCode >= HttpStatus.badRequest) {
throw ApiException(response.statusCode, await _decodeBodyBytes(response));
}
+4
View File
@@ -280,6 +280,8 @@ class ApiClient {
return CuratedLocationsResponseDto.fromJson(value);
case 'CuratedObjectsResponseDto':
return CuratedObjectsResponseDto.fromJson(value);
case 'DeleteUserDto':
return DeleteUserDto.fromJson(value);
case 'DownloadArchiveInfo':
return DownloadArchiveInfo.fromJson(value);
case 'DownloadInfoDto':
@@ -504,6 +506,8 @@ class ApiClient {
return UserDto.fromJson(value);
case 'UserResponseDto':
return UserResponseDto.fromJson(value);
case 'UserStatus':
return UserStatusTypeTransformer().decode(value);
case 'ValidateAccessTokenResponseDto':
return ValidateAccessTokenResponseDto.fromJson(value);
case 'ValidateLibraryDto':
+3
View File
@@ -136,6 +136,9 @@ String parameterToString(dynamic value) {
if (value is UserAvatarColor) {
return UserAvatarColorTypeTransformer().encode(value).toString();
}
if (value is UserStatus) {
return UserStatusTypeTransformer().encode(value).toString();
}
if (value is VideoCodec) {
return VideoCodecTypeTransformer().encode(value).toString();
}
+13 -41
View File
@@ -15,49 +15,30 @@ class AuditDeletesResponseDto {
AuditDeletesResponseDto({
this.ids = const [],
required this.needsFullSync,
this.requestedAt,
});
List<String> ids;
bool needsFullSync;
///
/// Please note: This property should have been non-nullable! Since the specification file
/// does not include a default value (using the "default:" property), however, the generated
/// source code must fall back to having a nullable type.
/// Consider adding a "default:" property in the specification file to hide this note.
///
DateTime? requestedAt;
@override
bool operator ==(Object other) =>
identical(this, other) ||
other is AuditDeletesResponseDto &&
other.ids == ids &&
other.needsFullSync == needsFullSync &&
other.requestedAt == requestedAt;
bool operator ==(Object other) => identical(this, other) || other is AuditDeletesResponseDto &&
_deepEquality.equals(other.ids, ids) &&
other.needsFullSync == needsFullSync;
@override
int get hashCode =>
// ignore: unnecessary_parenthesis
(ids.hashCode) +
(needsFullSync.hashCode) +
(requestedAt == null ? 0 : requestedAt!.hashCode);
// ignore: unnecessary_parenthesis
(ids.hashCode) +
(needsFullSync.hashCode);
@override
String toString() =>
'AuditDeletesResponseDto[ids=$ids, needsFullSync=$needsFullSync, requestedAt=$requestedAt]';
String toString() => 'AuditDeletesResponseDto[ids=$ids, needsFullSync=$needsFullSync]';
Map<String, dynamic> toJson() {
final json = <String, dynamic>{};
json[r'ids'] = this.ids;
json[r'needsFullSync'] = this.needsFullSync;
if (this.requestedAt != null) {
json[r'requestedAt'] = this.requestedAt!.toUtc().toIso8601String();
} else {
// json[r'requestedAt'] = null;
}
json[r'ids'] = this.ids;
json[r'needsFullSync'] = this.needsFullSync;
return json;
}
@@ -73,16 +54,12 @@ class AuditDeletesResponseDto {
? (json[r'ids'] as Iterable).cast<String>().toList(growable: false)
: const [],
needsFullSync: mapValueOfType<bool>(json, r'needsFullSync')!,
requestedAt: mapDateTime(json, r'requestedAt', ''),
);
}
return null;
}
static List<AuditDeletesResponseDto> listFromJson(
dynamic json, {
bool growable = false,
}) {
static List<AuditDeletesResponseDto> listFromJson(dynamic json, {bool growable = false,}) {
final result = <AuditDeletesResponseDto>[];
if (json is List && json.isNotEmpty) {
for (final row in json) {
@@ -110,19 +87,13 @@ class AuditDeletesResponseDto {
}
// maps a json object with a list of AuditDeletesResponseDto-objects as value to a dart map
static Map<String, List<AuditDeletesResponseDto>> mapListFromJson(
dynamic json, {
bool growable = false,
}) {
static Map<String, List<AuditDeletesResponseDto>> mapListFromJson(dynamic json, {bool growable = false,}) {
final map = <String, List<AuditDeletesResponseDto>>{};
if (json is Map && json.isNotEmpty) {
// ignore: parameter_assignments
json = json.cast<String, dynamic>();
for (final entry in json.entries) {
map[entry.key] = AuditDeletesResponseDto.listFromJson(
entry.value,
growable: growable,
);
map[entry.key] = AuditDeletesResponseDto.listFromJson(entry.value, growable: growable,);
}
}
return map;
@@ -134,3 +105,4 @@ class AuditDeletesResponseDto {
'needsFullSync',
};
}
+107
View File
@@ -0,0 +1,107 @@
//
// AUTO-GENERATED FILE, DO NOT MODIFY!
//
// @dart=2.12
// ignore_for_file: unused_element, unused_import
// ignore_for_file: always_put_required_named_parameters_first
// ignore_for_file: constant_identifier_names
// ignore_for_file: lines_longer_than_80_chars
part of openapi.api;
class DeleteUserDto {
/// Returns a new [DeleteUserDto] instance.
DeleteUserDto({
this.force,
});
///
/// Please note: This property should have been non-nullable! Since the specification file
/// does not include a default value (using the "default:" property), however, the generated
/// source code must fall back to having a nullable type.
/// Consider adding a "default:" property in the specification file to hide this note.
///
bool? force;
@override
bool operator ==(Object other) => identical(this, other) || other is DeleteUserDto &&
other.force == force;
@override
int get hashCode =>
// ignore: unnecessary_parenthesis
(force == null ? 0 : force!.hashCode);
@override
String toString() => 'DeleteUserDto[force=$force]';
Map<String, dynamic> toJson() {
final json = <String, dynamic>{};
if (this.force != null) {
json[r'force'] = this.force;
} else {
// json[r'force'] = null;
}
return json;
}
/// Returns a new [DeleteUserDto] instance and imports its values from
/// [value] if it's a [Map], null otherwise.
// ignore: prefer_constructors_over_static_methods
static DeleteUserDto? fromJson(dynamic value) {
if (value is Map) {
final json = value.cast<String, dynamic>();
return DeleteUserDto(
force: mapValueOfType<bool>(json, r'force'),
);
}
return null;
}
static List<DeleteUserDto> listFromJson(dynamic json, {bool growable = false,}) {
final result = <DeleteUserDto>[];
if (json is List && json.isNotEmpty) {
for (final row in json) {
final value = DeleteUserDto.fromJson(row);
if (value != null) {
result.add(value);
}
}
}
return result.toList(growable: growable);
}
static Map<String, DeleteUserDto> mapFromJson(dynamic json) {
final map = <String, DeleteUserDto>{};
if (json is Map && json.isNotEmpty) {
json = json.cast<String, dynamic>(); // ignore: parameter_assignments
for (final entry in json.entries) {
final value = DeleteUserDto.fromJson(entry.value);
if (value != null) {
map[entry.key] = value;
}
}
}
return map;
}
// maps a json object with a list of DeleteUserDto-objects as value to a dart map
static Map<String, List<DeleteUserDto>> mapListFromJson(dynamic json, {bool growable = false,}) {
final map = <String, List<DeleteUserDto>>{};
if (json is Map && json.isNotEmpty) {
// ignore: parameter_assignments
json = json.cast<String, dynamic>();
for (final entry in json.entries) {
map[entry.key] = DeleteUserDto.listFromJson(entry.value, growable: growable,);
}
}
return map;
}
/// The list of required keys that must be present in a JSON.
static const requiredKeys = <String>{
};
}
+9 -1
View File
@@ -27,6 +27,7 @@ class PartnerResponseDto {
required this.quotaSizeInBytes,
required this.quotaUsageInBytes,
required this.shouldChangePassword,
required this.status,
required this.storageLabel,
required this.updatedAt,
});
@@ -71,6 +72,8 @@ class PartnerResponseDto {
bool shouldChangePassword;
UserStatus status;
String? storageLabel;
DateTime updatedAt;
@@ -91,6 +94,7 @@ class PartnerResponseDto {
other.quotaSizeInBytes == quotaSizeInBytes &&
other.quotaUsageInBytes == quotaUsageInBytes &&
other.shouldChangePassword == shouldChangePassword &&
other.status == status &&
other.storageLabel == storageLabel &&
other.updatedAt == updatedAt;
@@ -111,11 +115,12 @@ class PartnerResponseDto {
(quotaSizeInBytes == null ? 0 : quotaSizeInBytes!.hashCode) +
(quotaUsageInBytes == null ? 0 : quotaUsageInBytes!.hashCode) +
(shouldChangePassword.hashCode) +
(status.hashCode) +
(storageLabel == null ? 0 : storageLabel!.hashCode) +
(updatedAt.hashCode);
@override
String toString() => 'PartnerResponseDto[avatarColor=$avatarColor, createdAt=$createdAt, deletedAt=$deletedAt, email=$email, id=$id, inTimeline=$inTimeline, isAdmin=$isAdmin, memoriesEnabled=$memoriesEnabled, name=$name, oauthId=$oauthId, profileImagePath=$profileImagePath, quotaSizeInBytes=$quotaSizeInBytes, quotaUsageInBytes=$quotaUsageInBytes, shouldChangePassword=$shouldChangePassword, storageLabel=$storageLabel, updatedAt=$updatedAt]';
String toString() => 'PartnerResponseDto[avatarColor=$avatarColor, createdAt=$createdAt, deletedAt=$deletedAt, email=$email, id=$id, inTimeline=$inTimeline, isAdmin=$isAdmin, memoriesEnabled=$memoriesEnabled, name=$name, oauthId=$oauthId, profileImagePath=$profileImagePath, quotaSizeInBytes=$quotaSizeInBytes, quotaUsageInBytes=$quotaUsageInBytes, shouldChangePassword=$shouldChangePassword, status=$status, storageLabel=$storageLabel, updatedAt=$updatedAt]';
Map<String, dynamic> toJson() {
final json = <String, dynamic>{};
@@ -153,6 +158,7 @@ class PartnerResponseDto {
// json[r'quotaUsageInBytes'] = null;
}
json[r'shouldChangePassword'] = this.shouldChangePassword;
json[r'status'] = this.status;
if (this.storageLabel != null) {
json[r'storageLabel'] = this.storageLabel;
} else {
@@ -184,6 +190,7 @@ class PartnerResponseDto {
quotaSizeInBytes: mapValueOfType<int>(json, r'quotaSizeInBytes'),
quotaUsageInBytes: mapValueOfType<int>(json, r'quotaUsageInBytes'),
shouldChangePassword: mapValueOfType<bool>(json, r'shouldChangePassword')!,
status: UserStatus.fromJson(json[r'status'])!,
storageLabel: mapValueOfType<String>(json, r'storageLabel'),
updatedAt: mapDateTime(json, r'updatedAt', r'')!,
);
@@ -245,6 +252,7 @@ class PartnerResponseDto {
'quotaSizeInBytes',
'quotaUsageInBytes',
'shouldChangePassword',
'status',
'storageLabel',
'updatedAt',
};
+9 -1
View File
@@ -26,6 +26,7 @@ class UserResponseDto {
required this.quotaSizeInBytes,
required this.quotaUsageInBytes,
required this.shouldChangePassword,
required this.status,
required this.storageLabel,
required this.updatedAt,
});
@@ -62,6 +63,8 @@ class UserResponseDto {
bool shouldChangePassword;
UserStatus status;
String? storageLabel;
DateTime updatedAt;
@@ -81,6 +84,7 @@ class UserResponseDto {
other.quotaSizeInBytes == quotaSizeInBytes &&
other.quotaUsageInBytes == quotaUsageInBytes &&
other.shouldChangePassword == shouldChangePassword &&
other.status == status &&
other.storageLabel == storageLabel &&
other.updatedAt == updatedAt;
@@ -100,11 +104,12 @@ class UserResponseDto {
(quotaSizeInBytes == null ? 0 : quotaSizeInBytes!.hashCode) +
(quotaUsageInBytes == null ? 0 : quotaUsageInBytes!.hashCode) +
(shouldChangePassword.hashCode) +
(status.hashCode) +
(storageLabel == null ? 0 : storageLabel!.hashCode) +
(updatedAt.hashCode);
@override
String toString() => 'UserResponseDto[avatarColor=$avatarColor, createdAt=$createdAt, deletedAt=$deletedAt, email=$email, id=$id, isAdmin=$isAdmin, memoriesEnabled=$memoriesEnabled, name=$name, oauthId=$oauthId, profileImagePath=$profileImagePath, quotaSizeInBytes=$quotaSizeInBytes, quotaUsageInBytes=$quotaUsageInBytes, shouldChangePassword=$shouldChangePassword, storageLabel=$storageLabel, updatedAt=$updatedAt]';
String toString() => 'UserResponseDto[avatarColor=$avatarColor, createdAt=$createdAt, deletedAt=$deletedAt, email=$email, id=$id, isAdmin=$isAdmin, memoriesEnabled=$memoriesEnabled, name=$name, oauthId=$oauthId, profileImagePath=$profileImagePath, quotaSizeInBytes=$quotaSizeInBytes, quotaUsageInBytes=$quotaUsageInBytes, shouldChangePassword=$shouldChangePassword, status=$status, storageLabel=$storageLabel, updatedAt=$updatedAt]';
Map<String, dynamic> toJson() {
final json = <String, dynamic>{};
@@ -137,6 +142,7 @@ class UserResponseDto {
// json[r'quotaUsageInBytes'] = null;
}
json[r'shouldChangePassword'] = this.shouldChangePassword;
json[r'status'] = this.status;
if (this.storageLabel != null) {
json[r'storageLabel'] = this.storageLabel;
} else {
@@ -167,6 +173,7 @@ class UserResponseDto {
quotaSizeInBytes: mapValueOfType<int>(json, r'quotaSizeInBytes'),
quotaUsageInBytes: mapValueOfType<int>(json, r'quotaUsageInBytes'),
shouldChangePassword: mapValueOfType<bool>(json, r'shouldChangePassword')!,
status: UserStatus.fromJson(json[r'status'])!,
storageLabel: mapValueOfType<String>(json, r'storageLabel'),
updatedAt: mapDateTime(json, r'updatedAt', r'')!,
);
@@ -228,6 +235,7 @@ class UserResponseDto {
'quotaSizeInBytes',
'quotaUsageInBytes',
'shouldChangePassword',
'status',
'storageLabel',
'updatedAt',
};
+88
View File
@@ -0,0 +1,88 @@
//
// AUTO-GENERATED FILE, DO NOT MODIFY!
//
// @dart=2.12
// ignore_for_file: unused_element, unused_import
// ignore_for_file: always_put_required_named_parameters_first
// ignore_for_file: constant_identifier_names
// ignore_for_file: lines_longer_than_80_chars
part of openapi.api;
class UserStatus {
/// Instantiate a new enum with the provided [value].
const UserStatus._(this.value);
/// The underlying value of this enum member.
final String value;
@override
String toString() => value;
String toJson() => value;
static const active = UserStatus._(r'active');
static const removing = UserStatus._(r'removing');
static const deleted = UserStatus._(r'deleted');
/// List of all possible values in this [enum][UserStatus].
static const values = <UserStatus>[
active,
removing,
deleted,
];
static UserStatus? fromJson(dynamic value) => UserStatusTypeTransformer().decode(value);
static List<UserStatus> listFromJson(dynamic json, {bool growable = false,}) {
final result = <UserStatus>[];
if (json is List && json.isNotEmpty) {
for (final row in json) {
final value = UserStatus.fromJson(row);
if (value != null) {
result.add(value);
}
}
}
return result.toList(growable: growable);
}
}
/// Transformation class that can [encode] an instance of [UserStatus] to String,
/// and [decode] dynamic data back to [UserStatus].
class UserStatusTypeTransformer {
factory UserStatusTypeTransformer() => _instance ??= const UserStatusTypeTransformer._();
const UserStatusTypeTransformer._();
String encode(UserStatus data) => data.value;
/// Decodes a [dynamic value][data] to a UserStatus.
///
/// If [allowNull] is true and the [dynamic value][data] cannot be decoded successfully,
/// then null is returned. However, if [allowNull] is false and the [dynamic value][data]
/// cannot be decoded successfully, then an [UnimplementedError] is thrown.
///
/// The [allowNull] is very handy when an API changes and a new enum value is added or removed,
/// and users are still using an old app with the old code.
UserStatus? decode(dynamic data, {bool allowNull = true}) {
if (data != null) {
switch (data) {
case r'active': return UserStatus.active;
case r'removing': return UserStatus.removing;
case r'deleted': return UserStatus.deleted;
default:
if (!allowNull) {
throw ArgumentError('Unknown enum value to decode: $data');
}
}
}
return null;
}
/// Singleton [UserStatusTypeTransformer] instance.
static UserStatusTypeTransformer? _instance;
}
@@ -26,11 +26,6 @@ void main() {
// TODO
});
// DateTime requestedAt
test('to test the property `requestedAt`', () async {
// TODO
});
});
+27
View File
@@ -0,0 +1,27 @@
//
// AUTO-GENERATED FILE, DO NOT MODIFY!
//
// @dart=2.12
// ignore_for_file: unused_element, unused_import
// ignore_for_file: always_put_required_named_parameters_first
// ignore_for_file: constant_identifier_names
// ignore_for_file: lines_longer_than_80_chars
import 'package:openapi/api.dart';
import 'package:test/test.dart';
// tests for DeleteUserDto
void main() {
// final instance = DeleteUserDto();
group('test DeleteUserDto', () {
// bool force
test('to test the property `force`', () async {
// TODO
});
});
}
+5
View File
@@ -86,6 +86,11 @@ void main() {
// TODO
});
// UserStatus status
test('to test the property `status`', () async {
// TODO
});
// String storageLabel
test('to test the property `storageLabel`', () async {
// TODO
+1 -1
View File
@@ -32,7 +32,7 @@ void main() {
// TODO
});
//Future<UserResponseDto> deleteUser(String id) async
//Future<UserResponseDto> deleteUser(String id, DeleteUserDto deleteUserDto) async
test('test deleteUser', () async {
// TODO
});
+5
View File
@@ -81,6 +81,11 @@ void main() {
// TODO
});
// UserStatus status
test('to test the property `status`', () async {
// TODO
});
// String storageLabel
test('to test the property `storageLabel`', () async {
// TODO
+21
View File
@@ -0,0 +1,21 @@
//
// AUTO-GENERATED FILE, DO NOT MODIFY!
//
// @dart=2.12
// ignore_for_file: unused_element, unused_import
// ignore_for_file: always_put_required_named_parameters_first
// ignore_for_file: constant_identifier_names
// ignore_for_file: lines_longer_than_80_chars
import 'package:openapi/api.dart';
import 'package:test/test.dart';
// tests for UserStatus
void main() {
group('test UserStatus', () {
});
}
+1 -1
View File
@@ -2,7 +2,7 @@ name: immich_mobile
description: Immich - selfhosted backup media file on mobile phone
publish_to: "none"
version: 1.98.0+126
version: 1.98.1+127
isar_version: &isar_version 3.1.0+1
environment:
+35 -5
View File
@@ -6402,6 +6402,16 @@
}
}
],
"requestBody": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/DeleteUserDto"
}
}
},
"required": true
},
"responses": {
"200": {
"content": {
@@ -6476,7 +6486,7 @@
"info": {
"title": "Immich",
"description": "Immich API",
"version": "1.98.0",
"version": "1.98.1",
"contact": {}
},
"tags": [],
@@ -7326,10 +7336,6 @@
},
"needsFullSync": {
"type": "boolean"
},
"requestedAt": {
"format": "date-time",
"type": "string"
}
},
"required": [
@@ -7754,6 +7760,14 @@
],
"type": "object"
},
"DeleteUserDto": {
"properties": {
"force": {
"type": "boolean"
}
},
"type": "object"
},
"DownloadArchiveInfo": {
"properties": {
"assetIds": {
@@ -8620,6 +8634,9 @@
"shouldChangePassword": {
"type": "boolean"
},
"status": {
"$ref": "#/components/schemas/UserStatus"
},
"storageLabel": {
"nullable": true,
"type": "string"
@@ -8642,6 +8659,7 @@
"quotaSizeInBytes",
"quotaUsageInBytes",
"shouldChangePassword",
"status",
"storageLabel",
"updatedAt"
],
@@ -10565,6 +10583,9 @@
"shouldChangePassword": {
"type": "boolean"
},
"status": {
"$ref": "#/components/schemas/UserStatus"
},
"storageLabel": {
"nullable": true,
"type": "string"
@@ -10587,11 +10608,20 @@
"quotaSizeInBytes",
"quotaUsageInBytes",
"shouldChangePassword",
"status",
"storageLabel",
"updatedAt"
],
"type": "object"
},
"UserStatus": {
"enum": [
"active",
"removing",
"deleted"
],
"type": "string"
},
"ValidateAccessTokenResponseDto": {
"properties": {
"authStatus": {
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "@immich/sdk",
"version": "1.98.0",
"version": "1.98.1",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "@immich/sdk",
"version": "1.98.0",
"version": "1.98.1",
"license": "GNU Affero General Public License version 3",
"devDependencies": {
"@oazapfts/runtime": "^1.0.0",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@immich/sdk",
"version": "1.98.0",
"version": "1.98.1",
"description": "",
"type": "module",
"main": "./build/index.js",
+17 -5
View File
@@ -1,6 +1,6 @@
/**
* Immich
* 1.98.0
* 1.98.1
* DO NOT MODIFY - This file has been generated using oazapfts.
* See https://www.npmjs.com/package/oazapfts
*/
@@ -75,6 +75,7 @@ export type UserResponseDto = {
quotaSizeInBytes: number | null;
quotaUsageInBytes: number | null;
shouldChangePassword: boolean;
status: UserStatus;
storageLabel: string | null;
updatedAt: string;
};
@@ -518,6 +519,7 @@ export type PartnerResponseDto = {
quotaSizeInBytes: number | null;
quotaUsageInBytes: number | null;
shouldChangePassword: boolean;
status: UserStatus;
storageLabel: string | null;
updatedAt: string;
};
@@ -994,6 +996,9 @@ export type CreateProfileImageResponseDto = {
profileImagePath: string;
userId: string;
};
export type DeleteUserDto = {
force?: boolean;
};
export function getActivities({ albumId, assetId, level, $type, userId }: {
albumId: string;
assetId?: string;
@@ -2678,16 +2683,18 @@ export function getProfileImage({ id }: {
...opts
}));
}
export function deleteUser({ id }: {
export function deleteUser({ id, deleteUserDto }: {
id: string;
deleteUserDto: DeleteUserDto;
}, opts?: Oazapfts.RequestOpts) {
return oazapfts.ok(oazapfts.fetchJson<{
status: 200;
data: UserResponseDto;
}>(`/user/${encodeURIComponent(id)}`, {
}>(`/user/${encodeURIComponent(id)}`, oazapfts.json({
...opts,
method: "DELETE"
}));
method: "DELETE",
body: deleteUserDto
})));
}
export function restoreUser({ id }: {
id: string;
@@ -2724,6 +2731,11 @@ export enum UserAvatarColor {
Gray = "gray",
Amber = "amber"
}
export enum UserStatus {
Active = "active",
Removing = "removing",
Deleted = "deleted"
}
export enum TagTypeEnum {
Object = "OBJECT",
Face = "FACE",
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "immich",
"version": "1.98.0",
"version": "1.98.1",
"lockfileVersion": 2,
"requires": true,
"packages": {
"": {
"name": "immich",
"version": "1.98.0",
"version": "1.98.1",
"license": "GNU Affero General Public License version 3",
"dependencies": {
"@babel/runtime": "^7.22.11",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "immich",
"version": "1.98.0",
"version": "1.98.1",
"description": "",
"author": "",
"private": true,
-1
View File
@@ -29,7 +29,6 @@ export enum PathEntityType {
export class AuditDeletesResponseDto {
needsFullSync!: boolean;
ids!: string[];
requestedAt?: Date;
}
export class FileReportDto {
@@ -61,7 +61,6 @@ describe(AuditService.name, () => {
await expect(sut.getDeletes(authStub.admin, { after: date, entityType: EntityType.ASSET })).resolves.toEqual({
needsFullSync: true,
ids: [],
requestedAt: expect.any(Date),
});
expect(auditMock.getAfter).toHaveBeenCalledWith(date, {
@@ -78,7 +77,6 @@ describe(AuditService.name, () => {
await expect(sut.getDeletes(authStub.admin, { after: date, entityType: EntityType.ASSET })).resolves.toEqual({
needsFullSync: false,
ids: ['asset-deleted'],
requestedAt: expect.any(Date),
});
expect(auditMock.getAfter).toHaveBeenCalledWith(date, {
+4 -5
View File
@@ -5,7 +5,7 @@ import { DateTime } from 'luxon';
import { resolve } from 'node:path';
import { AccessCore, Permission } from '../access';
import { AuthDto } from '../auth';
import { AUDIT_LOG_CLEANUP_DURATION, AUDIT_LOG_MAX_DURATION } from '../domain.constant';
import { AUDIT_LOG_MAX_DURATION } from '../domain.constant';
import { usePagination } from '../domain.util';
import { JOBS_ASSET_PAGINATION_SIZE } from '../job';
import {
@@ -45,7 +45,7 @@ export class AuditService {
}
async handleCleanup(): Promise<boolean> {
await this.repository.removeBefore(DateTime.now().minus(AUDIT_LOG_CLEANUP_DURATION).toJSDate());
await this.repository.removeBefore(DateTime.now().minus(AUDIT_LOG_MAX_DURATION).toJSDate());
return true;
}
@@ -53,16 +53,15 @@ export class AuditService {
const userId = dto.userId || auth.user.id;
await this.access.requirePermission(auth, Permission.TIMELINE_READ, userId);
const now = DateTime.utc();
const duration = now.diff(DateTime.fromJSDate(dto.after));
const audits = await this.repository.getAfter(dto.after, {
ownerId: userId,
entityType: dto.entityType,
action: DatabaseAction.DELETE,
});
const duration = DateTime.now().diff(DateTime.fromJSDate(dto.after));
return {
requestedAt: now.toJSDate(),
needsFullSync: duration > AUDIT_LOG_MAX_DURATION,
ids: audits.map(({ entityId }) => entityId),
};
-1
View File
@@ -4,7 +4,6 @@ import { readFileSync } from 'node:fs';
import { extname, join } from 'node:path';
export const AUDIT_LOG_MAX_DURATION = Duration.fromObject({ days: 100 });
export const AUDIT_LOG_CLEANUP_DURATION = Duration.fromObject({ days: 101 });
export const ONE_HOUR = Duration.fromObject({ hours: 1 });
export interface IVersion {
+5
View File
@@ -280,6 +280,11 @@ export class JobService {
}
break;
}
case JobName.USER_DELETION: {
this.communicationRepository.broadcast(ClientEvent.USER_DELETE, item.data.id);
break;
}
}
}
}
@@ -1,9 +1,4 @@
import {
AssetSearchOneToOneRelationOptions,
AssetSearchOptions,
ReverseGeocodeResult,
SearchExploreItem,
} from '@app/domain';
import { AssetSearchOptions, ReverseGeocodeResult, SearchExploreItem } from '@app/domain';
import { AssetEntity, AssetJobStatusEntity, AssetType, ExifEntity } from '@app/infra/entities';
import { FindOptionsRelations, FindOptionsSelect } from 'typeorm';
import { Paginated, PaginationOptions } from '../domain.util';
@@ -140,10 +135,6 @@ export interface IAssetRepository {
updateOfflineLibraryAssets(libraryId: string, originalPaths: string[]): Promise<void>;
deleteAll(ownerId: string): Promise<void>;
getAll(pagination: PaginationOptions, options?: AssetSearchOptions): Paginated<AssetEntity>;
getAllByFileCreationDate(
pagination: PaginationOptions,
options?: AssetSearchOneToOneRelationOptions,
): Paginated<AssetEntity>;
getAllByDeviceId(userId: string, deviceId: string): Promise<string[]>;
updateAll(ids: string[], options: Partial<AssetEntity>): Promise<void>;
save(asset: Pick<AssetEntity, 'id'> & Partial<AssetEntity>): Promise<AssetEntity>;
@@ -4,6 +4,7 @@ export const ICommunicationRepository = 'ICommunicationRepository';
export enum ClientEvent {
UPLOAD_SUCCESS = 'on_upload_success',
USER_DELETE = 'on_user_delete',
ASSET_DELETE = 'on_asset_delete',
ASSET_TRASH = 'on_asset_trash',
ASSET_UPDATE = 'on_asset_update',
@@ -22,6 +23,7 @@ export enum ServerEvent {
export interface ClientEventMap {
[ClientEvent.UPLOAD_SUCCESS]: AssetResponseDto;
[ClientEvent.USER_DELETE]: string;
[ClientEvent.ASSET_DELETE]: string;
[ClientEvent.ASSET_TRASH]: string[];
[ClientEvent.ASSET_UPDATE]: AssetResponseDto;
@@ -32,7 +32,6 @@ export interface IUserRepository {
create(user: Partial<UserEntity>): Promise<UserEntity>;
update(id: string, user: Partial<UserEntity>): Promise<UserEntity>;
delete(user: UserEntity, hard?: boolean): Promise<UserEntity>;
restore(user: UserEntity): Promise<UserEntity>;
updateUsage(id: string, delta: number): Promise<void>;
syncUsage(id?: string): Promise<void>;
}
@@ -0,0 +1,6 @@
import { ValidateBoolean } from '../../domain.util';
export class DeleteUserDto {
@ValidateBoolean({ optional: true })
force?: boolean;
}
+1
View File
@@ -1,3 +1,4 @@
export * from './create-profile-image.dto';
export * from './create-user.dto';
export * from './delete-user.dto';
export * from './update-user.dto';
@@ -1,4 +1,4 @@
import { UserAvatarColor, UserEntity } from '@app/infra/entities';
import { UserAvatarColor, UserEntity, UserStatus } from '@app/infra/entities';
import { ApiProperty } from '@nestjs/swagger';
import { IsEnum } from 'class-validator';
@@ -33,6 +33,8 @@ export class UserResponseDto extends UserDto {
quotaSizeInBytes!: number | null;
@ApiProperty({ type: 'integer', format: 'int64' })
quotaUsageInBytes!: number | null;
@ApiProperty({ enumName: 'UserStatus', enum: UserStatus })
status!: string;
}
export const mapSimpleUser = (entity: UserEntity): UserDto => {
@@ -58,5 +60,6 @@ export function mapUser(entity: UserEntity): UserResponseDto {
memoriesEnabled: entity.memoriesEnabled,
quotaSizeInBytes: entity.quotaSizeInBytes,
quotaUsageInBytes: entity.quotaUsageInBytes,
status: entity.status,
};
}
+31 -13
View File
@@ -1,4 +1,4 @@
import { UserEntity } from '@app/infra/entities';
import { UserEntity, UserStatus } from '@app/infra/entities';
import {
BadRequestException,
ForbiddenException,
@@ -243,16 +243,14 @@ describe(UserService.name, () => {
it('should throw error if user could not be found', async () => {
when(userMock.get).calledWith(userStub.admin.id, { withDeleted: true }).mockResolvedValue(null);
await expect(sut.restore(authStub.admin, userStub.admin.id)).rejects.toThrowError(BadRequestException);
expect(userMock.restore).not.toHaveBeenCalled();
expect(userMock.update).not.toHaveBeenCalled();
});
it('should restore an user', async () => {
userMock.get.mockResolvedValue(userStub.user1);
userMock.restore.mockResolvedValue(userStub.user1);
userMock.update.mockResolvedValue(userStub.user1);
await expect(sut.restore(authStub.admin, userStub.user1.id)).resolves.toEqual(mapUser(userStub.user1));
expect(userMock.get).toHaveBeenCalledWith(userStub.user1.id, { withDeleted: true });
expect(userMock.restore).toHaveBeenCalledWith(userStub.user1);
expect(userMock.update).toHaveBeenCalledWith(userStub.user1.id, { status: UserStatus.ACTIVE, deletedAt: null });
});
});
@@ -260,27 +258,47 @@ describe(UserService.name, () => {
it('should throw error if user could not be found', async () => {
userMock.get.mockResolvedValue(null);
await expect(sut.delete(authStub.admin, userStub.admin.id)).rejects.toThrowError(BadRequestException);
await expect(sut.delete(authStub.admin, userStub.admin.id, {})).rejects.toThrowError(BadRequestException);
expect(userMock.delete).not.toHaveBeenCalled();
});
it('cannot delete admin user', async () => {
await expect(sut.delete(authStub.admin, userStub.admin.id)).rejects.toBeInstanceOf(ForbiddenException);
await expect(sut.delete(authStub.admin, userStub.admin.id, {})).rejects.toBeInstanceOf(ForbiddenException);
});
it('should require the auth user be an admin', async () => {
await expect(sut.delete(authStub.user1, authStub.admin.user.id)).rejects.toBeInstanceOf(ForbiddenException);
await expect(sut.delete(authStub.user1, authStub.admin.user.id, {})).rejects.toBeInstanceOf(ForbiddenException);
expect(userMock.delete).not.toHaveBeenCalled();
});
it('should delete user', async () => {
userMock.get.mockResolvedValue(userStub.user1);
userMock.delete.mockResolvedValue(userStub.user1);
userMock.update.mockResolvedValue(userStub.user1);
await expect(sut.delete(authStub.admin, userStub.user1.id)).resolves.toEqual(mapUser(userStub.user1));
expect(userMock.get).toHaveBeenCalledWith(userStub.user1.id, {});
expect(userMock.delete).toHaveBeenCalledWith(userStub.user1);
await expect(sut.delete(authStub.admin, userStub.user1.id, {})).resolves.toEqual(mapUser(userStub.user1));
expect(userMock.update).toHaveBeenCalledWith(userStub.user1.id, {
status: UserStatus.DELETED,
deletedAt: expect.any(Date),
});
});
it('should force delete user', async () => {
userMock.get.mockResolvedValue(userStub.user1);
userMock.update.mockResolvedValue(userStub.user1);
await expect(sut.delete(authStub.admin, userStub.user1.id, { force: true })).resolves.toEqual(
mapUser(userStub.user1),
);
expect(userMock.update).toHaveBeenCalledWith(userStub.user1.id, {
status: UserStatus.REMOVING,
deletedAt: expect.any(Date),
});
expect(jobMock.queue).toHaveBeenCalledWith({
name: JobName.USER_DELETION,
data: { id: userStub.user1.id, force: true },
});
});
});
+18 -11
View File
@@ -1,4 +1,4 @@
import { UserEntity } from '@app/infra/entities';
import { UserEntity, UserStatus } from '@app/infra/entities';
import { ImmichLogger } from '@app/infra/logger';
import { BadRequestException, ForbiddenException, Inject, Injectable, NotFoundException } from '@nestjs/common';
import { DateTime } from 'luxon';
@@ -18,7 +18,7 @@ import {
} from '../repositories';
import { StorageCore, StorageFolder } from '../storage';
import { SystemConfigCore } from '../system-config/system-config.core';
import { CreateUserDto, UpdateUserDto } from './dto';
import { CreateUserDto, DeleteUserDto, UpdateUserDto } from './dto';
import { CreateProfileImageResponseDto, UserResponseDto, mapCreateProfileImageResponse, mapUser } from './response-dto';
import { UserCore } from './user.core';
@@ -73,22 +73,29 @@ export class UserService {
return this.userCore.updateUser(auth.user, dto.id, dto).then(mapUser);
}
async delete(auth: AuthDto, id: string): Promise<UserResponseDto> {
const user = await this.findOrFail(id, {});
if (user.isAdmin) {
async delete(auth: AuthDto, id: string, dto: DeleteUserDto): Promise<UserResponseDto> {
const { force } = dto;
const { isAdmin } = await this.findOrFail(id, {});
if (isAdmin) {
throw new ForbiddenException('Cannot delete admin user');
}
await this.albumRepository.softDeleteAll(id);
return this.userRepository.delete(user).then(mapUser);
const status = force ? UserStatus.REMOVING : UserStatus.DELETED;
const user = await this.userRepository.update(id, { status, deletedAt: new Date() });
if (force) {
await this.jobRepository.queue({ name: JobName.USER_DELETION, data: { id: user.id, force } });
}
return mapUser(user);
}
async restore(auth: AuthDto, id: string): Promise<UserResponseDto> {
let user = await this.findOrFail(id, { withDeleted: true });
user = await this.userRepository.restore(user);
await this.findOrFail(id, { withDeleted: true });
await this.albumRepository.restoreAll(id);
return mapUser(user);
return this.userRepository.update(id, { deletedAt: null, status: UserStatus.ACTIVE }).then(mapUser);
}
async createProfileImage(auth: AuthDto, fileInfo: Express.Multer.File): Promise<CreateProfileImageResponseDto> {
@@ -154,7 +161,7 @@ export class UserService {
return true;
}
async handleUserDelete({ id }: IEntityJob) {
async handleUserDelete({ id, force }: IEntityJob) {
const config = await this.configCore.getConfig();
const user = await this.userRepository.get(id, { withDeleted: true });
if (!user) {
@@ -162,7 +169,7 @@ export class UserService {
}
// just for extra protection here
if (!this.isReadyForDeletion(user, config.user.deleteDelay)) {
if (!force && !this.isReadyForDeletion(user, config.user.deleteDelay)) {
this.logger.warn(`Skipped user that was not ready for deletion: id=${id}`);
return false;
}
@@ -1,13 +1,14 @@
import { AssetEntity } from '@app/infra/entities';
import { AssetEntity, ExifEntity } from '@app/infra/entities';
import { OptionalBetween } from '@app/infra/infra.utils';
import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { In } from 'typeorm/find-options/operator/In.js';
import { Repository } from 'typeorm/repository/Repository.js';
import { AssetSearchDto } from './dto/asset-search.dto';
import { CheckExistingAssetsDto } from './dto/check-existing-assets.dto';
import { SearchPropertiesDto } from './dto/search-properties.dto';
import { CuratedLocationsResponseDto } from './response-dto/curated-locations-response.dto';
import { CuratedObjectsResponseDto } from './response-dto/curated-objects-response.dto';
export interface AssetCheck {
id: string;
checksum: Buffer;
@@ -21,6 +22,7 @@ export interface IAssetRepositoryV1 {
get(id: string): Promise<AssetEntity | null>;
getLocationsByUserId(userId: string): Promise<CuratedLocationsResponseDto[]>;
getDetectedObjectsByUserId(userId: string): Promise<CuratedObjectsResponseDto[]>;
getAllByUserId(userId: string, dto: AssetSearchDto): Promise<AssetEntity[]>;
getSearchPropertiesByUserId(userId: string): Promise<SearchPropertiesDto[]>;
getAssetsByChecksums(userId: string, checksums: Buffer[]): Promise<AssetCheck[]>;
getExistingAssets(userId: string, checkDuplicateAssetDto: CheckExistingAssetsDto): Promise<string[]>;
@@ -31,7 +33,40 @@ export const IAssetRepositoryV1 = 'IAssetRepositoryV1';
@Injectable()
export class AssetRepositoryV1 implements IAssetRepositoryV1 {
constructor(@InjectRepository(AssetEntity) private assetRepository: Repository<AssetEntity>) {}
constructor(
@InjectRepository(AssetEntity) private assetRepository: Repository<AssetEntity>,
@InjectRepository(ExifEntity) private exifRepository: Repository<ExifEntity>,
) {}
/**
* Retrieves all assets by user ID.
*
* @param ownerId - The ID of the owner.
* @param dto - The AssetSearchDto object containing search criteria.
* @returns A Promise that resolves to an array of AssetEntity objects.
*/
getAllByUserId(ownerId: string, dto: AssetSearchDto): Promise<AssetEntity[]> {
return this.assetRepository.find({
where: {
ownerId,
isVisible: true,
isFavorite: dto.isFavorite,
isArchived: dto.isArchived,
updatedAt: OptionalBetween(dto.updatedAfter, dto.updatedBefore),
},
relations: {
exifInfo: true,
tags: true,
stack: { assets: true },
},
skip: dto.skip || 0,
take: dto.take,
order: {
fileCreatedAt: 'DESC',
},
withDeleted: true,
});
}
getSearchPropertiesByUserId(userId: string): Promise<SearchPropertiesDto[]> {
return this.assetRepository
@@ -77,6 +77,7 @@ describe('AssetService', () => {
beforeEach(() => {
assetRepositoryMockV1 = {
get: jest.fn(),
getAllByUserId: jest.fn(),
getDetectedObjectsByUserId: jest.fn(),
getLocationsByUserId: jest.fn(),
getSearchPropertiesByUserId: jest.fn(),
@@ -113,19 +113,8 @@ export class AssetService {
public async getAllAssets(auth: AuthDto, dto: AssetSearchDto): Promise<AssetResponseDto[]> {
const userId = dto.userId || auth.user.id;
await this.access.requirePermission(auth, Permission.TIMELINE_READ, userId);
const assets = await this.assetRepository.getAllByFileCreationDate(
{ take: dto.take ?? 1000, skip: dto.skip },
{
...dto,
userIds: [userId],
withDeleted: true,
orderDirection: 'DESC',
withExif: true,
isVisible: true,
withStacked: true,
},
);
return assets.items.map((asset) => mapAsset(asset, { withStack: true }));
const assets = await this.assetRepositoryV1.getAllByUserId(userId, dto);
return assets.map((asset) => mapAsset(asset, { withStack: true, auth }));
}
async serveThumbnail(auth: AuthDto, assetId: string, dto: GetAssetThumbnailDto): Promise<ImmichFileResponse> {
@@ -3,6 +3,7 @@ import {
CreateUserDto as CreateDto,
CreateProfileImageDto,
CreateProfileImageResponseDto,
DeleteUserDto,
UpdateUserDto as UpdateDto,
UserResponseDto,
UserService,
@@ -66,8 +67,12 @@ export class UserController {
@AdminRoute()
@Delete(':id')
deleteUser(@Auth() auth: AuthDto, @Param() { id }: UUIDParamDto): Promise<UserResponseDto> {
return this.service.delete(auth, id);
deleteUser(
@Auth() auth: AuthDto,
@Param() { id }: UUIDParamDto,
@Body() dto: DeleteUserDto,
): Promise<UserResponseDto> {
return this.service.delete(auth, id, dto);
}
@AdminRoute()
+9
View File
@@ -23,6 +23,12 @@ export enum UserAvatarColor {
AMBER = 'amber',
}
export enum UserStatus {
ACTIVE = 'active',
REMOVING = 'removing',
DELETED = 'deleted',
}
@Entity('users')
export class UserEntity {
@PrimaryGeneratedColumn('uuid')
@@ -61,6 +67,9 @@ export class UserEntity {
@DeleteDateColumn({ type: 'timestamptz' })
deletedAt!: Date | null;
@Column({ type: 'varchar', default: UserStatus.ACTIVE })
status!: UserStatus;
@UpdateDateColumn({ type: 'timestamptz' })
updatedAt!: Date;
@@ -0,0 +1,14 @@
import { MigrationInterface, QueryRunner } from "typeorm";
export class AddUserStatus1709870213078 implements MigrationInterface {
name = 'AddUserStatus1709870213078'
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`ALTER TABLE "users" ADD "status" character varying NOT NULL DEFAULT 'active'`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`ALTER TABLE "users" DROP COLUMN "status"`);
}
}
@@ -2,7 +2,6 @@ import {
AssetBuilderOptions,
AssetCreate,
AssetExploreFieldOptions,
AssetSearchOneToOneRelationOptions,
AssetSearchOptions,
AssetStats,
AssetStatsOptions,
@@ -233,29 +232,6 @@ export class AssetRepository implements IAssetRepository {
});
}
@GenerateSql({
params: [
{ skip: 20_000, take: 10_000 },
{
takenBefore: DummyValue.DATE,
userIds: [DummyValue.UUID],
},
],
})
getAllByFileCreationDate(
pagination: PaginationOptions,
options: AssetSearchOneToOneRelationOptions = {},
): Paginated<AssetEntity> {
let builder = this.repository.createQueryBuilder('asset');
builder = searchAssetBuilder(builder, options);
builder.orderBy('asset.fileCreatedAt', options.orderDirection ?? 'DESC');
return paginatedBuilder<AssetEntity>(builder, {
mode: PaginationMode.LIMIT_OFFSET,
skip: pagination.skip,
take: pagination.take,
});
}
/**
* Get assets by device's Id on the database
* @param ownerId
@@ -77,10 +77,6 @@ export class UserRepository implements IUserRepository {
return hard ? this.userRepository.remove(user) : this.userRepository.softRemove(user);
}
async restore(user: UserEntity): Promise<UserEntity> {
return this.userRepository.recover(user);
}
@GenerateSql()
async getUserStats(): Promise<UserStatsQueryResponse[]> {
const stats = await this.userRepository
@@ -135,6 +131,6 @@ export class UserRepository implements IUserRepository {
private async save(user: Partial<UserEntity>) {
const { id } = await this.userRepository.save(user);
return this.userRepository.findOneByOrFail({ id });
return this.userRepository.findOneOrFail({ where: { id }, withDeleted: true });
}
}
+13
View File
@@ -26,6 +26,7 @@ FROM
"AlbumEntity__AlbumEntity_owner"."shouldChangePassword" AS "AlbumEntity__AlbumEntity_owner_shouldChangePassword",
"AlbumEntity__AlbumEntity_owner"."createdAt" AS "AlbumEntity__AlbumEntity_owner_createdAt",
"AlbumEntity__AlbumEntity_owner"."deletedAt" AS "AlbumEntity__AlbumEntity_owner_deletedAt",
"AlbumEntity__AlbumEntity_owner"."status" AS "AlbumEntity__AlbumEntity_owner_status",
"AlbumEntity__AlbumEntity_owner"."updatedAt" AS "AlbumEntity__AlbumEntity_owner_updatedAt",
"AlbumEntity__AlbumEntity_owner"."memoriesEnabled" AS "AlbumEntity__AlbumEntity_owner_memoriesEnabled",
"AlbumEntity__AlbumEntity_owner"."quotaSizeInBytes" AS "AlbumEntity__AlbumEntity_owner_quotaSizeInBytes",
@@ -41,6 +42,7 @@ FROM
"AlbumEntity__AlbumEntity_sharedUsers"."shouldChangePassword" AS "AlbumEntity__AlbumEntity_sharedUsers_shouldChangePassword",
"AlbumEntity__AlbumEntity_sharedUsers"."createdAt" AS "AlbumEntity__AlbumEntity_sharedUsers_createdAt",
"AlbumEntity__AlbumEntity_sharedUsers"."deletedAt" AS "AlbumEntity__AlbumEntity_sharedUsers_deletedAt",
"AlbumEntity__AlbumEntity_sharedUsers"."status" AS "AlbumEntity__AlbumEntity_sharedUsers_status",
"AlbumEntity__AlbumEntity_sharedUsers"."updatedAt" AS "AlbumEntity__AlbumEntity_sharedUsers_updatedAt",
"AlbumEntity__AlbumEntity_sharedUsers"."memoriesEnabled" AS "AlbumEntity__AlbumEntity_sharedUsers_memoriesEnabled",
"AlbumEntity__AlbumEntity_sharedUsers"."quotaSizeInBytes" AS "AlbumEntity__AlbumEntity_sharedUsers_quotaSizeInBytes",
@@ -100,6 +102,7 @@ SELECT
"AlbumEntity__AlbumEntity_owner"."shouldChangePassword" AS "AlbumEntity__AlbumEntity_owner_shouldChangePassword",
"AlbumEntity__AlbumEntity_owner"."createdAt" AS "AlbumEntity__AlbumEntity_owner_createdAt",
"AlbumEntity__AlbumEntity_owner"."deletedAt" AS "AlbumEntity__AlbumEntity_owner_deletedAt",
"AlbumEntity__AlbumEntity_owner"."status" AS "AlbumEntity__AlbumEntity_owner_status",
"AlbumEntity__AlbumEntity_owner"."updatedAt" AS "AlbumEntity__AlbumEntity_owner_updatedAt",
"AlbumEntity__AlbumEntity_owner"."memoriesEnabled" AS "AlbumEntity__AlbumEntity_owner_memoriesEnabled",
"AlbumEntity__AlbumEntity_owner"."quotaSizeInBytes" AS "AlbumEntity__AlbumEntity_owner_quotaSizeInBytes",
@@ -115,6 +118,7 @@ SELECT
"AlbumEntity__AlbumEntity_sharedUsers"."shouldChangePassword" AS "AlbumEntity__AlbumEntity_sharedUsers_shouldChangePassword",
"AlbumEntity__AlbumEntity_sharedUsers"."createdAt" AS "AlbumEntity__AlbumEntity_sharedUsers_createdAt",
"AlbumEntity__AlbumEntity_sharedUsers"."deletedAt" AS "AlbumEntity__AlbumEntity_sharedUsers_deletedAt",
"AlbumEntity__AlbumEntity_sharedUsers"."status" AS "AlbumEntity__AlbumEntity_sharedUsers_status",
"AlbumEntity__AlbumEntity_sharedUsers"."updatedAt" AS "AlbumEntity__AlbumEntity_sharedUsers_updatedAt",
"AlbumEntity__AlbumEntity_sharedUsers"."memoriesEnabled" AS "AlbumEntity__AlbumEntity_sharedUsers_memoriesEnabled",
"AlbumEntity__AlbumEntity_sharedUsers"."quotaSizeInBytes" AS "AlbumEntity__AlbumEntity_sharedUsers_quotaSizeInBytes",
@@ -156,6 +160,7 @@ SELECT
"AlbumEntity__AlbumEntity_owner"."shouldChangePassword" AS "AlbumEntity__AlbumEntity_owner_shouldChangePassword",
"AlbumEntity__AlbumEntity_owner"."createdAt" AS "AlbumEntity__AlbumEntity_owner_createdAt",
"AlbumEntity__AlbumEntity_owner"."deletedAt" AS "AlbumEntity__AlbumEntity_owner_deletedAt",
"AlbumEntity__AlbumEntity_owner"."status" AS "AlbumEntity__AlbumEntity_owner_status",
"AlbumEntity__AlbumEntity_owner"."updatedAt" AS "AlbumEntity__AlbumEntity_owner_updatedAt",
"AlbumEntity__AlbumEntity_owner"."memoriesEnabled" AS "AlbumEntity__AlbumEntity_owner_memoriesEnabled",
"AlbumEntity__AlbumEntity_owner"."quotaSizeInBytes" AS "AlbumEntity__AlbumEntity_owner_quotaSizeInBytes",
@@ -171,6 +176,7 @@ SELECT
"AlbumEntity__AlbumEntity_sharedUsers"."shouldChangePassword" AS "AlbumEntity__AlbumEntity_sharedUsers_shouldChangePassword",
"AlbumEntity__AlbumEntity_sharedUsers"."createdAt" AS "AlbumEntity__AlbumEntity_sharedUsers_createdAt",
"AlbumEntity__AlbumEntity_sharedUsers"."deletedAt" AS "AlbumEntity__AlbumEntity_sharedUsers_deletedAt",
"AlbumEntity__AlbumEntity_sharedUsers"."status" AS "AlbumEntity__AlbumEntity_sharedUsers_status",
"AlbumEntity__AlbumEntity_sharedUsers"."updatedAt" AS "AlbumEntity__AlbumEntity_sharedUsers_updatedAt",
"AlbumEntity__AlbumEntity_sharedUsers"."memoriesEnabled" AS "AlbumEntity__AlbumEntity_sharedUsers_memoriesEnabled",
"AlbumEntity__AlbumEntity_sharedUsers"."quotaSizeInBytes" AS "AlbumEntity__AlbumEntity_sharedUsers_quotaSizeInBytes",
@@ -284,6 +290,7 @@ SELECT
"AlbumEntity__AlbumEntity_sharedUsers"."shouldChangePassword" AS "AlbumEntity__AlbumEntity_sharedUsers_shouldChangePassword",
"AlbumEntity__AlbumEntity_sharedUsers"."createdAt" AS "AlbumEntity__AlbumEntity_sharedUsers_createdAt",
"AlbumEntity__AlbumEntity_sharedUsers"."deletedAt" AS "AlbumEntity__AlbumEntity_sharedUsers_deletedAt",
"AlbumEntity__AlbumEntity_sharedUsers"."status" AS "AlbumEntity__AlbumEntity_sharedUsers_status",
"AlbumEntity__AlbumEntity_sharedUsers"."updatedAt" AS "AlbumEntity__AlbumEntity_sharedUsers_updatedAt",
"AlbumEntity__AlbumEntity_sharedUsers"."memoriesEnabled" AS "AlbumEntity__AlbumEntity_sharedUsers_memoriesEnabled",
"AlbumEntity__AlbumEntity_sharedUsers"."quotaSizeInBytes" AS "AlbumEntity__AlbumEntity_sharedUsers_quotaSizeInBytes",
@@ -311,6 +318,7 @@ SELECT
"AlbumEntity__AlbumEntity_owner"."shouldChangePassword" AS "AlbumEntity__AlbumEntity_owner_shouldChangePassword",
"AlbumEntity__AlbumEntity_owner"."createdAt" AS "AlbumEntity__AlbumEntity_owner_createdAt",
"AlbumEntity__AlbumEntity_owner"."deletedAt" AS "AlbumEntity__AlbumEntity_owner_deletedAt",
"AlbumEntity__AlbumEntity_owner"."status" AS "AlbumEntity__AlbumEntity_owner_status",
"AlbumEntity__AlbumEntity_owner"."updatedAt" AS "AlbumEntity__AlbumEntity_owner_updatedAt",
"AlbumEntity__AlbumEntity_owner"."memoriesEnabled" AS "AlbumEntity__AlbumEntity_owner_memoriesEnabled",
"AlbumEntity__AlbumEntity_owner"."quotaSizeInBytes" AS "AlbumEntity__AlbumEntity_owner_quotaSizeInBytes",
@@ -355,6 +363,7 @@ SELECT
"AlbumEntity__AlbumEntity_sharedUsers"."shouldChangePassword" AS "AlbumEntity__AlbumEntity_sharedUsers_shouldChangePassword",
"AlbumEntity__AlbumEntity_sharedUsers"."createdAt" AS "AlbumEntity__AlbumEntity_sharedUsers_createdAt",
"AlbumEntity__AlbumEntity_sharedUsers"."deletedAt" AS "AlbumEntity__AlbumEntity_sharedUsers_deletedAt",
"AlbumEntity__AlbumEntity_sharedUsers"."status" AS "AlbumEntity__AlbumEntity_sharedUsers_status",
"AlbumEntity__AlbumEntity_sharedUsers"."updatedAt" AS "AlbumEntity__AlbumEntity_sharedUsers_updatedAt",
"AlbumEntity__AlbumEntity_sharedUsers"."memoriesEnabled" AS "AlbumEntity__AlbumEntity_sharedUsers_memoriesEnabled",
"AlbumEntity__AlbumEntity_sharedUsers"."quotaSizeInBytes" AS "AlbumEntity__AlbumEntity_sharedUsers_quotaSizeInBytes",
@@ -382,6 +391,7 @@ SELECT
"AlbumEntity__AlbumEntity_owner"."shouldChangePassword" AS "AlbumEntity__AlbumEntity_owner_shouldChangePassword",
"AlbumEntity__AlbumEntity_owner"."createdAt" AS "AlbumEntity__AlbumEntity_owner_createdAt",
"AlbumEntity__AlbumEntity_owner"."deletedAt" AS "AlbumEntity__AlbumEntity_owner_deletedAt",
"AlbumEntity__AlbumEntity_owner"."status" AS "AlbumEntity__AlbumEntity_owner_status",
"AlbumEntity__AlbumEntity_owner"."updatedAt" AS "AlbumEntity__AlbumEntity_owner_updatedAt",
"AlbumEntity__AlbumEntity_owner"."memoriesEnabled" AS "AlbumEntity__AlbumEntity_owner_memoriesEnabled",
"AlbumEntity__AlbumEntity_owner"."quotaSizeInBytes" AS "AlbumEntity__AlbumEntity_owner_quotaSizeInBytes",
@@ -463,6 +473,7 @@ SELECT
"AlbumEntity__AlbumEntity_sharedUsers"."shouldChangePassword" AS "AlbumEntity__AlbumEntity_sharedUsers_shouldChangePassword",
"AlbumEntity__AlbumEntity_sharedUsers"."createdAt" AS "AlbumEntity__AlbumEntity_sharedUsers_createdAt",
"AlbumEntity__AlbumEntity_sharedUsers"."deletedAt" AS "AlbumEntity__AlbumEntity_sharedUsers_deletedAt",
"AlbumEntity__AlbumEntity_sharedUsers"."status" AS "AlbumEntity__AlbumEntity_sharedUsers_status",
"AlbumEntity__AlbumEntity_sharedUsers"."updatedAt" AS "AlbumEntity__AlbumEntity_sharedUsers_updatedAt",
"AlbumEntity__AlbumEntity_sharedUsers"."memoriesEnabled" AS "AlbumEntity__AlbumEntity_sharedUsers_memoriesEnabled",
"AlbumEntity__AlbumEntity_sharedUsers"."quotaSizeInBytes" AS "AlbumEntity__AlbumEntity_sharedUsers_quotaSizeInBytes",
@@ -490,6 +501,7 @@ SELECT
"AlbumEntity__AlbumEntity_owner"."shouldChangePassword" AS "AlbumEntity__AlbumEntity_owner_shouldChangePassword",
"AlbumEntity__AlbumEntity_owner"."createdAt" AS "AlbumEntity__AlbumEntity_owner_createdAt",
"AlbumEntity__AlbumEntity_owner"."deletedAt" AS "AlbumEntity__AlbumEntity_owner_deletedAt",
"AlbumEntity__AlbumEntity_owner"."status" AS "AlbumEntity__AlbumEntity_owner_status",
"AlbumEntity__AlbumEntity_owner"."updatedAt" AS "AlbumEntity__AlbumEntity_owner_updatedAt",
"AlbumEntity__AlbumEntity_owner"."memoriesEnabled" AS "AlbumEntity__AlbumEntity_owner_memoriesEnabled",
"AlbumEntity__AlbumEntity_owner"."quotaSizeInBytes" AS "AlbumEntity__AlbumEntity_owner_quotaSizeInBytes",
@@ -552,6 +564,7 @@ SELECT
"AlbumEntity__AlbumEntity_owner"."shouldChangePassword" AS "AlbumEntity__AlbumEntity_owner_shouldChangePassword",
"AlbumEntity__AlbumEntity_owner"."createdAt" AS "AlbumEntity__AlbumEntity_owner_createdAt",
"AlbumEntity__AlbumEntity_owner"."deletedAt" AS "AlbumEntity__AlbumEntity_owner_deletedAt",
"AlbumEntity__AlbumEntity_owner"."status" AS "AlbumEntity__AlbumEntity_owner_status",
"AlbumEntity__AlbumEntity_owner"."updatedAt" AS "AlbumEntity__AlbumEntity_owner_updatedAt",
"AlbumEntity__AlbumEntity_owner"."memoriesEnabled" AS "AlbumEntity__AlbumEntity_owner_memoriesEnabled",
"AlbumEntity__AlbumEntity_owner"."quotaSizeInBytes" AS "AlbumEntity__AlbumEntity_owner_quotaSizeInBytes",
@@ -20,6 +20,7 @@ FROM
"APIKeyEntity__APIKeyEntity_user"."shouldChangePassword" AS "APIKeyEntity__APIKeyEntity_user_shouldChangePassword",
"APIKeyEntity__APIKeyEntity_user"."createdAt" AS "APIKeyEntity__APIKeyEntity_user_createdAt",
"APIKeyEntity__APIKeyEntity_user"."deletedAt" AS "APIKeyEntity__APIKeyEntity_user_deletedAt",
"APIKeyEntity__APIKeyEntity_user"."status" AS "APIKeyEntity__APIKeyEntity_user_status",
"APIKeyEntity__APIKeyEntity_user"."updatedAt" AS "APIKeyEntity__APIKeyEntity_user_updatedAt",
"APIKeyEntity__APIKeyEntity_user"."memoriesEnabled" AS "APIKeyEntity__APIKeyEntity_user_memoriesEnabled",
"APIKeyEntity__APIKeyEntity_user"."quotaSizeInBytes" AS "APIKeyEntity__APIKeyEntity_user_quotaSizeInBytes",
-49
View File
@@ -428,55 +428,6 @@ WHERE
AND "isOffline" = $4
)
-- AssetRepository.getAllByFileCreationDate
SELECT
"asset"."id" AS "asset_id",
"asset"."deviceAssetId" AS "asset_deviceAssetId",
"asset"."ownerId" AS "asset_ownerId",
"asset"."libraryId" AS "asset_libraryId",
"asset"."deviceId" AS "asset_deviceId",
"asset"."type" AS "asset_type",
"asset"."originalPath" AS "asset_originalPath",
"asset"."resizePath" AS "asset_resizePath",
"asset"."webpPath" AS "asset_webpPath",
"asset"."thumbhash" AS "asset_thumbhash",
"asset"."encodedVideoPath" AS "asset_encodedVideoPath",
"asset"."createdAt" AS "asset_createdAt",
"asset"."updatedAt" AS "asset_updatedAt",
"asset"."deletedAt" AS "asset_deletedAt",
"asset"."fileCreatedAt" AS "asset_fileCreatedAt",
"asset"."localDateTime" AS "asset_localDateTime",
"asset"."fileModifiedAt" AS "asset_fileModifiedAt",
"asset"."isFavorite" AS "asset_isFavorite",
"asset"."isArchived" AS "asset_isArchived",
"asset"."isExternal" AS "asset_isExternal",
"asset"."isReadOnly" AS "asset_isReadOnly",
"asset"."isOffline" AS "asset_isOffline",
"asset"."checksum" AS "asset_checksum",
"asset"."duration" AS "asset_duration",
"asset"."isVisible" AS "asset_isVisible",
"asset"."livePhotoVideoId" AS "asset_livePhotoVideoId",
"asset"."originalFileName" AS "asset_originalFileName",
"asset"."sidecarPath" AS "asset_sidecarPath",
"asset"."stackId" AS "asset_stackId"
FROM
"assets" "asset"
WHERE
(
"asset"."fileCreatedAt" <= $1
AND 1 = 1
AND "asset"."ownerId" IN ($2)
AND 1 = 1
AND "asset"."isArchived" = $3
)
AND ("asset"."deletedAt" IS NULL)
ORDER BY
"asset"."fileCreatedAt" DESC
LIMIT
10001
OFFSET
20000
-- AssetRepository.getAllByDeviceId
SELECT
"AssetEntity"."deviceAssetId" AS "AssetEntity_deviceAssetId",
@@ -28,6 +28,7 @@ FROM
"LibraryEntity__LibraryEntity_owner"."shouldChangePassword" AS "LibraryEntity__LibraryEntity_owner_shouldChangePassword",
"LibraryEntity__LibraryEntity_owner"."createdAt" AS "LibraryEntity__LibraryEntity_owner_createdAt",
"LibraryEntity__LibraryEntity_owner"."deletedAt" AS "LibraryEntity__LibraryEntity_owner_deletedAt",
"LibraryEntity__LibraryEntity_owner"."status" AS "LibraryEntity__LibraryEntity_owner_status",
"LibraryEntity__LibraryEntity_owner"."updatedAt" AS "LibraryEntity__LibraryEntity_owner_updatedAt",
"LibraryEntity__LibraryEntity_owner"."memoriesEnabled" AS "LibraryEntity__LibraryEntity_owner_memoriesEnabled",
"LibraryEntity__LibraryEntity_owner"."quotaSizeInBytes" AS "LibraryEntity__LibraryEntity_owner_quotaSizeInBytes",
@@ -143,6 +144,7 @@ SELECT
"LibraryEntity__LibraryEntity_owner"."shouldChangePassword" AS "LibraryEntity__LibraryEntity_owner_shouldChangePassword",
"LibraryEntity__LibraryEntity_owner"."createdAt" AS "LibraryEntity__LibraryEntity_owner_createdAt",
"LibraryEntity__LibraryEntity_owner"."deletedAt" AS "LibraryEntity__LibraryEntity_owner_deletedAt",
"LibraryEntity__LibraryEntity_owner"."status" AS "LibraryEntity__LibraryEntity_owner_status",
"LibraryEntity__LibraryEntity_owner"."updatedAt" AS "LibraryEntity__LibraryEntity_owner_updatedAt",
"LibraryEntity__LibraryEntity_owner"."memoriesEnabled" AS "LibraryEntity__LibraryEntity_owner_memoriesEnabled",
"LibraryEntity__LibraryEntity_owner"."quotaSizeInBytes" AS "LibraryEntity__LibraryEntity_owner_quotaSizeInBytes",
@@ -188,6 +190,7 @@ SELECT
"LibraryEntity__LibraryEntity_owner"."shouldChangePassword" AS "LibraryEntity__LibraryEntity_owner_shouldChangePassword",
"LibraryEntity__LibraryEntity_owner"."createdAt" AS "LibraryEntity__LibraryEntity_owner_createdAt",
"LibraryEntity__LibraryEntity_owner"."deletedAt" AS "LibraryEntity__LibraryEntity_owner_deletedAt",
"LibraryEntity__LibraryEntity_owner"."status" AS "LibraryEntity__LibraryEntity_owner_status",
"LibraryEntity__LibraryEntity_owner"."updatedAt" AS "LibraryEntity__LibraryEntity_owner_updatedAt",
"LibraryEntity__LibraryEntity_owner"."memoriesEnabled" AS "LibraryEntity__LibraryEntity_owner_memoriesEnabled",
"LibraryEntity__LibraryEntity_owner"."quotaSizeInBytes" AS "LibraryEntity__LibraryEntity_owner_quotaSizeInBytes",
@@ -227,6 +230,7 @@ SELECT
"LibraryEntity__LibraryEntity_owner"."shouldChangePassword" AS "LibraryEntity__LibraryEntity_owner_shouldChangePassword",
"LibraryEntity__LibraryEntity_owner"."createdAt" AS "LibraryEntity__LibraryEntity_owner_createdAt",
"LibraryEntity__LibraryEntity_owner"."deletedAt" AS "LibraryEntity__LibraryEntity_owner_deletedAt",
"LibraryEntity__LibraryEntity_owner"."status" AS "LibraryEntity__LibraryEntity_owner_status",
"LibraryEntity__LibraryEntity_owner"."updatedAt" AS "LibraryEntity__LibraryEntity_owner_updatedAt",
"LibraryEntity__LibraryEntity_owner"."memoriesEnabled" AS "LibraryEntity__LibraryEntity_owner_memoriesEnabled",
"LibraryEntity__LibraryEntity_owner"."quotaSizeInBytes" AS "LibraryEntity__LibraryEntity_owner_quotaSizeInBytes",
@@ -155,6 +155,7 @@ FROM
"6d7fd45329a05fd86b3dbcacde87fe76e33a422d"."shouldChangePassword" AS "6d7fd45329a05fd86b3dbcacde87fe76e33a422d_shouldChangePassword",
"6d7fd45329a05fd86b3dbcacde87fe76e33a422d"."createdAt" AS "6d7fd45329a05fd86b3dbcacde87fe76e33a422d_createdAt",
"6d7fd45329a05fd86b3dbcacde87fe76e33a422d"."deletedAt" AS "6d7fd45329a05fd86b3dbcacde87fe76e33a422d_deletedAt",
"6d7fd45329a05fd86b3dbcacde87fe76e33a422d"."status" AS "6d7fd45329a05fd86b3dbcacde87fe76e33a422d_status",
"6d7fd45329a05fd86b3dbcacde87fe76e33a422d"."updatedAt" AS "6d7fd45329a05fd86b3dbcacde87fe76e33a422d_updatedAt",
"6d7fd45329a05fd86b3dbcacde87fe76e33a422d"."memoriesEnabled" AS "6d7fd45329a05fd86b3dbcacde87fe76e33a422d_memoriesEnabled",
"6d7fd45329a05fd86b3dbcacde87fe76e33a422d"."quotaSizeInBytes" AS "6d7fd45329a05fd86b3dbcacde87fe76e33a422d_quotaSizeInBytes",
@@ -258,6 +259,7 @@ SELECT
"6d7fd45329a05fd86b3dbcacde87fe76e33a422d"."shouldChangePassword" AS "6d7fd45329a05fd86b3dbcacde87fe76e33a422d_shouldChangePassword",
"6d7fd45329a05fd86b3dbcacde87fe76e33a422d"."createdAt" AS "6d7fd45329a05fd86b3dbcacde87fe76e33a422d_createdAt",
"6d7fd45329a05fd86b3dbcacde87fe76e33a422d"."deletedAt" AS "6d7fd45329a05fd86b3dbcacde87fe76e33a422d_deletedAt",
"6d7fd45329a05fd86b3dbcacde87fe76e33a422d"."status" AS "6d7fd45329a05fd86b3dbcacde87fe76e33a422d_status",
"6d7fd45329a05fd86b3dbcacde87fe76e33a422d"."updatedAt" AS "6d7fd45329a05fd86b3dbcacde87fe76e33a422d_updatedAt",
"6d7fd45329a05fd86b3dbcacde87fe76e33a422d"."memoriesEnabled" AS "6d7fd45329a05fd86b3dbcacde87fe76e33a422d_memoriesEnabled",
"6d7fd45329a05fd86b3dbcacde87fe76e33a422d"."quotaSizeInBytes" AS "6d7fd45329a05fd86b3dbcacde87fe76e33a422d_quotaSizeInBytes",
@@ -311,6 +313,7 @@ FROM
"SharedLinkEntity__SharedLinkEntity_user"."shouldChangePassword" AS "SharedLinkEntity__SharedLinkEntity_user_shouldChangePassword",
"SharedLinkEntity__SharedLinkEntity_user"."createdAt" AS "SharedLinkEntity__SharedLinkEntity_user_createdAt",
"SharedLinkEntity__SharedLinkEntity_user"."deletedAt" AS "SharedLinkEntity__SharedLinkEntity_user_deletedAt",
"SharedLinkEntity__SharedLinkEntity_user"."status" AS "SharedLinkEntity__SharedLinkEntity_user_status",
"SharedLinkEntity__SharedLinkEntity_user"."updatedAt" AS "SharedLinkEntity__SharedLinkEntity_user_updatedAt",
"SharedLinkEntity__SharedLinkEntity_user"."memoriesEnabled" AS "SharedLinkEntity__SharedLinkEntity_user_memoriesEnabled",
"SharedLinkEntity__SharedLinkEntity_user"."quotaSizeInBytes" AS "SharedLinkEntity__SharedLinkEntity_user_quotaSizeInBytes",
+4
View File
@@ -13,6 +13,7 @@ SELECT
"UserEntity"."shouldChangePassword" AS "UserEntity_shouldChangePassword",
"UserEntity"."createdAt" AS "UserEntity_createdAt",
"UserEntity"."deletedAt" AS "UserEntity_deletedAt",
"UserEntity"."status" AS "UserEntity_status",
"UserEntity"."updatedAt" AS "UserEntity_updatedAt",
"UserEntity"."memoriesEnabled" AS "UserEntity_memoriesEnabled",
"UserEntity"."quotaSizeInBytes" AS "UserEntity_quotaSizeInBytes",
@@ -59,6 +60,7 @@ SELECT
"user"."shouldChangePassword" AS "user_shouldChangePassword",
"user"."createdAt" AS "user_createdAt",
"user"."deletedAt" AS "user_deletedAt",
"user"."status" AS "user_status",
"user"."updatedAt" AS "user_updatedAt",
"user"."memoriesEnabled" AS "user_memoriesEnabled",
"user"."quotaSizeInBytes" AS "user_quotaSizeInBytes",
@@ -82,6 +84,7 @@ SELECT
"UserEntity"."shouldChangePassword" AS "UserEntity_shouldChangePassword",
"UserEntity"."createdAt" AS "UserEntity_createdAt",
"UserEntity"."deletedAt" AS "UserEntity_deletedAt",
"UserEntity"."status" AS "UserEntity_status",
"UserEntity"."updatedAt" AS "UserEntity_updatedAt",
"UserEntity"."memoriesEnabled" AS "UserEntity_memoriesEnabled",
"UserEntity"."quotaSizeInBytes" AS "UserEntity_quotaSizeInBytes",
@@ -107,6 +110,7 @@ SELECT
"UserEntity"."shouldChangePassword" AS "UserEntity_shouldChangePassword",
"UserEntity"."createdAt" AS "UserEntity_createdAt",
"UserEntity"."deletedAt" AS "UserEntity_deletedAt",
"UserEntity"."status" AS "UserEntity_status",
"UserEntity"."updatedAt" AS "UserEntity_updatedAt",
"UserEntity"."memoriesEnabled" AS "UserEntity_memoriesEnabled",
"UserEntity"."quotaSizeInBytes" AS "UserEntity_quotaSizeInBytes",
@@ -23,6 +23,7 @@ FROM
"UserTokenEntity__UserTokenEntity_user"."shouldChangePassword" AS "UserTokenEntity__UserTokenEntity_user_shouldChangePassword",
"UserTokenEntity__UserTokenEntity_user"."createdAt" AS "UserTokenEntity__UserTokenEntity_user_createdAt",
"UserTokenEntity__UserTokenEntity_user"."deletedAt" AS "UserTokenEntity__UserTokenEntity_user_deletedAt",
"UserTokenEntity__UserTokenEntity_user"."status" AS "UserTokenEntity__UserTokenEntity_user_status",
"UserTokenEntity__UserTokenEntity_user"."updatedAt" AS "UserTokenEntity__UserTokenEntity_user_updatedAt",
"UserTokenEntity__UserTokenEntity_user"."memoriesEnabled" AS "UserTokenEntity__UserTokenEntity_user_memoriesEnabled",
"UserTokenEntity__UserTokenEntity_user"."quotaSizeInBytes" AS "UserTokenEntity__UserTokenEntity_user_quotaSizeInBytes",
@@ -18,7 +18,6 @@ export const newAssetRepositoryMock = (): jest.Mocked<IAssetRepository> => {
getFirstAssetForAlbumId: jest.fn(),
getLastUpdatedAssetForAlbumId: jest.fn(),
getAll: jest.fn().mockResolvedValue({ items: [], hasNextPage: false }),
getAllByFileCreationDate: jest.fn(),
getAllByDeviceId: jest.fn(),
updateAll: jest.fn(),
getByLibraryId: jest.fn(),
@@ -17,7 +17,6 @@ export const newUserRepositoryMock = (reset = true): jest.Mocked<IUserRepository
update: jest.fn(),
delete: jest.fn(),
getDeletedUsers: jest.fn(),
restore: jest.fn(),
hasAdmin: jest.fn(),
updateUsage: jest.fn(),
syncUsage: jest.fn(),
+3 -3
View File
@@ -1,12 +1,12 @@
{
"name": "immich-web",
"version": "1.98.0",
"version": "1.98.1",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "immich-web",
"version": "1.98.0",
"version": "1.98.1",
"license": "GNU Affero General Public License version 3",
"dependencies": {
"@immich/sdk": "file:../open-api/typescript-sdk",
@@ -63,7 +63,7 @@
},
"../open-api/typescript-sdk": {
"name": "@immich/sdk",
"version": "1.98.0",
"version": "1.98.1",
"license": "GNU Affero General Public License version 3",
"devDependencies": {
"@oazapfts/runtime": "^1.0.0",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "immich-web",
"version": "1.98.0",
"version": "1.98.1",
"license": "GNU Affero General Public License version 3",
"scripts": {
"dev": "vite dev --host 0.0.0.0 --port 3000",
@@ -7,6 +7,10 @@
export let user: UserResponseDto;
let forceDelete = false;
let deleteButtonDisabled = false;
let userIdInput: string = '';
const dispatch = createEventDispatcher<{
success: void;
fail: void;
@@ -15,7 +19,11 @@
const handleDeleteUser = async () => {
try {
const { deletedAt } = await deleteUser({ id: user.id });
const { deletedAt } = await deleteUser({
id: user.id,
deleteUserDto: { force: forceDelete },
});
if (deletedAt == undefined) {
dispatch('fail');
} else {
@@ -26,20 +34,68 @@
dispatch('fail');
}
};
const handleConfirm = (e: Event) => {
userIdInput = (e.target as HTMLInputElement).value;
deleteButtonDisabled = userIdInput != user.email;
};
</script>
<ConfirmDialogue
title="Delete User"
confirmText="Delete"
confirmText={forceDelete ? 'Permanently Delete' : 'Delete'}
onConfirm={handleDeleteUser}
onClose={() => dispatch('cancel')}
disabled={deleteButtonDisabled}
>
<svelte:fragment slot="prompt">
<div class="flex flex-col gap-4">
<p>
<b>{user.name}</b>'s account and assets will be permanently deleted after {$serverConfig.userDeleteDelay} days.
</p>
<p>Are you sure you want to continue?</p>
{#if forceDelete}
<p>
<b>{user.name}</b>'s account and assets will be queued for permanent deletion <b>immediately</b>.
</p>
{:else}
<p>
<b>{user.name}</b>'s account and assets will be scheduled for permanent deletion in {$serverConfig.userDeleteDelay}
days.
</p>
{/if}
<div class="flex justify-center m-4 gap-2">
<label class="text-sm dark:text-immich-dark-fg" for="forceDelete">
Queue user and assets for immediate deletion
</label>
<input
id="forceDelete"
type="checkbox"
class="form-checkbox h-5 w-5"
bind:checked={forceDelete}
on:change={() => {
deleteButtonDisabled = forceDelete;
}}
/>
</div>
{#if forceDelete}
<p class="text-immich-error">
WARNING: This will immediately remove the user and all assets. This cannot be undone and the files cannot be
recovered.
</p>
<p class="immich-form-label text-sm" id="confirm-user-desc">
To confirm, type "{user.email}" below
</p>
<input
class="immich-form-input w-full pb-2"
id="confirm-user-id"
aria-describedby="confirm-user-desc"
name="confirm-user-id"
type="text"
on:input={handleConfirm}
/>
{/if}
</div>
</svelte:fragment>
</ConfirmDialogue>
@@ -1,5 +1,6 @@
<script lang="ts">
import ConfirmDialogue from '$lib/components/shared-components/confirm-dialogue.svelte';
import { handleError } from '$lib/utils/handle-error';
import { restoreUser, type UserResponseDto } from '@immich/sdk';
import { createEventDispatcher } from 'svelte';
@@ -12,10 +13,15 @@
}>();
const handleRestoreUser = async () => {
const { deletedAt } = await restoreUser({ id: user.id });
if (deletedAt == undefined) {
dispatch('success');
} else {
try {
const { deletedAt } = await restoreUser({ id: user.id });
if (deletedAt == undefined) {
dispatch('success');
} else {
dispatch('fail');
}
} catch (error) {
handleError(error, 'Unable to restore user');
dispatch('fail');
}
};
@@ -69,7 +69,7 @@
>
<p slot="desc" class="immich-form-label pb-2 text-sm">
The name of a CLIP model listed <a href="https://huggingface.co/immich-app"><u>here</u></a>. Note that you
must re-run the 'Encode CLIP' job for all images upon changing a model.
must re-run the 'Smart Search' job for all images upon changing a model.
</p>
</SettingInputField>
</div>
@@ -0,0 +1,32 @@
<script lang="ts">
import { dateFormats } from '$lib/constants';
import { locale } from '$lib/stores/preferences.store';
import type { AlbumResponseDto } from '@immich/sdk';
export let album: AlbumResponseDto;
$: startDate = formatDate(album.startDate);
$: endDate = formatDate(album.endDate);
const formatDate = (date?: string) => {
return date ? new Date(date).toLocaleDateString($locale, dateFormats.album) : undefined;
};
const getDateRange = (start?: string, end?: string) => {
if (start && end && start !== end) {
return `${start} - ${end}`;
}
if (start) {
return start;
}
return '';
};
</script>
<span class="my-2 flex gap-2 text-sm font-medium text-gray-500" data-testid="album-details">
<p>{getDateRange(startDate, endDate)}</p>
<p>·</p>
<p>{album.assetCount} items</p>
</span>
@@ -3,11 +3,9 @@
import SelectAllAssets from '$lib/components/photos-page/actions/select-all-assets.svelte';
import { assetViewingStore } from '$lib/stores/asset-viewing.store';
import { dragAndDropFilesStore } from '$lib/stores/drag-and-drop-files.store';
import { locale } from '$lib/stores/preferences.store';
import { fileUploadHandler, openFileUploadDialog } from '$lib/utils/file-uploader';
import type { AlbumResponseDto, SharedLinkResponseDto, UserResponseDto } from '@immich/sdk';
import { onDestroy, onMount } from 'svelte';
import { dateFormats } from '../../constants';
import { createAssetInteractionStore } from '../../stores/asset-interaction.store';
import { AssetStore } from '../../stores/assets.store';
import { downloadArchive } from '../../utils/asset-utils';
@@ -21,6 +19,7 @@
import { shouldIgnoreShortcut } from '$lib/utils/shortcut';
import { mdiFileImagePlusOutline, mdiFolderDownloadOutline } from '@mdi/js';
import { handlePromiseError } from '$lib/utils';
import AlbumSummary from './album-summary.svelte';
export let sharedLink: SharedLinkResponseDto;
export let user: UserResponseDto | undefined = undefined;
@@ -40,31 +39,6 @@
}
});
const getDateRange = () => {
const { startDate, endDate } = album;
let start = '';
let end = '';
if (startDate) {
start = new Date(startDate).toLocaleDateString($locale, dateFormats.album);
}
if (endDate) {
end = new Date(endDate).toLocaleDateString($locale, dateFormats.album);
}
if (startDate && endDate && start !== end) {
return `${start} - ${end}`;
}
if (start) {
return start;
}
return '';
};
const onKeyboardPress = (event: KeyboardEvent) => handleKeyboardPress(event);
onMount(() => {
@@ -148,13 +122,8 @@
{album.albumName}
</h1>
<!-- ALBUM SUMMARY -->
{#if album.assetCount > 0}
<span class="my-4 flex gap-2 text-sm font-medium text-gray-500" data-testid="album-details">
<p class="">{getDateRange()}</p>
<p>·</p>
<p>{album.assetCount} items</p>
</span>
<AlbumSummary {album} />
{/if}
<!-- ALBUM DESCRIPTION -->
+1
View File
@@ -12,6 +12,7 @@ export interface ReleaseEvent {
}
export interface Events {
on_upload_success: (asset: AssetResponseDto) => void;
on_user_delete: (id: string) => void;
on_asset_delete: (assetId: string) => void;
on_asset_trash: (assetIds: string[]) => void;
on_asset_update: (asset: AssetResponseDto) => void;
@@ -31,7 +31,7 @@
notificationController,
} from '$lib/components/shared-components/notification/notification';
import UserAvatar from '$lib/components/shared-components/user-avatar.svelte';
import { AppRoute, dateFormats } from '$lib/constants';
import { AppRoute } from '$lib/constants';
import { numberOfComments, setNumberOfComments, updateNumberOfComments } from '$lib/stores/activity.store';
import { createAssetInteractionStore } from '$lib/stores/asset-interaction.store';
import { assetViewingStore } from '$lib/stores/asset-viewing.store';
@@ -74,6 +74,7 @@
import AlbumTitle from '$lib/components/album-page/album-title.svelte';
import AlbumDescription from '$lib/components/album-page/album-description.svelte';
import { handlePromiseError } from '$lib/utils';
import AlbumSummary from '$lib/components/album-page/album-summary.svelte';
export let data: PageData;
@@ -280,31 +281,6 @@
album = await getAlbumInfo({ id: album.id, withoutAssets: true });
};
const getDateRange = () => {
const { startDate, endDate } = album;
let start = '';
let end = '';
if (startDate) {
start = new Date(startDate).toLocaleDateString($locale, dateFormats.album);
}
if (endDate) {
end = new Date(endDate).toLocaleDateString($locale, dateFormats.album);
}
if (startDate && endDate && start !== end) {
return `${start} - ${end}`;
}
if (start) {
return start;
}
return '';
};
const handleAddAssets = async () => {
const assetIds = [...$timelineSelected].map((asset) => asset.id);
@@ -389,6 +365,11 @@
}
};
const handleRemoveAssets = async (assetIds: string[]) => {
assetStore.removeAssets(assetIds);
await refreshAlbum();
};
const handleUpdateThumbnail = async (assetId: string) => {
if (viewMode !== ViewMode.SELECT_THUMBNAIL) {
return;
@@ -429,10 +410,10 @@
{/if}
<DownloadAction menuItem filename="{album.albumName}.zip" />
{#if isOwned || isAllUserOwned}
<RemoveFromAlbum menuItem bind:album onRemove={(assetIds) => assetStore.removeAssets(assetIds)} />
<RemoveFromAlbum menuItem bind:album onRemove={handleRemoveAssets} />
{/if}
{#if isAllUserOwned}
<DeleteAssets menuItem onAssetDelete={(assetIds) => assetStore.removeAssets(assetIds)} />
<DeleteAssets menuItem onAssetDelete={handleRemoveAssets} />
<ChangeDate menuItem />
<ChangeLocation menuItem />
{/if}
@@ -469,9 +450,7 @@
<CircleIconButton title="Album options" on:click={handleOpenAlbumOptions} icon={mdiDotsVertical}>
{#if viewMode === ViewMode.ALBUM_OPTIONS}
<ContextMenu {...contextMenuPosition}>
{#if album.assetCount !== 0}
<MenuOption on:click={handleStartSlideshow} text="Slideshow" />
{/if}
<MenuOption on:click={handleStartSlideshow} text="Slideshow" />
<MenuOption on:click={() => (viewMode = ViewMode.SELECT_THUMBNAIL)} text="Set album cover" />
<MenuOption on:click={() => (viewMode = ViewMode.OPTIONS)} text="Options" />
</ContextMenu>
@@ -485,7 +464,7 @@
<Button
size="sm"
rounded="lg"
disabled={album.assetCount == 0}
disabled={album.assetCount === 0}
on:click={() => (viewMode = ViewMode.SELECT_USERS)}
>
Share
@@ -557,13 +536,8 @@
<section class="pt-24">
<AlbumTitle id={album.id} albumName={album.albumName} {isOwned} />
<!-- ALBUM SUMMARY -->
{#if album.assetCount > 0}
<span class="my-2 flex gap-2 text-sm font-medium text-gray-500" data-testid="album-details">
<p class="">{getDateRange()}</p>
<p>·</p>
<p>{album.assetCount} items</p>
</span>
<AlbumSummary {album} />
{/if}
<!-- ALBUM SHARING -->
@@ -72,9 +72,9 @@
},
{
item: LibrarySettings,
title: 'Library',
subtitle: 'Manage library settings',
key: 'library',
title: 'External Library',
subtitle: 'Manage external library settings',
key: 'external-library',
},
{
item: LoggingSettings,
@@ -8,11 +8,18 @@
import EditUserForm from '$lib/components/forms/edit-user-form.svelte';
import UserPageLayout from '$lib/components/layouts/user-page-layout.svelte';
import FullScreenModal from '$lib/components/shared-components/full-screen-modal.svelte';
import {
NotificationType,
notificationController,
} from '$lib/components/shared-components/notification/notification';
import { locale } from '$lib/stores/preferences.store';
import { serverConfig } from '$lib/stores/server-config.store';
import { user } from '$lib/stores/user.store';
import { websocketEvents } from '$lib/stores/websocket';
import { asByteUnitString } from '$lib/utils/byte-units';
import { getAllUsers, type UserResponseDto } from '@immich/sdk';
import { UserStatus, getAllUsers, type UserResponseDto } from '@immich/sdk';
import { mdiClose, mdiDeleteRestore, mdiPencilOutline, mdiTrashCanOutline } from '@mdi/js';
import { DateTime } from 'luxon';
import { onMount } from 'svelte';
import type { PageData } from './$types';
@@ -26,13 +33,26 @@
let shouldShowRestoreDialog = false;
let selectedUser: UserResponseDto;
const refresh = async () => {
allUsers = await getAllUsers({ isAll: false });
};
const onDeleteSuccess = (userId: string) => {
const user = allUsers.find(({ id }) => id === userId);
if (user) {
allUsers = allUsers.filter((user) => user.id !== userId);
notificationController.show({
type: NotificationType.Info,
message: `User ${user.email} has been successfully removed.`,
});
}
};
onMount(() => {
allUsers = $page.data.allUsers;
});
const isDeleted = (user: UserResponseDto): boolean => {
return user.deletedAt != undefined;
};
return websocketEvents.on('on_user_delete', onDeleteSuccess);
});
const deleteDateFormat: Intl.DateTimeFormatOptions = {
month: 'long',
@@ -40,14 +60,14 @@
year: 'numeric',
};
const getDeleteDate = (user: UserResponseDto): string => {
let deletedAt = new Date(user.deletedAt ?? Date.now());
deletedAt.setDate(deletedAt.getDate() + 7);
return deletedAt.toLocaleString($locale, deleteDateFormat);
const getDeleteDate = (deletedAt: string): string => {
return DateTime.fromISO(deletedAt)
.plus({ days: $serverConfig.userDeleteDelay })
.toLocaleString(deleteDateFormat, { locale: $locale });
};
const onUserCreated = async () => {
allUsers = await getAllUsers({ isAll: false });
await refresh();
shouldShowCreateUserForm = false;
};
@@ -57,12 +77,12 @@
};
const onEditUserSuccess = async () => {
allUsers = await getAllUsers({ isAll: false });
await refresh();
shouldShowEditUserForm = false;
};
const onEditPasswordSuccess = async () => {
allUsers = await getAllUsers({ isAll: false });
await refresh();
shouldShowEditUserForm = false;
shouldShowInfoPanel = true;
};
@@ -72,13 +92,8 @@
shouldShowDeleteConfirmDialog = true;
};
const onUserDeleteSuccess = async () => {
allUsers = await getAllUsers({ isAll: false });
shouldShowDeleteConfirmDialog = false;
};
const onUserDeleteFail = async () => {
allUsers = await getAllUsers({ isAll: false });
const onUserDelete = async () => {
await refresh();
shouldShowDeleteConfirmDialog = false;
};
@@ -87,14 +102,8 @@
shouldShowRestoreDialog = true;
};
const onUserRestoreSuccess = async () => {
allUsers = await getAllUsers({ isAll: false });
shouldShowRestoreDialog = false;
};
const onUserRestoreFail = async () => {
// show fail dialog
allUsers = await getAllUsers({ isAll: false });
const onUserRestore = async () => {
await refresh();
shouldShowRestoreDialog = false;
};
</script>
@@ -123,8 +132,8 @@
{#if shouldShowDeleteConfirmDialog}
<DeleteConfirmDialog
user={selectedUser}
on:success={onUserDeleteSuccess}
on:fail={onUserDeleteFail}
on:success={onUserDelete}
on:fail={onUserDelete}
on:cancel={() => (shouldShowDeleteConfirmDialog = false)}
/>
{/if}
@@ -132,22 +141,28 @@
{#if shouldShowRestoreDialog}
<RestoreDialogue
user={selectedUser}
on:success={onUserRestoreSuccess}
on:fail={onUserRestoreFail}
on:success={onUserRestore}
on:fail={onUserRestore}
on:cancel={() => (shouldShowRestoreDialog = false)}
/>
{/if}
{#if shouldShowInfoPanel}
<FullScreenModal onClose={() => (shouldShowInfoPanel = false)}>
<div class="w-[500px] max-w-[95vw] rounded-3xl border bg-white p-8 text-sm shadow-sm">
<h1 class="mb-4 text-lg font-medium text-immich-primary">Password reset success</h1>
<div
class="w-[500px] max-w-[95vw] rounded-3xl bg-immich-bg p-8 text-immich-fg shadow-sm dark:bg-immich-dark-gray dark:text-immich-dark-fg"
>
<h1 class="mb-4 text-2xl font-medium text-immich-primary dark:text-immich-dark-primary">
Password reset success
</h1>
<p>
The user's password has been reset to the default <code
class="rounded-md bg-gray-200 px-2 py-1 font-bold text-immich-primary">password</code
class="rounded-md bg-gray-200 px-2 py-1 font-bold text-immich-primary dark:text-immich-dark-primary dark:bg-gray-700"
>password</code
>
<br />
<br />
Please inform the user, and they will need to change the password at the next log-on.
</p>
@@ -173,9 +188,7 @@
{#if allUsers}
{#each allUsers as immichUser, index}
<tr
class="flex h-[80px] overflow-hidden w-full place-items-center text-center dark:text-immich-dark-fg {isDeleted(
immichUser,
)
class="flex h-[80px] overflow-hidden w-full place-items-center text-center dark:text-immich-dark-fg {immichUser.deletedAt
? 'bg-red-300 dark:bg-red-900'
: index % 2 == 0
? 'bg-immich-gray dark:bg-immich-dark-gray/75'
@@ -195,7 +208,7 @@
</div>
</td>
<td class="w-4/12 lg:w-3/12 xl:w-2/12 text-ellipsis break-all text-sm">
{#if !isDeleted(immichUser)}
{#if !immichUser.deletedAt}
<button
on:click={() => editUserHandler(immichUser)}
class="rounded-full bg-immich-primary p-2 sm:p-3 text-gray-100 transition-all duration-150 hover:bg-immich-primary/75 dark:bg-immich-dark-primary dark:text-gray-700 max-sm:mb-1"
@@ -211,11 +224,11 @@
</button>
{/if}
{/if}
{#if isDeleted(immichUser)}
{#if immichUser.deletedAt && immichUser.status === UserStatus.Deleted}
<button
on:click={() => restoreUserHandler(immichUser)}
class="rounded-full bg-immich-primary p-3 text-gray-100 transition-all duration-150 hover:bg-immich-primary/75 dark:bg-immich-dark-primary dark:text-gray-700"
title="scheduled removal on {getDeleteDate(immichUser)}"
title="scheduled removal on {getDeleteDate(immichUser.deletedAt)}"
>
<Icon path={mdiDeleteRestore} size="16" />
</button>
+2 -1
View File
@@ -1,5 +1,5 @@
import { faker } from '@faker-js/faker';
import { UserAvatarColor, type UserResponseDto } from '@immich/sdk';
import { UserAvatarColor, UserStatus, type UserResponseDto } from '@immich/sdk';
import { Sync } from 'factory.ts';
export const userFactory = Sync.makeFactory<UserResponseDto>({
@@ -18,4 +18,5 @@ export const userFactory = Sync.makeFactory<UserResponseDto>({
avatarColor: UserAvatarColor.Primary,
quotaUsageInBytes: 0,
quotaSizeInBytes: null,
status: UserStatus.Active,
});