diff --git a/docs/docs/install/config-file.md b/docs/docs/install/config-file.md index 8a7776a420..0d7c8dafc1 100644 --- a/docs/docs/install/config-file.md +++ b/docs/docs/install/config-file.md @@ -128,6 +128,9 @@ The default configuration looks like this: "theme": { "customCss": "" }, + "user": { + "deleteDelay": 7 + }, "library": { "scan": { "enabled": true, diff --git a/e2e/src/api/specs/server-info.e2e-spec.ts b/e2e/src/api/specs/server-info.e2e-spec.ts index 7c8c45709e..b8262cb68a 100644 --- a/e2e/src/api/specs/server-info.e2e-spec.ts +++ b/e2e/src/api/specs/server-info.e2e-spec.ts @@ -88,6 +88,7 @@ describe('/server-info', () => { loginPageMessage: '', oauthButtonText: 'Login with OAuth', trashDays: 30, + userDeleteDelay: 7, isInitialized: true, externalDomain: '', isOnboarded: false, diff --git a/mobile/openapi/.openapi-generator/FILES b/mobile/openapi/.openapi-generator/FILES index ea413b4870..6144510b10 100644 --- a/mobile/openapi/.openapi-generator/FILES +++ b/mobile/openapi/.openapi-generator/FILES @@ -160,6 +160,7 @@ doc/SystemConfigTemplateStorageOptionDto.md doc/SystemConfigThemeDto.md doc/SystemConfigThumbnailDto.md doc/SystemConfigTrashDto.md +doc/SystemConfigUserDto.md doc/TagApi.md doc/TagResponseDto.md doc/TagTypeEnum.md @@ -357,6 +358,7 @@ lib/model/system_config_template_storage_option_dto.dart lib/model/system_config_theme_dto.dart lib/model/system_config_thumbnail_dto.dart lib/model/system_config_trash_dto.dart +lib/model/system_config_user_dto.dart lib/model/tag_response_dto.dart lib/model/tag_type_enum.dart lib/model/thumbnail_format.dart @@ -539,6 +541,7 @@ test/system_config_template_storage_option_dto_test.dart test/system_config_theme_dto_test.dart test/system_config_thumbnail_dto_test.dart test/system_config_trash_dto_test.dart +test/system_config_user_dto_test.dart test/tag_api_test.dart test/tag_response_dto_test.dart test/tag_type_enum_test.dart diff --git a/mobile/openapi/README.md b/mobile/openapi/README.md index b8548c79e6..d61ebcb65d 100644 --- a/mobile/openapi/README.md +++ b/mobile/openapi/README.md @@ -355,6 +355,7 @@ Class | Method | HTTP request | Description - [SystemConfigThemeDto](doc//SystemConfigThemeDto.md) - [SystemConfigThumbnailDto](doc//SystemConfigThumbnailDto.md) - [SystemConfigTrashDto](doc//SystemConfigTrashDto.md) + - [SystemConfigUserDto](doc//SystemConfigUserDto.md) - [TagResponseDto](doc//TagResponseDto.md) - [TagTypeEnum](doc//TagTypeEnum.md) - [ThumbnailFormat](doc//ThumbnailFormat.md) diff --git a/mobile/openapi/doc/ServerConfigDto.md b/mobile/openapi/doc/ServerConfigDto.md index 317431b9bb..7261965bfb 100644 --- a/mobile/openapi/doc/ServerConfigDto.md +++ b/mobile/openapi/doc/ServerConfigDto.md @@ -14,6 +14,7 @@ Name | Type | Description | Notes **loginPageMessage** | **String** | | **oauthButtonText** | **String** | | **trashDays** | **int** | | +**userDeleteDelay** | **int** | | [[Back to Model list]](../README.md#documentation-for-models) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to README]](../README.md) diff --git a/mobile/openapi/doc/SystemConfigDto.md b/mobile/openapi/doc/SystemConfigDto.md index 51bf203ff7..ad1afbe9fc 100644 --- a/mobile/openapi/doc/SystemConfigDto.md +++ b/mobile/openapi/doc/SystemConfigDto.md @@ -23,6 +23,7 @@ Name | Type | Description | Notes **theme** | [**SystemConfigThemeDto**](SystemConfigThemeDto.md) | | **thumbnail** | [**SystemConfigThumbnailDto**](SystemConfigThumbnailDto.md) | | **trash** | [**SystemConfigTrashDto**](SystemConfigTrashDto.md) | | +**user** | [**SystemConfigUserDto**](SystemConfigUserDto.md) | | [[Back to Model list]](../README.md#documentation-for-models) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to README]](../README.md) diff --git a/mobile/openapi/doc/SystemConfigUserDto.md b/mobile/openapi/doc/SystemConfigUserDto.md new file mode 100644 index 0000000000..c295954a8d --- /dev/null +++ b/mobile/openapi/doc/SystemConfigUserDto.md @@ -0,0 +1,15 @@ +# openapi.model.SystemConfigUserDto + +## Load the model package +```dart +import 'package:openapi/api.dart'; +``` + +## Properties +Name | Type | Description | Notes +------------ | ------------- | ------------- | ------------- +**deleteDelay** | **int** | | + +[[Back to Model list]](../README.md#documentation-for-models) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to README]](../README.md) + + diff --git a/mobile/openapi/lib/api.dart b/mobile/openapi/lib/api.dart index 56bd907e0a..2dfe3a3bee 100644 --- a/mobile/openapi/lib/api.dart +++ b/mobile/openapi/lib/api.dart @@ -190,6 +190,7 @@ part 'model/system_config_template_storage_option_dto.dart'; part 'model/system_config_theme_dto.dart'; part 'model/system_config_thumbnail_dto.dart'; part 'model/system_config_trash_dto.dart'; +part 'model/system_config_user_dto.dart'; part 'model/tag_response_dto.dart'; part 'model/tag_type_enum.dart'; part 'model/thumbnail_format.dart'; diff --git a/mobile/openapi/lib/api_client.dart b/mobile/openapi/lib/api_client.dart index 24cffb7cff..d73b505937 100644 --- a/mobile/openapi/lib/api_client.dart +++ b/mobile/openapi/lib/api_client.dart @@ -462,6 +462,8 @@ class ApiClient { return SystemConfigThumbnailDto.fromJson(value); case 'SystemConfigTrashDto': return SystemConfigTrashDto.fromJson(value); + case 'SystemConfigUserDto': + return SystemConfigUserDto.fromJson(value); case 'TagResponseDto': return TagResponseDto.fromJson(value); case 'TagTypeEnum': diff --git a/mobile/openapi/lib/model/server_config_dto.dart b/mobile/openapi/lib/model/server_config_dto.dart index 1509c1bbeb..faa167c73a 100644 --- a/mobile/openapi/lib/model/server_config_dto.dart +++ b/mobile/openapi/lib/model/server_config_dto.dart @@ -19,6 +19,7 @@ class ServerConfigDto { required this.loginPageMessage, required this.oauthButtonText, required this.trashDays, + required this.userDeleteDelay, }); String externalDomain; @@ -33,6 +34,8 @@ class ServerConfigDto { int trashDays; + int userDeleteDelay; + @override bool operator ==(Object other) => identical(this, other) || other is ServerConfigDto && other.externalDomain == externalDomain && @@ -40,7 +43,8 @@ class ServerConfigDto { other.isOnboarded == isOnboarded && other.loginPageMessage == loginPageMessage && other.oauthButtonText == oauthButtonText && - other.trashDays == trashDays; + other.trashDays == trashDays && + other.userDeleteDelay == userDeleteDelay; @override int get hashCode => @@ -50,10 +54,11 @@ class ServerConfigDto { (isOnboarded.hashCode) + (loginPageMessage.hashCode) + (oauthButtonText.hashCode) + - (trashDays.hashCode); + (trashDays.hashCode) + + (userDeleteDelay.hashCode); @override - String toString() => 'ServerConfigDto[externalDomain=$externalDomain, isInitialized=$isInitialized, isOnboarded=$isOnboarded, loginPageMessage=$loginPageMessage, oauthButtonText=$oauthButtonText, trashDays=$trashDays]'; + String toString() => 'ServerConfigDto[externalDomain=$externalDomain, isInitialized=$isInitialized, isOnboarded=$isOnboarded, loginPageMessage=$loginPageMessage, oauthButtonText=$oauthButtonText, trashDays=$trashDays, userDeleteDelay=$userDeleteDelay]'; Map toJson() { final json = {}; @@ -63,6 +68,7 @@ class ServerConfigDto { json[r'loginPageMessage'] = this.loginPageMessage; json[r'oauthButtonText'] = this.oauthButtonText; json[r'trashDays'] = this.trashDays; + json[r'userDeleteDelay'] = this.userDeleteDelay; return json; } @@ -80,6 +86,7 @@ class ServerConfigDto { loginPageMessage: mapValueOfType(json, r'loginPageMessage')!, oauthButtonText: mapValueOfType(json, r'oauthButtonText')!, trashDays: mapValueOfType(json, r'trashDays')!, + userDeleteDelay: mapValueOfType(json, r'userDeleteDelay')!, ); } return null; @@ -133,6 +140,7 @@ class ServerConfigDto { 'loginPageMessage', 'oauthButtonText', 'trashDays', + 'userDeleteDelay', }; } diff --git a/mobile/openapi/lib/model/system_config_dto.dart b/mobile/openapi/lib/model/system_config_dto.dart index 26387a1631..0b5f64fc27 100644 --- a/mobile/openapi/lib/model/system_config_dto.dart +++ b/mobile/openapi/lib/model/system_config_dto.dart @@ -28,6 +28,7 @@ class SystemConfigDto { required this.theme, required this.thumbnail, required this.trash, + required this.user, }); SystemConfigFFmpegDto ffmpeg; @@ -60,6 +61,8 @@ class SystemConfigDto { SystemConfigTrashDto trash; + SystemConfigUserDto user; + @override bool operator ==(Object other) => identical(this, other) || other is SystemConfigDto && other.ffmpeg == ffmpeg && @@ -76,7 +79,8 @@ class SystemConfigDto { other.storageTemplate == storageTemplate && other.theme == theme && other.thumbnail == thumbnail && - other.trash == trash; + other.trash == trash && + other.user == user; @override int get hashCode => @@ -95,10 +99,11 @@ class SystemConfigDto { (storageTemplate.hashCode) + (theme.hashCode) + (thumbnail.hashCode) + - (trash.hashCode); + (trash.hashCode) + + (user.hashCode); @override - String toString() => 'SystemConfigDto[ffmpeg=$ffmpeg, job=$job, library_=$library_, logging=$logging, machineLearning=$machineLearning, map=$map, newVersionCheck=$newVersionCheck, oauth=$oauth, passwordLogin=$passwordLogin, reverseGeocoding=$reverseGeocoding, server=$server, storageTemplate=$storageTemplate, theme=$theme, thumbnail=$thumbnail, trash=$trash]'; + String toString() => 'SystemConfigDto[ffmpeg=$ffmpeg, job=$job, library_=$library_, logging=$logging, machineLearning=$machineLearning, map=$map, newVersionCheck=$newVersionCheck, oauth=$oauth, passwordLogin=$passwordLogin, reverseGeocoding=$reverseGeocoding, server=$server, storageTemplate=$storageTemplate, theme=$theme, thumbnail=$thumbnail, trash=$trash, user=$user]'; Map toJson() { final json = {}; @@ -117,6 +122,7 @@ class SystemConfigDto { json[r'theme'] = this.theme; json[r'thumbnail'] = this.thumbnail; json[r'trash'] = this.trash; + json[r'user'] = this.user; return json; } @@ -143,6 +149,7 @@ class SystemConfigDto { theme: SystemConfigThemeDto.fromJson(json[r'theme'])!, thumbnail: SystemConfigThumbnailDto.fromJson(json[r'thumbnail'])!, trash: SystemConfigTrashDto.fromJson(json[r'trash'])!, + user: SystemConfigUserDto.fromJson(json[r'user'])!, ); } return null; @@ -205,6 +212,7 @@ class SystemConfigDto { 'theme', 'thumbnail', 'trash', + 'user', }; } diff --git a/mobile/openapi/lib/model/system_config_user_dto.dart b/mobile/openapi/lib/model/system_config_user_dto.dart new file mode 100644 index 0000000000..08d939c483 --- /dev/null +++ b/mobile/openapi/lib/model/system_config_user_dto.dart @@ -0,0 +1,98 @@ +// +// 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 SystemConfigUserDto { + /// Returns a new [SystemConfigUserDto] instance. + SystemConfigUserDto({ + required this.deleteDelay, + }); + + int deleteDelay; + + @override + bool operator ==(Object other) => identical(this, other) || other is SystemConfigUserDto && + other.deleteDelay == deleteDelay; + + @override + int get hashCode => + // ignore: unnecessary_parenthesis + (deleteDelay.hashCode); + + @override + String toString() => 'SystemConfigUserDto[deleteDelay=$deleteDelay]'; + + Map toJson() { + final json = {}; + json[r'deleteDelay'] = this.deleteDelay; + return json; + } + + /// Returns a new [SystemConfigUserDto] instance and imports its values from + /// [value] if it's a [Map], null otherwise. + // ignore: prefer_constructors_over_static_methods + static SystemConfigUserDto? fromJson(dynamic value) { + if (value is Map) { + final json = value.cast(); + + return SystemConfigUserDto( + deleteDelay: mapValueOfType(json, r'deleteDelay')!, + ); + } + return null; + } + + static List listFromJson(dynamic json, {bool growable = false,}) { + final result = []; + if (json is List && json.isNotEmpty) { + for (final row in json) { + final value = SystemConfigUserDto.fromJson(row); + if (value != null) { + result.add(value); + } + } + } + return result.toList(growable: growable); + } + + static Map mapFromJson(dynamic json) { + final map = {}; + if (json is Map && json.isNotEmpty) { + json = json.cast(); // ignore: parameter_assignments + for (final entry in json.entries) { + final value = SystemConfigUserDto.fromJson(entry.value); + if (value != null) { + map[entry.key] = value; + } + } + } + return map; + } + + // maps a json object with a list of SystemConfigUserDto-objects as value to a dart map + static Map> mapListFromJson(dynamic json, {bool growable = false,}) { + final map = >{}; + if (json is Map && json.isNotEmpty) { + // ignore: parameter_assignments + json = json.cast(); + for (final entry in json.entries) { + map[entry.key] = SystemConfigUserDto.listFromJson(entry.value, growable: growable,); + } + } + return map; + } + + /// The list of required keys that must be present in a JSON. + static const requiredKeys = { + 'deleteDelay', + }; +} + diff --git a/mobile/openapi/test/server_config_dto_test.dart b/mobile/openapi/test/server_config_dto_test.dart index 813ac25656..f76556c50f 100644 --- a/mobile/openapi/test/server_config_dto_test.dart +++ b/mobile/openapi/test/server_config_dto_test.dart @@ -46,6 +46,11 @@ void main() { // TODO }); + // int userDeleteDelay + test('to test the property `userDeleteDelay`', () async { + // TODO + }); + }); diff --git a/mobile/openapi/test/system_config_dto_test.dart b/mobile/openapi/test/system_config_dto_test.dart index 5f41549870..b41d07e5f9 100644 --- a/mobile/openapi/test/system_config_dto_test.dart +++ b/mobile/openapi/test/system_config_dto_test.dart @@ -91,6 +91,11 @@ void main() { // TODO }); + // SystemConfigUserDto user + test('to test the property `user`', () async { + // TODO + }); + }); diff --git a/mobile/openapi/test/system_config_user_dto_test.dart b/mobile/openapi/test/system_config_user_dto_test.dart new file mode 100644 index 0000000000..d3c7be050d --- /dev/null +++ b/mobile/openapi/test/system_config_user_dto_test.dart @@ -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 SystemConfigUserDto +void main() { + // final instance = SystemConfigUserDto(); + + group('test SystemConfigUserDto', () { + // int deleteDelay + test('to test the property `deleteDelay`', () async { + // TODO + }); + + + }); + +} diff --git a/open-api/immich-openapi-specs.json b/open-api/immich-openapi-specs.json index 132df95b91..676c91233c 100644 --- a/open-api/immich-openapi-specs.json +++ b/open-api/immich-openapi-specs.json @@ -9090,6 +9090,9 @@ }, "trashDays": { "type": "integer" + }, + "userDeleteDelay": { + "type": "integer" } }, "required": [ @@ -9098,7 +9101,8 @@ "isOnboarded", "loginPageMessage", "oauthButtonText", - "trashDays" + "trashDays", + "userDeleteDelay" ], "type": "object" }, @@ -9661,6 +9665,9 @@ }, "trash": { "$ref": "#/components/schemas/SystemConfigTrashDto" + }, + "user": { + "$ref": "#/components/schemas/SystemConfigUserDto" } }, "required": [ @@ -9678,7 +9685,8 @@ "storageTemplate", "theme", "thumbnail", - "trash" + "trash", + "user" ], "type": "object" }, @@ -10162,6 +10170,17 @@ ], "type": "object" }, + "SystemConfigUserDto": { + "properties": { + "deleteDelay": { + "type": "integer" + } + }, + "required": [ + "deleteDelay" + ], + "type": "object" + }, "TagResponseDto": { "properties": { "id": { diff --git a/open-api/typescript-sdk/src/fetch-client.ts b/open-api/typescript-sdk/src/fetch-client.ts index 77fd06fe74..334037f1e6 100644 --- a/open-api/typescript-sdk/src/fetch-client.ts +++ b/open-api/typescript-sdk/src/fetch-client.ts @@ -705,6 +705,7 @@ export type ServerConfigDto = { loginPageMessage: string; oauthButtonText: string; trashDays: number; + userDeleteDelay: number; }; export type ServerFeaturesDto = { configFile: boolean; @@ -918,6 +919,9 @@ export type SystemConfigTrashDto = { days: number; enabled: boolean; }; +export type SystemConfigUserDto = { + deleteDelay: number; +}; export type SystemConfigDto = { ffmpeg: SystemConfigFFmpegDto; job: SystemConfigJobDto; @@ -934,6 +938,7 @@ export type SystemConfigDto = { theme: SystemConfigThemeDto; thumbnail: SystemConfigThumbnailDto; trash: SystemConfigTrashDto; + user: SystemConfigUserDto; }; export type SystemConfigTemplateStorageOptionDto = { dayOptions: string[]; diff --git a/server/src/domain/server-info/server-info.dto.ts b/server/src/domain/server-info/server-info.dto.ts index b3ef426dae..99d4f1566b 100644 --- a/server/src/domain/server-info/server-info.dto.ts +++ b/server/src/domain/server-info/server-info.dto.ts @@ -88,6 +88,8 @@ export class ServerConfigDto { loginPageMessage!: string; @ApiProperty({ type: 'integer' }) trashDays!: number; + @ApiProperty({ type: 'integer' }) + userDeleteDelay!: number; isInitialized!: boolean; isOnboarded!: boolean; externalDomain!: string; diff --git a/server/src/domain/server-info/server-info.service.spec.ts b/server/src/domain/server-info/server-info.service.spec.ts index e097509e6a..8c90f8107f 100644 --- a/server/src/domain/server-info/server-info.service.spec.ts +++ b/server/src/domain/server-info/server-info.service.spec.ts @@ -196,6 +196,7 @@ describe(ServerInfoService.name, () => { loginPageMessage: '', oauthButtonText: 'Login with OAuth', trashDays: 30, + userDeleteDelay: 7, isInitialized: undefined, isOnboarded: false, externalDomain: '', diff --git a/server/src/domain/server-info/server-info.service.ts b/server/src/domain/server-info/server-info.service.ts index ba295aefab..04b3c4b6e6 100644 --- a/server/src/domain/server-info/server-info.service.ts +++ b/server/src/domain/server-info/server-info.service.ts @@ -96,6 +96,7 @@ export class ServerInfoService { return { loginPageMessage: config.server.loginPageMessage, trashDays: config.trash.days, + userDeleteDelay: config.user.deleteDelay, oauthButtonText: config.oauth.buttonText, isInitialized, isOnboarded: onboarding?.isOnboarded || false, diff --git a/server/src/domain/system-config/dto/system-config-user.dto.ts b/server/src/domain/system-config/dto/system-config-user.dto.ts new file mode 100644 index 0000000000..22d6ef5fc3 --- /dev/null +++ b/server/src/domain/system-config/dto/system-config-user.dto.ts @@ -0,0 +1,11 @@ +import { ApiProperty } from '@nestjs/swagger'; +import { Type } from 'class-transformer'; +import { IsInt, Min } from 'class-validator'; + +export class SystemConfigUserDto { + @IsInt() + @Min(1) + @Type(() => Number) + @ApiProperty({ type: 'integer' }) + deleteDelay!: number; +} diff --git a/server/src/domain/system-config/dto/system-config.dto.ts b/server/src/domain/system-config/dto/system-config.dto.ts index 122d78ca61..4906e293e9 100644 --- a/server/src/domain/system-config/dto/system-config.dto.ts +++ b/server/src/domain/system-config/dto/system-config.dto.ts @@ -16,6 +16,7 @@ import { SystemConfigStorageTemplateDto } from './system-config-storage-template import { SystemConfigThemeDto } from './system-config-theme.dto'; import { SystemConfigThumbnailDto } from './system-config-thumbnail.dto'; import { SystemConfigTrashDto } from './system-config-trash.dto'; +import { SystemConfigUserDto } from './system-config-user.dto'; export class SystemConfigDto implements SystemConfig { @Type(() => SystemConfigFFmpegDto) @@ -92,6 +93,11 @@ export class SystemConfigDto implements SystemConfig { @ValidateNested() @IsObject() server!: SystemConfigServerDto; + + @Type(() => SystemConfigUserDto) + @ValidateNested() + @IsObject() + user!: SystemConfigUserDto; } export function mapConfig(config: SystemConfig): SystemConfigDto { diff --git a/server/src/domain/system-config/system-config.core.ts b/server/src/domain/system-config/system-config.core.ts index a9d41d76d7..1699f7131d 100644 --- a/server/src/domain/system-config/system-config.core.ts +++ b/server/src/domain/system-config/system-config.core.ts @@ -140,6 +140,9 @@ export const defaults = Object.freeze({ externalDomain: '', loginPageMessage: '', }, + user: { + deleteDelay: 7, + }, }); export enum FeatureFlag { diff --git a/server/src/domain/system-config/system-config.service.spec.ts b/server/src/domain/system-config/system-config.service.spec.ts index 35e306a705..7721182152 100644 --- a/server/src/domain/system-config/system-config.service.spec.ts +++ b/server/src/domain/system-config/system-config.service.spec.ts @@ -23,6 +23,7 @@ const updates: SystemConfigEntity[] = [ { key: SystemConfigKey.FFMPEG_CRF, value: 30 }, { key: SystemConfigKey.OAUTH_AUTO_LAUNCH, value: true }, { key: SystemConfigKey.TRASH_DAYS, value: 10 }, + { key: SystemConfigKey.USER_DELETE_DELAY, value: 15 }, ]; const updatedConfig = Object.freeze({ @@ -140,6 +141,9 @@ const updatedConfig = Object.freeze({ enabled: false, }, }, + user: { + deleteDelay: 15, + }, }); describe(SystemConfigService.name, () => { @@ -199,6 +203,7 @@ describe(SystemConfigService.name, () => { { key: SystemConfigKey.FFMPEG_CRF, value: 30 }, { key: SystemConfigKey.OAUTH_AUTO_LAUNCH, value: true }, { key: SystemConfigKey.TRASH_DAYS, value: 10 }, + { key: SystemConfigKey.USER_DELETE_DELAY, value: 15 }, ]); await expect(sut.getConfig()).resolves.toEqual(updatedConfig); @@ -206,7 +211,12 @@ describe(SystemConfigService.name, () => { it('should load the config from a file', async () => { process.env.IMMICH_CONFIG_FILE = 'immich-config.json'; - const partialConfig = { ffmpeg: { crf: 30 }, oauth: { autoLaunch: true }, trash: { days: 10 } }; + const partialConfig = { + ffmpeg: { crf: 30 }, + oauth: { autoLaunch: true }, + trash: { days: 10 }, + user: { deleteDelay: 15 }, + }; configMock.readFile.mockResolvedValue(JSON.stringify(partialConfig)); await expect(sut.getConfig()).resolves.toEqual(updatedConfig); diff --git a/server/src/domain/user/user.service.spec.ts b/server/src/domain/user/user.service.spec.ts index a1e8b28c1a..cba4581562 100644 --- a/server/src/domain/user/user.service.spec.ts +++ b/server/src/domain/user/user.service.spec.ts @@ -13,7 +13,9 @@ import { newJobRepositoryMock, newLibraryRepositoryMock, newStorageRepositoryMock, + newSystemConfigRepositoryMock, newUserRepositoryMock, + systemConfigStub, userStub, } from '@test'; import { when } from 'jest-when'; @@ -26,6 +28,7 @@ import { IJobRepository, ILibraryRepository, IStorageRepository, + ISystemConfigRepository, IUserRepository, } from '../repositories'; import { UpdateUserDto } from './dto/update-user.dto'; @@ -48,17 +51,28 @@ describe(UserService.name, () => { let jobMock: jest.Mocked; let libraryMock: jest.Mocked; let storageMock: jest.Mocked; + let configMock: jest.Mocked; beforeEach(() => { albumMock = newAlbumRepositoryMock(); assetMock = newAssetRepositoryMock(); + configMock = newSystemConfigRepositoryMock(); cryptoRepositoryMock = newCryptoRepositoryMock(); jobMock = newJobRepositoryMock(); libraryMock = newLibraryRepositoryMock(); storageMock = newStorageRepositoryMock(); userMock = newUserRepositoryMock(); - sut = new UserService(albumMock, assetMock, cryptoRepositoryMock, jobMock, libraryMock, storageMock, userMock); + sut = new UserService( + albumMock, + assetMock, + cryptoRepositoryMock, + jobMock, + libraryMock, + storageMock, + configMock, + userMock, + ); when(userMock.get).calledWith(authStub.admin.user.id, {}).mockResolvedValue(userStub.admin); when(userMock.get).calledWith(authStub.admin.user.id, { withDeleted: true }).mockResolvedValue(userStub.admin); @@ -461,6 +475,22 @@ describe(UserService.name, () => { expect(jobMock.queueAll).toHaveBeenCalledWith([]); }); + it('should skip users not ready for deletion - deleteDelay30', async () => { + configMock.load.mockResolvedValue(systemConfigStub.deleteDelay30); + userMock.getDeletedUsers.mockResolvedValue([ + {}, + { deletedAt: undefined }, + { deletedAt: null }, + { deletedAt: makeDeletedAt(15) }, + ] as UserEntity[]); + + await sut.handleUserDeleteCheck(); + + expect(userMock.getDeletedUsers).toHaveBeenCalled(); + expect(jobMock.queue).not.toHaveBeenCalled(); + expect(jobMock.queueAll).toHaveBeenCalledWith([]); + }); + it('should queue user ready for deletion', async () => { const user = { id: 'deleted-user', deletedAt: makeDeletedAt(10) }; userMock.getDeletedUsers.mockResolvedValue([user] as UserEntity[]); @@ -470,6 +500,16 @@ describe(UserService.name, () => { expect(userMock.getDeletedUsers).toHaveBeenCalled(); expect(jobMock.queueAll).toHaveBeenCalledWith([{ name: JobName.USER_DELETION, data: { id: user.id } }]); }); + + it('should queue user ready for deletion - deleteDelay30', async () => { + const user = { id: 'deleted-user', deletedAt: makeDeletedAt(31) }; + userMock.getDeletedUsers.mockResolvedValue([user] as UserEntity[]); + + await sut.handleUserDeleteCheck(); + + expect(userMock.getDeletedUsers).toHaveBeenCalled(); + expect(jobMock.queueAll).toHaveBeenCalledWith([{ name: JobName.USER_DELETION, data: { id: user.id } }]); + }); }); describe('handleUserDelete', () => { diff --git a/server/src/domain/user/user.service.ts b/server/src/domain/user/user.service.ts index a5b3fb7dc7..ace2fb5e17 100644 --- a/server/src/domain/user/user.service.ts +++ b/server/src/domain/user/user.service.ts @@ -13,16 +13,19 @@ import { IJobRepository, ILibraryRepository, IStorageRepository, + ISystemConfigRepository, IUserRepository, UserFindOptions, } from '../repositories'; import { StorageCore, StorageFolder } from '../storage'; +import { SystemConfigCore } from '../system-config/system-config.core'; import { CreateUserDto, UpdateUserDto } from './dto'; import { CreateProfileImageResponseDto, UserResponseDto, mapCreateProfileImageResponse, mapUser } from './response-dto'; import { UserCore } from './user.core'; @Injectable() export class UserService { + private configCore: SystemConfigCore; private logger = new ImmichLogger(UserService.name); private userCore: UserCore; @@ -33,9 +36,11 @@ export class UserService { @Inject(IJobRepository) private jobRepository: IJobRepository, @Inject(ILibraryRepository) libraryRepository: ILibraryRepository, @Inject(IStorageRepository) private storageRepository: IStorageRepository, + @Inject(ISystemConfigRepository) configRepository: ISystemConfigRepository, @Inject(IUserRepository) private userRepository: IUserRepository, ) { this.userCore = UserCore.create(cryptoRepository, libraryRepository, userRepository); + this.configCore = SystemConfigCore.create(configRepository); } async getAll(auth: AuthDto, isAll: boolean): Promise { @@ -140,22 +145,26 @@ export class UserService { async handleUserDeleteCheck() { const users = await this.userRepository.getDeletedUsers(); + const config = await this.configCore.getConfig(); await this.jobRepository.queueAll( users.flatMap((user) => - this.isReadyForDeletion(user) ? [{ name: JobName.USER_DELETION, data: { id: user.id } }] : [], + this.isReadyForDeletion(user, config.user.deleteDelay) + ? [{ name: JobName.USER_DELETION, data: { id: user.id } }] + : [], ), ); return true; } async handleUserDelete({ id }: IEntityJob) { + const config = await this.configCore.getConfig(); const user = await this.userRepository.get(id, { withDeleted: true }); if (!user) { return false; } // just for extra protection here - if (!this.isReadyForDeletion(user)) { + if (!this.isReadyForDeletion(user, config.user.deleteDelay)) { this.logger.warn(`Skipped user that was not ready for deletion: id=${id}`); return false; } @@ -184,12 +193,12 @@ export class UserService { return true; } - private isReadyForDeletion(user: UserEntity): boolean { + private isReadyForDeletion(user: UserEntity, deleteDelay: number): boolean { if (!user.deletedAt) { return false; } - return DateTime.now().minus({ days: 7 }) > DateTime.fromJSDate(user.deletedAt); + return DateTime.now().minus({ days: deleteDelay }) > DateTime.fromJSDate(user.deletedAt); } private async findOrFail(id: string, options: UserFindOptions) { diff --git a/server/src/infra/entities/system-config.entity.ts b/server/src/infra/entities/system-config.entity.ts index e2d0c71f6b..1ba219429e 100644 --- a/server/src/infra/entities/system-config.entity.ts +++ b/server/src/infra/entities/system-config.entity.ts @@ -108,6 +108,8 @@ export enum SystemConfigKey { TRASH_DAYS = 'trash.days', THEME_CUSTOM_CSS = 'theme.customCss', + + USER_DELETE_DELAY = 'user.deleteDelay', } export enum TranscodePolicy { @@ -276,4 +278,7 @@ export interface SystemConfig { externalDomain: string; loginPageMessage: string; }; + user: { + deleteDelay: number; + }; } diff --git a/server/test/fixtures/system-config.stub.ts b/server/test/fixtures/system-config.stub.ts index 0e99fb07a2..9f9f02144c 100644 --- a/server/test/fixtures/system-config.stub.ts +++ b/server/test/fixtures/system-config.stub.ts @@ -27,6 +27,7 @@ export const systemConfigStub: Record = { { key: SystemConfigKey.OAUTH_AUTO_REGISTER, value: true }, { key: SystemConfigKey.OAUTH_DEFAULT_STORAGE_QUOTA, value: 1 }, ], + deleteDelay30: [{ key: SystemConfigKey.USER_DELETE_DELAY, value: 30 }], libraryWatchEnabled: [{ key: SystemConfigKey.LIBRARY_WATCH_ENABLED, value: true }], libraryWatchDisabled: [{ key: SystemConfigKey.LIBRARY_WATCH_ENABLED, value: false }], }; diff --git a/web/src/lib/components/admin-page/delete-confirm-dialoge.svelte b/web/src/lib/components/admin-page/delete-confirm-dialoge.svelte index 1046b7ef67..90246eb82b 100644 --- a/web/src/lib/components/admin-page/delete-confirm-dialoge.svelte +++ b/web/src/lib/components/admin-page/delete-confirm-dialoge.svelte @@ -2,6 +2,7 @@ import ConfirmDialogue from '$lib/components/shared-components/confirm-dialogue.svelte'; import { handleError } from '$lib/utils/handle-error'; import { deleteUser, type UserResponseDto } from '@immich/sdk'; + import { serverConfig } from '$lib/stores/server-config.store'; import { createEventDispatcher } from 'svelte'; export let user: UserResponseDto; @@ -30,7 +31,7 @@

- {user.name}'s account and assets will be permanently deleted after 7 days. + {user.name}'s account and assets will be permanently deleted after {$serverConfig.userDeleteDelay} days.

Are you sure you want to continue?

diff --git a/web/src/lib/components/admin-page/settings/admin-settings.svelte b/web/src/lib/components/admin-page/settings/admin-settings.svelte index 16b2afc7fe..8f819f1eb3 100644 --- a/web/src/lib/components/admin-page/settings/admin-settings.svelte +++ b/web/src/lib/components/admin-page/settings/admin-settings.svelte @@ -7,6 +7,7 @@ } from '$lib/components/shared-components/notification/notification'; import { handleError } from '$lib/utils/handle-error'; import { getConfig, getConfigDefaults, updateConfig, type SystemConfigDto } from '@immich/sdk'; + import { loadConfig } from '$lib/stores/server-config.store'; import { cloneDeep } from 'lodash-es'; import { createEventDispatcher, onMount } from 'svelte'; import type { SettingsEventType } from './admin-settings'; @@ -35,6 +36,8 @@ savedConfig = cloneDeep(newConfig); notificationController.show({ message: 'Settings saved', type: NotificationType.Info }); + await loadConfig(); + dispatch('save'); } catch (error) { handleError(error, 'Unable to save settings'); diff --git a/web/src/lib/components/admin-page/settings/user-settings/user-settings.svelte b/web/src/lib/components/admin-page/settings/user-settings/user-settings.svelte new file mode 100644 index 0000000000..81a93a4091 --- /dev/null +++ b/web/src/lib/components/admin-page/settings/user-settings/user-settings.svelte @@ -0,0 +1,45 @@ + + +
+
+
+
+ +
+ +
+ dispatch('reset', { ...detail, configKeys: ['user'] })} + on:save={() => dispatch('save', { user: config.user })} + showResetToDefault={!isEqual(savedConfig, defaultConfig)} + {disabled} + /> +
+
+
+
diff --git a/web/src/lib/stores/server-config.store.ts b/web/src/lib/stores/server-config.store.ts index 5b0a529834..3190a8e23f 100644 --- a/web/src/lib/stores/server-config.store.ts +++ b/web/src/lib/stores/server-config.store.ts @@ -25,6 +25,7 @@ export const serverConfig = writable({ oauthButtonText: '', loginPageMessage: '', trashDays: 30, + userDeleteDelay: 7, isInitialized: false, isOnboarded: false, externalDomain: '', diff --git a/web/src/routes/admin/system-settings/+page.svelte b/web/src/routes/admin/system-settings/+page.svelte index e5d67bb99a..9cfd23b8cf 100644 --- a/web/src/routes/admin/system-settings/+page.svelte +++ b/web/src/routes/admin/system-settings/+page.svelte @@ -15,6 +15,7 @@ import ThemeSettings from '$lib/components/admin-page/settings/theme/theme-settings.svelte'; import ThumbnailSettings from '$lib/components/admin-page/settings/thumbnail/thumbnail-settings.svelte'; import TrashSettings from '$lib/components/admin-page/settings/trash-settings/trash-settings.svelte'; + import UserSettings from '$lib/components/admin-page/settings/user-settings/user-settings.svelte'; import LinkButton from '$lib/components/elements/buttons/link-button.svelte'; import Icon from '$lib/components/elements/icon.svelte'; import UserPageLayout from '$lib/components/layouts/user-page-layout.svelte'; @@ -45,7 +46,8 @@ | typeof ThumbnailSettings | typeof TrashSettings | typeof NewVersionCheckSettings - | typeof FFmpegSettings; + | typeof FFmpegSettings + | typeof UserSettings; const downloadConfig = () => { const blob = new Blob([JSON.stringify(config, null, 2)], { type: 'application/json' }); @@ -134,6 +136,12 @@ subtitle: 'Manage trash settings', key: 'trash', }, + { + item: UserSettings, + title: 'User Settings', + subtitle: 'Manage user settings', + key: 'user-settings', + }, { item: NewVersionCheckSettings, title: 'Version Check',