From 7a4ae7d1424f0e2dd6869b35a72c0d7d2d5bfea2 Mon Sep 17 00:00:00 2001 From: Sam Holton Date: Fri, 8 Mar 2024 17:49:39 -0500 Subject: [PATCH] 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 --- e2e/src/responses.ts | 1 + mobile/openapi/.openapi-generator/FILES | 6 ++ mobile/openapi/README.md | Bin 25057 -> 25135 bytes mobile/openapi/doc/DeleteUserDto.md | Bin 0 -> 417 bytes mobile/openapi/doc/PartnerResponseDto.md | Bin 1027 -> 1078 bytes mobile/openapi/doc/UserApi.md | Bin 19933 -> 20091 bytes mobile/openapi/doc/UserResponseDto.md | Bin 981 -> 1032 bytes mobile/openapi/doc/UserStatus.md | Bin 0 -> 376 bytes mobile/openapi/lib/api.dart | Bin 8649 -> 8715 bytes mobile/openapi/lib/api/user_api.dart | Bin 15551 -> 15770 bytes mobile/openapi/lib/api_client.dart | Bin 24266 -> 24431 bytes mobile/openapi/lib/api_helper.dart | Bin 5944 -> 6042 bytes mobile/openapi/lib/model/delete_user_dto.dart | Bin 0 -> 3147 bytes .../lib/model/partner_response_dto.dart | Bin 8488 -> 8712 bytes .../openapi/lib/model/user_response_dto.dart | Bin 7729 -> 7953 bytes mobile/openapi/lib/model/user_status.dart | Bin 0 -> 2679 bytes mobile/openapi/test/delete_user_dto_test.dart | Bin 0 -> 555 bytes .../test/partner_response_dto_test.dart | Bin 2176 -> 2277 bytes mobile/openapi/test/user_api_test.dart | Bin 1732 -> 1761 bytes .../openapi/test/user_response_dto_test.dart | Bin 2064 -> 2165 bytes mobile/openapi/test/user_status_test.dart | Bin 0 -> 417 bytes open-api/immich-openapi-specs.json | 34 ++++++++ open-api/typescript-sdk/src/fetch-client.ts | 20 ++++- server/src/domain/job/job.service.ts | 5 ++ .../repositories/communication.repository.ts | 2 + .../domain/repositories/user.repository.ts | 1 - server/src/domain/user/dto/delete-user.dto.ts | 6 ++ server/src/domain/user/dto/index.ts | 1 + .../user/response-dto/user-response.dto.ts | 5 +- server/src/domain/user/user.service.spec.ts | 44 +++++++--- server/src/domain/user/user.service.ts | 29 ++++--- .../src/immich/controllers/user.controller.ts | 9 +- server/src/infra/entities/user.entity.ts | 9 ++ .../migrations/1709870213078-AddUserStatus.ts | 14 +++ .../src/infra/repositories/user.repository.ts | 6 +- server/src/infra/sql/album.repository.sql | 13 +++ server/src/infra/sql/api.key.repository.sql | 1 + server/src/infra/sql/library.repository.sql | 4 + .../src/infra/sql/shared.link.repository.sql | 3 + server/src/infra/sql/user.repository.sql | 4 + .../src/infra/sql/user.token.repository.sql | 1 + .../test/repositories/user.repository.mock.ts | 1 - .../admin-page/delete-confirm-dialoge.svelte | 68 +++++++++++++-- .../admin-page/restore-dialoge.svelte | 14 ++- web/src/lib/stores/websocket.ts | 1 + .../routes/admin/user-management/+page.svelte | 81 ++++++++++-------- web/src/test-data/factories/user-factory.ts | 3 +- 47 files changed, 300 insertions(+), 86 deletions(-) create mode 100644 mobile/openapi/doc/DeleteUserDto.md create mode 100644 mobile/openapi/doc/UserStatus.md create mode 100644 mobile/openapi/lib/model/delete_user_dto.dart create mode 100644 mobile/openapi/lib/model/user_status.dart create mode 100644 mobile/openapi/test/delete_user_dto_test.dart create mode 100644 mobile/openapi/test/user_status_test.dart create mode 100644 server/src/domain/user/dto/delete-user.dto.ts create mode 100644 server/src/infra/migrations/1709870213078-AddUserStatus.ts diff --git a/e2e/src/responses.ts b/e2e/src/responses.ts index 76e289ade2..37892be0c8 100644 --- a/e2e/src/responses.ts +++ b/e2e/src/responses.ts @@ -76,6 +76,7 @@ export const signupResponseDto = { memoriesEnabled: true, quotaUsageInBytes: 0, quotaSizeInBytes: null, + status: 'active', }, }; diff --git a/mobile/openapi/.openapi-generator/FILES b/mobile/openapi/.openapi-generator/FILES index bdd8e1d4bc..ddebdaf77d 100644 --- a/mobile/openapi/.openapi-generator/FILES +++ b/mobile/openapi/.openapi-generator/FILES @@ -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 diff --git a/mobile/openapi/README.md b/mobile/openapi/README.md index 536c671b8df40abcd6c06920e4071a80c6f3673b..8f060a4a665d05064654551551c64725465f7182 100644 GIT binary patch delta 77 zcmaEOm~s6P#tmORWK(leOHxCNQ;S?m@?$kp@{{%TUEpGyUwFicNrPmAOA<>;i=he- L0-J5)moNbU-b5bC delta 23 fcmZ2~gz@2F#tmORHp_bkicNkH&#`$<{A4Boid+h& diff --git a/mobile/openapi/doc/DeleteUserDto.md b/mobile/openapi/doc/DeleteUserDto.md new file mode 100644 index 0000000000000000000000000000000000000000..50894b61675aa801239c5deafae49d78655d9bab GIT binary patch literal 417 zcma)2u}%Xq4Bh<|mToAGlW3MQ;S?m@)c|qT!3oe zd<{)&1$}+6*yIgfCc+>CAd(uHDG>7}+Y3ZY{wTLhS3ye)Aq_N3OG}|fAzDid#Xv2s SSPhWC<^aLFjGJQ=8+8D$j5pW- delta 36 ucmV+<0NeljoB`dN0k9Mrvx7f@1e4z$4U-Z?Pm@X?rn8Ve#R0SWA%QF$)ej;7 diff --git a/mobile/openapi/doc/UserResponseDto.md b/mobile/openapi/doc/UserResponseDto.md index 700a5b849efd191b7c82682874b49458349dec60..69d85fbbd4b1052af06f92aee0680e8fe9836c8e 100644 GIT binary patch delta 53 wcmcc0-odfq9+O65Nolc`mO_m}w3b$AacWU;2}C4T1HseFP0`$ZnTd%J0Q_DOa{vGU delta 11 ScmeC+xXQla9@At&=I;O-as-!4BY({mfk=a5_XuVV5kHL0i6)ii(X8LAaRtK0f~=q@`_a2rS)Q+eZKQG z$dQ7HPJ6a=(eIhkR*YOTK-1uYjYT;T_FIgmJDU%Jwr#CR0?tQHf`i9={LmUzy-FBN zDz#Tyr?O1qD8tHl!aMG6v9=lIF1aX*@OKpSLBiqF^JtAU`C&41bvq0pJty C19S)g literal 0 HcmV?d00001 diff --git a/mobile/openapi/lib/api.dart b/mobile/openapi/lib/api.dart index 0a093e45365b23e5e5153b59bc82b988b5cfb045..5b49d8d67f9501c8f60ebeabb5a4031d012e1d94 100644 GIT binary patch delta 47 zcmX@<-0iYKo0ly$C$%IsesY4G%w{d#azP-2m94lWv81$kvLU<3NZU5tx%qC_j^jG!m1f78R4P XG$oV!78HO}ek%#Hwi)CjO%xf0 diff --git a/mobile/openapi/lib/api_client.dart b/mobile/openapi/lib/api_client.dart index 5e5f702996d9a5a9e8b8dfd5132126bd2f7f437e..312153788c5a0f4a65f67ae30eba8bf6b3b8c639 100644 GIT binary patch delta 84 zcmX@Lm+}2R#tl8%%&9r4lMA(E*<65(lGMooLF${kwe8foLW@(2f=d!hN{c5as)_)` hS=gZ5Mr-}aydfNunS;e9=h%o$jt}P8%p8228vr*J9kKub delta 19 bcmaFAkMY!A#tl8%n{R2`s&CEX6f`x1VBBlhJ diff --git a/mobile/openapi/lib/model/delete_user_dto.dart b/mobile/openapi/lib/model/delete_user_dto.dart new file mode 100644 index 0000000000000000000000000000000000000000..d62f40b1ee6075b05f7b3d1ae24ddf3f051c5fb6 GIT binary patch literal 3147 zcmbVOU2hXP6n)RHxJ4_PO4EepX%$ES(RKw(n-wi9t)gn=%s7c*Vh7vPC@T5yd#*i` z&d`)ri$v0l@8>z^-f_R*>G$c~m#gvdpQjh6pWj`bp3vEcPp1i;jOk*0Md#y_4`=@# zz>F>5=EC~n%iixVJN#44wXrgt8Jo^jAqQ0I(#brPgu8j42Jw&k<8<(z1 zpIZ67R2J@9Eb+M#7T;c4hv3%jch8J=UTB}H90}D-NgLeV-eiT+(xrvbbC~&s(CHs9 z(rh8D>vWLLlbMlONi9~YhySlSovaYf(Fq#z@{5!9#2dQp5cSdhXX#6;9SPEMO_P?< zHK|ZoCVQ}M9d+5C$S;)Z0l7>j_`N+svJf_Y(%*#?PLehruwKEIRu-jMzah6UWs%cD z+(?>AsbQp#by*Z*TF3*sR63IgFUhTB21qjDmC?iuRl##39fwAdSyAQ^E#z`0%EHr) zC`w7)(kVR;L7r77gJ-)6juKj!wY-rw2_|!?r4?T0Rd{Dgv_+Xg z@S$#w4b-ZV$!}HxCI zWE5Dl=Syz`$tM2M4Q(E_nwW&Z!{5T!^sY)!2Z!0jSx}XGo?jA zU-5{BbkF*B`zn;JW)#4z&^d_Z*D)ygPwZhxet3;YcQX?MY)KXvl9&@%-S=QTD%+6K zhM5g^{CHap-4kb-gw820Ge^q1I4zE5)+`Z?^*adzgz=xj;Ee~bXz&zFdYN$k9%J389@cY(U`r<5fGwW}t_HLetABWrjAvJv?bkecqb|!Kp_gz;`H##mZd_jrZvqc=9+D%PekAErY z>Q)%=*v=yqhA6#Y)F9*$Dgn_XI)0NIUU$Ji>X0-4f={?M))`a6n1aBCBd~Vb|5;{-|ouRGZrt0X?Q=2Ui+y+R^wBNs@HWS)_zz3$ B^{xN_ literal 0 HcmV?d00001 diff --git a/mobile/openapi/lib/model/partner_response_dto.dart b/mobile/openapi/lib/model/partner_response_dto.dart index 008e0c4f2673a6e20190ef23e857e4cf0bf8ca2f..37602d04b72a244dd0978f43f1b91b1c0594c05e 100644 GIT binary patch delta 189 zcmZ4C)ZwzhgqbC=q_lXlB9q8wBW67o!O-H=qTmvcm_jj_+8oV(hKUa>U~3B#6X!a@ z1kt#VM|krso)?TfAQiSMP(Azjlvw2z6tar*^P-E?A%g0$3bqO*8JWd;P(`eE2eym^^q3_HZm9|{Hl-I+x6 delta 46 zcmV+}0MY-5M5scrGy}6v11toykO#*Cvvmrz0<%F4=K-@g4;}@xdlb|KvxXW@2b1C< ECWKuOQUCw| diff --git a/mobile/openapi/lib/model/user_response_dto.dart b/mobile/openapi/lib/model/user_response_dto.dart index d4e0bf07dd3b2b5a76188875a5f5daceed9e3154..df68128e714babdffd5214d6807467294e1a14e8 100644 GIT binary patch delta 180 zcmdmJGtq8?7&A*^NonyW<_k=Mp~b01!6hKBLNS=ye42GJ6CYT>))pof$I-_G*0cEr z*8@f#kgTl=RQ(p7*(~x33R%VZdC^7c5JB}=1zUxZjLc#^s3K3nNvxV6ZB_~h8}-tP Y@^ig_`ZUlqZ$2w##}2k{GNYUU0F@j<5&!@I delta 45 zcmV+|0Mh@FKCwKo76Y>|1G)mT2A%@5ya;{*vr-Gp0kbX*mjtuw5sU@1trsu{lj|NP Deh?6h diff --git a/mobile/openapi/lib/model/user_status.dart b/mobile/openapi/lib/model/user_status.dart new file mode 100644 index 0000000000000000000000000000000000000000..cbbe1b56d9840c4210754a047c253df78764ae36 GIT binary patch literal 2679 zcmai0U2oz>6n*Dc+(^}iNYu3ZRArZCBZai7wn?=i4;4bzV2{Ie&5S!U4nnK^_ntdr z8wY3_DS(;#dG5Jq7>@_zFnHD%pWhGoQ9Ws6Wm*`U7OIq&RO{NwJe8%~NbN`Mv)WX~`jZ}_+=`t`tJUH2MiWn z2lhOEe~t!-UO8B~0SIW}$fZ3w;go|t9=IL(GzaX`0QE(=Ff&n>R2k>gs+6%aM@mP$ z7Ce^aVn4w!iC1L?fE^Nk+*^HGAO}%wZ4^GYHOHr>8h%)Yf|`)~5K}6w*#uPc9XJgf zBYsYlyEA^asr;^uhIq!(FtQOj z8Gbh1pSKw!n_msLg9(m-M4UXPkJe`s(sfz(gAv8j6Orc~Qqhm-C6wR3$0sX&ZFTGA z4L3Aw;koR93i)3KFRX84>gIwi01i`!hER)U^2(sgC=DEXP2g-5O$Q+p`^qbjA=<0|! z;~;g|uD5eHvHz!nn48)4sBv$`n^fjAYmJRfuCXTiH57bf92A5)cZ4f0-_e%4LBy*+ zZwa>@Za*LnlzP`z+%q(bb%OR{mxJ2LyhViVYutjcuo5b-Ai1?;ZAV~JB3M=JFF*c* zSA(nEQ(@X*K5S>rLvEF-nVde@VpgS7_}8^Pld QA6!QpvSgxM)5o86dixOUnY_)v5Az4s9*24PvWPCqip_HxC z7I~vBZ)As4=vD8q=GfsLl^>mJxi{MStAyyj#ldmYdv5WwmzHoP_Ou&e>GWtFnOk=q zue5SrD9>e0fwGZkBkrz-byA_@omMT${7xwTaL22iu#T})kB}HqeTWvP+Mp6frnJUk z>SXp$;?emuY-d3T4YdRM63FNqJZ>D?%xm_gWes~F)nEue41lDKRH6eJC#VqSm&9N= zW29@X?#&z_1*}K+Vh^>$yh;0z1;|2T)EbNmPvtI%LGARZ> diff --git a/mobile/openapi/test/user_status_test.dart b/mobile/openapi/test/user_status_test.dart new file mode 100644 index 0000000000000000000000000000000000000000..88abba0459225c2a3e70590cd61aef4ff37b6bb7 GIT binary patch literal 417 zcmZvYQA@)x6oudOE3Qx7V6M6+*$^DGlYy?F-Gfiz(%yE#HiBC`K(q7(%CaL?ty zcXE=XsYp}U9?RnLK0oA7+cM8!w|~eNkQHz!N_Z@?{qE%=V3EA5k^SvzdA$Szsv9vsz@I_gRL#pqRU4g1Lib|3iZq#e9udK(mnxW+!HMGo5&yKp$W>3C(d z^QgU4btqJgBL0cH$+Avqa(`/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", diff --git a/server/src/domain/job/job.service.ts b/server/src/domain/job/job.service.ts index 1010185b2c..5d5333f3ab 100644 --- a/server/src/domain/job/job.service.ts +++ b/server/src/domain/job/job.service.ts @@ -280,6 +280,11 @@ export class JobService { } break; } + + case JobName.USER_DELETION: { + this.communicationRepository.broadcast(ClientEvent.USER_DELETE, item.data.id); + break; + } } } } diff --git a/server/src/domain/repositories/communication.repository.ts b/server/src/domain/repositories/communication.repository.ts index 4a3bc552c9..65e322702f 100644 --- a/server/src/domain/repositories/communication.repository.ts +++ b/server/src/domain/repositories/communication.repository.ts @@ -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; diff --git a/server/src/domain/repositories/user.repository.ts b/server/src/domain/repositories/user.repository.ts index cecdb0b06e..efd950318f 100644 --- a/server/src/domain/repositories/user.repository.ts +++ b/server/src/domain/repositories/user.repository.ts @@ -32,7 +32,6 @@ export interface IUserRepository { create(user: Partial): Promise; update(id: string, user: Partial): Promise; delete(user: UserEntity, hard?: boolean): Promise; - restore(user: UserEntity): Promise; updateUsage(id: string, delta: number): Promise; syncUsage(id?: string): Promise; } diff --git a/server/src/domain/user/dto/delete-user.dto.ts b/server/src/domain/user/dto/delete-user.dto.ts new file mode 100644 index 0000000000..88f55f4af5 --- /dev/null +++ b/server/src/domain/user/dto/delete-user.dto.ts @@ -0,0 +1,6 @@ +import { ValidateBoolean } from '../../domain.util'; + +export class DeleteUserDto { + @ValidateBoolean({ optional: true }) + force?: boolean; +} diff --git a/server/src/domain/user/dto/index.ts b/server/src/domain/user/dto/index.ts index 09d7998e8e..2d166de368 100644 --- a/server/src/domain/user/dto/index.ts +++ b/server/src/domain/user/dto/index.ts @@ -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'; diff --git a/server/src/domain/user/response-dto/user-response.dto.ts b/server/src/domain/user/response-dto/user-response.dto.ts index a82337945e..bd437ea344 100644 --- a/server/src/domain/user/response-dto/user-response.dto.ts +++ b/server/src/domain/user/response-dto/user-response.dto.ts @@ -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, }; } diff --git a/server/src/domain/user/user.service.spec.ts b/server/src/domain/user/user.service.spec.ts index dba0106fb6..d0e56e4cd3 100644 --- a/server/src/domain/user/user.service.spec.ts +++ b/server/src/domain/user/user.service.spec.ts @@ -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 }, + }); }); }); diff --git a/server/src/domain/user/user.service.ts b/server/src/domain/user/user.service.ts index 9a862199b8..564163d775 100644 --- a/server/src/domain/user/user.service.ts +++ b/server/src/domain/user/user.service.ts @@ -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 { - const user = await this.findOrFail(id, {}); - if (user.isAdmin) { + async delete(auth: AuthDto, id: string, dto: DeleteUserDto): Promise { + 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 { - 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 { @@ -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; } diff --git a/server/src/immich/controllers/user.controller.ts b/server/src/immich/controllers/user.controller.ts index 2cf2c6f86d..7fa7ccd0fd 100644 --- a/server/src/immich/controllers/user.controller.ts +++ b/server/src/immich/controllers/user.controller.ts @@ -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 { - return this.service.delete(auth, id); + deleteUser( + @Auth() auth: AuthDto, + @Param() { id }: UUIDParamDto, + @Body() dto: DeleteUserDto, + ): Promise { + return this.service.delete(auth, id, dto); } @AdminRoute() diff --git a/server/src/infra/entities/user.entity.ts b/server/src/infra/entities/user.entity.ts index c574595ea8..20c057d790 100644 --- a/server/src/infra/entities/user.entity.ts +++ b/server/src/infra/entities/user.entity.ts @@ -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; diff --git a/server/src/infra/migrations/1709870213078-AddUserStatus.ts b/server/src/infra/migrations/1709870213078-AddUserStatus.ts new file mode 100644 index 0000000000..858f51258f --- /dev/null +++ b/server/src/infra/migrations/1709870213078-AddUserStatus.ts @@ -0,0 +1,14 @@ +import { MigrationInterface, QueryRunner } from "typeorm"; + +export class AddUserStatus1709870213078 implements MigrationInterface { + name = 'AddUserStatus1709870213078' + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(`ALTER TABLE "users" ADD "status" character varying NOT NULL DEFAULT 'active'`); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(`ALTER TABLE "users" DROP COLUMN "status"`); + } + +} diff --git a/server/src/infra/repositories/user.repository.ts b/server/src/infra/repositories/user.repository.ts index 640eda0ee4..d9f12bb314 100644 --- a/server/src/infra/repositories/user.repository.ts +++ b/server/src/infra/repositories/user.repository.ts @@ -77,10 +77,6 @@ export class UserRepository implements IUserRepository { return hard ? this.userRepository.remove(user) : this.userRepository.softRemove(user); } - async restore(user: UserEntity): Promise { - return this.userRepository.recover(user); - } - @GenerateSql() async getUserStats(): Promise { const stats = await this.userRepository @@ -135,6 +131,6 @@ export class UserRepository implements IUserRepository { private async save(user: Partial) { const { id } = await this.userRepository.save(user); - return this.userRepository.findOneByOrFail({ id }); + return this.userRepository.findOneOrFail({ where: { id }, withDeleted: true }); } } diff --git a/server/src/infra/sql/album.repository.sql b/server/src/infra/sql/album.repository.sql index 3997dd1a22..d9b2e896e9 100644 --- a/server/src/infra/sql/album.repository.sql +++ b/server/src/infra/sql/album.repository.sql @@ -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", diff --git a/server/src/infra/sql/api.key.repository.sql b/server/src/infra/sql/api.key.repository.sql index 3f6b207ce1..22b8fd6722 100644 --- a/server/src/infra/sql/api.key.repository.sql +++ b/server/src/infra/sql/api.key.repository.sql @@ -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", diff --git a/server/src/infra/sql/library.repository.sql b/server/src/infra/sql/library.repository.sql index 433ab6fbac..93a6fc97fb 100644 --- a/server/src/infra/sql/library.repository.sql +++ b/server/src/infra/sql/library.repository.sql @@ -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", diff --git a/server/src/infra/sql/shared.link.repository.sql b/server/src/infra/sql/shared.link.repository.sql index 6cac1c44fc..b5e6894130 100644 --- a/server/src/infra/sql/shared.link.repository.sql +++ b/server/src/infra/sql/shared.link.repository.sql @@ -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", diff --git a/server/src/infra/sql/user.repository.sql b/server/src/infra/sql/user.repository.sql index e4c7d3a314..b3741bcf75 100644 --- a/server/src/infra/sql/user.repository.sql +++ b/server/src/infra/sql/user.repository.sql @@ -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", diff --git a/server/src/infra/sql/user.token.repository.sql b/server/src/infra/sql/user.token.repository.sql index b51e53106e..f09238e137 100644 --- a/server/src/infra/sql/user.token.repository.sql +++ b/server/src/infra/sql/user.token.repository.sql @@ -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", diff --git a/server/test/repositories/user.repository.mock.ts b/server/test/repositories/user.repository.mock.ts index e365a20bd5..402b90eadd 100644 --- a/server/test/repositories/user.repository.mock.ts +++ b/server/test/repositories/user.repository.mock.ts @@ -17,7 +17,6 @@ export const newUserRepositoryMock = (reset = true): jest.Mocked { 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; + }; dispatch('cancel')} + disabled={deleteButtonDisabled} >
-

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

