From 9125999d1a41dc042b545545429cabbc120cd036 Mon Sep 17 00:00:00 2001 From: Sam Holton Date: Wed, 6 Mar 2024 00:45:40 -0500 Subject: [PATCH] feat(server,web): make user deletion delay configurable (#7663) * feat(server,web): make user deletion delay configurable * alphabetical order * add min for user.deleteDelay in SettingInputField * make config.user.deleteDelay SettingInputField min consistent format * fix e2e test * update description on user delete delay --- docs/docs/install/config-file.md | 3 ++ e2e/src/api/specs/server-info.e2e-spec.ts | 1 + mobile/openapi/.openapi-generator/FILES | 3 ++ mobile/openapi/README.md | Bin 24957 -> 25011 bytes mobile/openapi/doc/ServerConfigDto.md | Bin 596 -> 632 bytes mobile/openapi/doc/SystemConfigDto.md | Bin 1597 -> 1664 bytes mobile/openapi/doc/SystemConfigUserDto.md | Bin 0 -> 417 bytes mobile/openapi/lib/api.dart | Bin 8570 -> 8612 bytes mobile/openapi/lib/api_client.dart | Bin 24094 -> 24184 bytes .../openapi/lib/model/server_config_dto.dart | Bin 4376 -> 4701 bytes .../openapi/lib/model/system_config_dto.dart | Bin 6807 -> 7025 bytes .../lib/model/system_config_user_dto.dart | Bin 0 -> 2897 bytes .../openapi/test/server_config_dto_test.dart | Bin 1127 -> 1239 bytes .../openapi/test/system_config_dto_test.dart | Bin 2270 -> 2376 bytes .../test/system_config_user_dto_test.dart | Bin 0 -> 584 bytes open-api/immich-openapi-specs.json | 23 ++++++++- open-api/typescript-sdk/src/fetch-client.ts | 5 ++ .../src/domain/server-info/server-info.dto.ts | 2 + .../server-info/server-info.service.spec.ts | 1 + .../domain/server-info/server-info.service.ts | 1 + .../dto/system-config-user.dto.ts | 11 +++++ .../system-config/dto/system-config.dto.ts | 6 +++ .../system-config/system-config.core.ts | 3 ++ .../system-config.service.spec.ts | 12 ++++- server/src/domain/user/user.service.spec.ts | 42 +++++++++++++++- server/src/domain/user/user.service.ts | 17 +++++-- .../infra/entities/system-config.entity.ts | 5 ++ server/test/fixtures/system-config.stub.ts | 1 + .../admin-page/delete-confirm-dialoge.svelte | 3 +- .../admin-page/settings/admin-settings.svelte | 3 ++ .../user-settings/user-settings.svelte | 45 ++++++++++++++++++ web/src/lib/stores/server-config.store.ts | 1 + .../routes/admin/system-settings/+page.svelte | 10 +++- 33 files changed, 188 insertions(+), 10 deletions(-) create mode 100644 mobile/openapi/doc/SystemConfigUserDto.md create mode 100644 mobile/openapi/lib/model/system_config_user_dto.dart create mode 100644 mobile/openapi/test/system_config_user_dto_test.dart create mode 100644 server/src/domain/system-config/dto/system-config-user.dto.ts create mode 100644 web/src/lib/components/admin-page/settings/user-settings/user-settings.svelte 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 b8548c79e60aba7c0bc38b2e5da2af7401dd1b58..d61ebcb65d153624c026f30751ea46fda30aa541 100644 GIT binary patch delta 32 lcmex+h;j2_#tjpqC+kG;u!I(;7EN{x7Xvdkw?zMB1pwl<4GjPQ delta 14 WcmdmdnDOr+#tjpqHy?|B%L)KHUZ delta 11 Tcmeyta)o8XddA6@8J7Y8Aj$t+DD CgAy_T delta 11 ScmZqR-OICK2J7V8tV;nK7X-Wj diff --git a/mobile/openapi/doc/SystemConfigUserDto.md b/mobile/openapi/doc/SystemConfigUserDto.md new file mode 100644 index 0000000000000000000000000000000000000000..c295954a8d3a67a4d561c9e461b484079e003847 GIT binary patch literal 417 zcma)2Jx>EM4Bh=JEZtBVDc2pQ!gWBRRmI0tRl2BgOT;CP5;L5{k0&`QQCPrB{G8`! zKR3vcf{Cv6Y-wnp#w_ITI2_&SB~$8z0h$IM*jSWT!ik2_9Nx@{pznKYl7RD(li=)J zzdnmDs&FODZYp(B+NScE#8HNg@q{1vJjVKTln=>8aU2TTuoQ;Ah*G>jTK|PXsp5!) zIvUKVRN*V-f=pQf0K467@qxm)uI6!YS@!#@wp~1~mfd>QdfQk&yn6~_>R$TDNlzEZ dbNudJZtwruTkfGL*vMs%9}-^!e+{1lz$g27fWZI& literal 0 HcmV?d00001 diff --git a/mobile/openapi/lib/api.dart b/mobile/openapi/lib/api.dart index 56bd907e0ab8f58dd1c7d9ca88c086968e451a97..2dfe3a3bee225d772626ee0b5f8736793da2ef8a 100644 GIT binary patch delta 20 ccmez6w8VMC0_n*UB={x^%E@eAAbpD!09{xJ0{{R3 delta 12 UcmZ4D{L5*>0_n}Cq;IhT04e4M1^@s6 diff --git a/mobile/openapi/lib/api_client.dart b/mobile/openapi/lib/api_client.dart index 24cffb7cff2290e7241d841674680215f48a1467..d73b505937a2f201322e6f968d365a32cc299b7b 100644 GIT binary patch delta 30 mcmbQYhw;ZA#tqxNCkJ@)Or8)RJz2n=XY$4X_08M7GlT%o(F|t* delta 14 WcmeydhjHE>#tqxNHw*Y=2mt^$A_hwU diff --git a/mobile/openapi/lib/model/server_config_dto.dart b/mobile/openapi/lib/model/server_config_dto.dart index 1509c1bbeb7ab3b19175c987b72352410d082874..faa167c73a305800f89462a907db231a14617680 100644 GIT binary patch delta 306 zcmbQCbXR4AE7N3FCISA^;?yFS)ST3kR3MpHIoXkEmT+cXi2|DV=4(v3j0Os7YFr9H zkYAFKTBL`jM8Vb;LzgoPGo!eIHdvDen)=B$te=&1(6!jApefs|&(_RnsGyKloSzq6 zq>d)59;;xhP?C{ZjP9DvYuJUE1r^YZpWMqOhwkUiTe#xbWEB)J%+>+AyVjbkmWvAj D#dK&E delta 47 zcmV+~0MP&4B$y(wMFNv(0+zED184!Wt^@-ClhOt0v)u-R0kf9~4Fj|73sVLLeLD($ F3JRh`5D)+W diff --git a/mobile/openapi/lib/model/system_config_dto.dart b/mobile/openapi/lib/model/system_config_dto.dart index 26387a163104f253185371c7b78c6a38fd5fb8ae..0b5f64fc2709b3066fddbb68f653adc8ddff6ad4 100644 GIT binary patch delta 225 zcmbPk`q6BI9`j^TW&xJc;?$zeTFl-|62X7hVTmy&!1uxtTqC6knb zni`h^5agF+q!#Ic#1(99A@VQTZ!z*HXoICSz&dtvHZt<)fMsn}z#>-MXPKlG6tar* z^P-E?K|<=W3bqO*8JWdk!!|SW9b`370Bf;Qz~U6Ww4(f6FQ9e}Br`T|6TZzRq@VyX OPzUIOT5GOaE-nDFCQ3vA delta 49 zcmV-10M7sMHkUQ9E(5bh14jb0ss(fcvp5La0kbFycLB4j3(W$vnGU-Jvyc?e1_pgQ H3VjL+z@ZTI 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 0000000000000000000000000000000000000000..08d939c4836d2cbd05d271f77281f167fdb24458 GIT binary patch literal 2897 zcmbVOZExE)5dQ98aRG`%0aSVGry;4m7PT|vjtgH=1W*J;z5 zRPn!Cq0qf%Tm0WJh2MrNjlq=;yJxc2hSeq&IUXnq!IgE_!NV$1bDdVQ-k_N&S)Km& zOPZBT>Cp&}Gaw6)4X;@v68yUyjk1bq4L5sj`1Xyg3$gjExtbXXcO!r?V(^KZR@E99 z)O-i4faw~9O)}#N`UDIjiUrWu52h44$q_`uXS`zj7`G4SmA05)?C1!DJFGZol#j3o|8G=P;Rq11e)xM90Ma&iC)`7l?4Vr>8bY8^_NL&v-N; zN_fK!luVazWX>@K8)#4uiKgZm*P5w4k&Zl;TnlZ@BCpt_kKv&3%f2y6)Eh9;{fKO( zX91{JUcAXO?_qsrvHZxImo7#O`5uKRvMGjrr)7;n9Z!X*Sp{El2`2Es`tERPGY4z2 zQfJ;FXKVEMH@nVUSs7U$S~w8d|5aYV>Kt6JX%I%kH&(S=L!soh3%9aMsK^2h&PcT@ zEO{M6-Bu_ZQprweD^!o6M)3kW#x|R3GnAH2Npvapo)KA!ZCc8l7;_6Dk^gSfut0Xe zy7YQfF{8*PM+(H8`gMRKysb%+F>M|oA30sPR-&d1r4eD0?&I-t0bAC5A+Q$3a^LWm zsM={n499Rx;Q0wPBYp5N`a0PE9eV`gz+MN%2SKSm93WGuaOVoCIl{ypdT0B1qvVd= zR@gI8uuAjz(1YS2QCzo`u_6q_U#(q+1S~}*TqzkiJ+(4Rgua$$zHQ9D4~tmi)QGz1 za);x_n%?jCY&fPSA%qi>o2?1c?@%J7?g>7bSh@L8Y5Gwy3CnXQ1JBc8++%%&c;{s# zRTlMtT|ocKMzPJ*(cw3EkQG!ykd!`K2M${K%^!-?4+0;5JpEDlEl(3i&yO^YW1+iv zFJQPMQ@p0|Hl|zG4bCGfaaHYmG81|dT=xYLH?F8Bk!oWl*3_fuC*&erE9dwZ-_sDZ z{!X8C{v!;d>(EkIIX=?VvOr09x9x-}af#ZAF7N;s&z&VYb~6!2I(pE{3Quo`Ot1qCow#&c`=5spdX+1e{}Rji9AJa-MI4x!%x+Or5&>!pd=x>1^_)##Y6Aj zcX2!@%?agb1mJ0&K*q2v`Jjd2l?r92@#ba7cZ)K)<$!mZ#$%}~7`(e#T+_>0Cj)!Y a4ew*`$#=Nvr*{^Sqify1pk0n|&in%d=Cecq literal 0 HcmV?d00001 diff --git a/mobile/openapi/test/server_config_dto_test.dart b/mobile/openapi/test/server_config_dto_test.dart index 813ac25656db1f2da84e4a0a73a26a648bb24453..f76556c50f6fc725ebc27c23bf445ee75f7c0775 100644 GIT binary patch delta 55 ucmaFPah-EREX(Ad%-sB?#i>OusX3`7sX#KZa`JvAMGUb7X0^$&EL;Fnz!WL~ delta 11 Scmcc4`J7`zEX(AxEL;E`*aS}i diff --git a/mobile/openapi/test/system_config_dto_test.dart b/mobile/openapi/test/system_config_dto_test.dart index 5f41549870eeb1645a88f86fc426f2884c2f77cb..b41d07e5f9b2842ff834360da2667a4cbe573efa 100644 GIT binary patch delta 48 zcmca7ctU8y6^_a8*oApQi&KkSO7ay-f%IfU7DW~?!;wRci%UVFR@0h`tCkA@vnvlE delta 11 ScmX>hbWd=@6^_YvoLm4NzXUn} 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 0000000000000000000000000000000000000000..d3c7be050dd212e959c838a1c36ef7932c1b14e5 GIT binary patch literal 584 zcmZuuUrWO<5P$EdIG?(~Ty;;fF>u&U2D*Z74?bm~y>`JiiAkyy*?0GnIS}kaE_d+z zcS({&Ndl|=F28=vw%N;Sm!+`YJY^F|bJ*rPc+S(!`t6Ef9(iAKhr=K2;Vi{% XFN=`Vb1CaCPxGL2@`0!e_bK`T@Vmi5 literal 0 HcmV?d00001 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',