-

Are you sure you want to continue?

+ {#if forceDelete} +

+ {user.name}'s account and assets will be queued for permanent deletion immediately. +

+ {:else} +

+ {user.name}'s account and assets will be scheduled for permanent deletion in {$serverConfig.userDeleteDelay} + days. +

+ {/if} + +
+ + + { + deleteButtonDisabled = forceDelete; + }} + /> +
+ + {#if forceDelete} +

+ WARNING: This will immediately remove the user and all assets. This cannot be undone and the files cannot be + recovered. +

+ +

+ To confirm, type "{user.email}" below +

+ + + {/if}
diff --git a/web/src/lib/components/admin-page/restore-dialoge.svelte b/web/src/lib/components/admin-page/restore-dialoge.svelte index d9a8ed3bc0..b98932f829 100644 --- a/web/src/lib/components/admin-page/restore-dialoge.svelte +++ b/web/src/lib/components/admin-page/restore-dialoge.svelte @@ -1,5 +1,6 @@ @@ -123,8 +132,8 @@ {#if shouldShowDeleteConfirmDialog} (shouldShowDeleteConfirmDialog = false)} /> {/if} @@ -132,8 +141,8 @@ {#if shouldShowRestoreDialog} (shouldShowRestoreDialog = false)} /> {/if} @@ -179,9 +188,7 @@ {#if allUsers} {#each allUsers as immichUser, index} - {#if !isDeleted(immichUser)} + {#if !immichUser.deletedAt} {/if} {/if} - {#if isDeleted(immichUser)} + {#if immichUser.deletedAt && immichUser.status === UserStatus.Deleted} diff --git a/web/src/test-data/factories/user-factory.ts b/web/src/test-data/factories/user-factory.ts index 02273f750f..563844e07d 100644 --- a/web/src/test-data/factories/user-factory.ts +++ b/web/src/test-data/factories/user-factory.ts @@ -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({ @@ -18,4 +18,5 @@ export const userFactory = Sync.makeFactory({ avatarColor: UserAvatarColor.Primary, quotaUsageInBytes: 0, quotaSizeInBytes: null, + status: UserStatus.Active, });