From 0fc6d6982466b73ecc284ee3ec2c99120bee8acc Mon Sep 17 00:00:00 2001 From: Jason Rasmussen Date: Mon, 27 May 2024 22:16:53 -0400 Subject: [PATCH] feat(server): user preferences (#9736) * refactor(server): user endpoints * feat(server): user preferences * mobile: user preference * wording --------- Co-authored-by: Alex --- docs/docs/administration/server-commands.md | 1 - e2e/src/api/specs/user-admin.e2e-spec.ts | 91 +++---- e2e/src/api/specs/user.e2e-spec.ts | 42 ++- e2e/src/fixtures.ts | 6 - e2e/src/responses.ts | 1 - mobile/lib/entities/user.entity.dart | 8 +- .../providers/authentication.provider.dart | 6 +- mobile/lib/providers/user.provider.dart | 3 +- .../lib/routing/tab_navigation_observer.dart | 4 +- mobile/openapi/README.md | Bin 27685 -> 28457 bytes mobile/openapi/lib/api.dart | Bin 9821 -> 10053 bytes mobile/openapi/lib/api/user_api.dart | Bin 20999 -> 27808 bytes mobile/openapi/lib/api_client.dart | Bin 26388 -> 26904 bytes mobile/openapi/lib/model/avatar_response.dart | Bin 0 -> 2752 bytes mobile/openapi/lib/model/avatar_update.dart | Bin 0 -> 3144 bytes mobile/openapi/lib/model/memory_response.dart | Bin 0 -> 2761 bytes mobile/openapi/lib/model/memory_update.dart | Bin 0 -> 3157 bytes .../lib/model/user_admin_create_dto.dart | Bin 6377 -> 5602 bytes .../lib/model/user_admin_response_dto.dart | Bin 8043 -> 7268 bytes .../lib/model/user_admin_update_dto.dart | Bin 7750 -> 6241 bytes .../model/user_preferences_response_dto.dart | Bin 0 -> 3234 bytes .../model/user_preferences_update_dto.dart | Bin 0 -> 4054 bytes .../openapi/lib/model/user_update_me_dto.dart | Bin 6002 -> 4493 bytes open-api/immich-openapi-specs.json | 246 ++++++++++++++++-- open-api/typescript-sdk/README.md | 11 +- open-api/typescript-sdk/src/fetch-client.ts | 69 ++++- .../src/controllers/user-admin.controller.ts | 17 ++ server/src/controllers/user.controller.ts | 16 ++ server/src/dtos/user-preferences.dto.ts | 47 ++++ server/src/dtos/user.dto.ts | 24 +- server/src/services/user-admin.service.ts | 54 ++-- server/src/services/user.service.ts | 38 ++- server/src/utils/preferences.ts | 10 + .../navigation-bar/account-info-panel.svelte | 19 +- .../memories-settings.svelte | 12 +- .../user-settings-list.svelte | 2 +- web/src/lib/stores/user.store.ts | 4 +- web/src/lib/utils/auth.ts | 18 +- .../(user)/photos/[[assetId=id]]/+page.svelte | 4 +- 39 files changed, 532 insertions(+), 221 deletions(-) create mode 100644 mobile/openapi/lib/model/avatar_response.dart create mode 100644 mobile/openapi/lib/model/avatar_update.dart create mode 100644 mobile/openapi/lib/model/memory_response.dart create mode 100644 mobile/openapi/lib/model/memory_update.dart create mode 100644 mobile/openapi/lib/model/user_preferences_response_dto.dart create mode 100644 mobile/openapi/lib/model/user_preferences_update_dto.dart create mode 100644 server/src/dtos/user-preferences.dto.ts diff --git a/docs/docs/administration/server-commands.md b/docs/docs/administration/server-commands.md index 2594da44b..355ee10e3 100644 --- a/docs/docs/administration/server-commands.md +++ b/docs/docs/administration/server-commands.md @@ -77,7 +77,6 @@ immich-admin list-users deletedAt: null, updatedAt: 2023-09-21T15:42:28.129Z, oauthId: '', - memoriesEnabled: true } ] ``` diff --git a/e2e/src/api/specs/user-admin.e2e-spec.ts b/e2e/src/api/specs/user-admin.e2e-spec.ts index ac2b3e693..a041d9841 100644 --- a/e2e/src/api/specs/user-admin.e2e-spec.ts +++ b/e2e/src/api/specs/user-admin.e2e-spec.ts @@ -1,4 +1,11 @@ -import { LoginResponseDto, deleteUserAdmin, getMyUser, getUserAdmin, login } from '@immich/sdk'; +import { + LoginResponseDto, + deleteUserAdmin, + getMyUser, + getUserAdmin, + getUserPreferencesAdmin, + login, +} from '@immich/sdk'; import { Socket } from 'socket.io-client'; import { createUserDto, uuidDto } from 'src/fixtures'; import { errorDto } from 'src/responses'; @@ -103,15 +110,7 @@ describe('/admin/users', () => { expect(body).toEqual(errorDto.forbidden); }); - for (const key of [ - 'password', - 'email', - 'name', - 'quotaSizeInBytes', - 'shouldChangePassword', - 'memoriesEnabled', - 'notify', - ]) { + for (const key of ['password', 'email', 'name', 'quotaSizeInBytes', 'shouldChangePassword', 'notify']) { it(`should not allow null ${key}`, async () => { const { status, body } = await request(app) .post(`/admin/users`) @@ -139,23 +138,6 @@ describe('/admin/users', () => { }); expect(status).toBe(201); }); - - it('should create a user without memories enabled', async () => { - const { status, body } = await request(app) - .post(`/admin/users`) - .send({ - email: 'no-memories@immich.cloud', - password: 'Password123', - name: 'No Memories', - memoriesEnabled: false, - }) - .set('Authorization', `Bearer ${admin.accessToken}`); - expect(body).toMatchObject({ - email: 'no-memories@immich.cloud', - memoriesEnabled: false, - }); - expect(status).toBe(201); - }); }); describe('PUT /admin/users/:id', () => { @@ -173,7 +155,7 @@ describe('/admin/users', () => { expect(body).toEqual(errorDto.forbidden); }); - for (const key of ['password', 'email', 'name', 'shouldChangePassword', 'memoriesEnabled']) { + for (const key of ['password', 'email', 'name', 'shouldChangePassword']) { it(`should not allow null ${key}`, async () => { const { status, body } = await request(app) .put(`/admin/users/${uuidDto.notFound}`) @@ -221,22 +203,6 @@ describe('/admin/users', () => { expect(before.updatedAt).not.toEqual(body.updatedAt); }); - it('should update memories enabled', async () => { - const before = await getUserAdmin({ id: admin.userId }, { headers: asBearerAuth(admin.accessToken) }); - const { status, body } = await request(app) - .put(`/admin/users/${admin.userId}`) - .send({ memoriesEnabled: false }) - .set('Authorization', `Bearer ${admin.accessToken}`); - - expect(status).toBe(200); - expect(body).toMatchObject({ - ...before, - updatedAt: expect.anything(), - memoriesEnabled: false, - }); - expect(before.updatedAt).not.toEqual(body.updatedAt); - }); - it('should update password', async () => { const { status, body } = await request(app) .put(`/admin/users/${nonAdmin.userId}`) @@ -254,6 +220,43 @@ describe('/admin/users', () => { }); }); + describe('PUT /admin/users/:id/preferences', () => { + it('should require authentication', async () => { + const { status, body } = await request(app).put(`/admin/users/${userToDelete.userId}/preferences`); + expect(status).toBe(401); + expect(body).toEqual(errorDto.unauthorized); + }); + + it('should update memories enabled', async () => { + const before = await getUserPreferencesAdmin({ id: admin.userId }, { headers: asBearerAuth(admin.accessToken) }); + expect(before).toMatchObject({ memories: { enabled: true } }); + + const { status, body } = await request(app) + .put(`/admin/users/${admin.userId}/preferences`) + .send({ memories: { enabled: false } }) + .set('Authorization', `Bearer ${admin.accessToken}`); + + expect(status).toBe(200); + expect(body).toMatchObject({ memories: { enabled: false } }); + + const after = await getUserPreferencesAdmin({ id: admin.userId }, { headers: asBearerAuth(admin.accessToken) }); + expect(after).toMatchObject({ memories: { enabled: false } }); + }); + + it('should update the avatar color', async () => { + const { status, body } = await request(app) + .put(`/admin/users/${admin.userId}/preferences`) + .send({ avatar: { color: 'orange' } }) + .set('Authorization', `Bearer ${admin.accessToken}`); + + expect(status).toBe(200); + expect(body).toEqual({ avatar: { color: 'orange' }, memories: { enabled: false } }); + + const after = await getUserPreferencesAdmin({ id: admin.userId }, { headers: asBearerAuth(admin.accessToken) }); + expect(after).toEqual({ avatar: { color: 'orange' }, memories: { enabled: false } }); + }); + }); + describe('DELETE /admin/users/:id', () => { it('should require authentication', async () => { const { status, body } = await request(app).delete(`/admin/users/${userToDelete.userId}`); diff --git a/e2e/src/api/specs/user.e2e-spec.ts b/e2e/src/api/specs/user.e2e-spec.ts index 0cc08479d..ccf7d6dd3 100644 --- a/e2e/src/api/specs/user.e2e-spec.ts +++ b/e2e/src/api/specs/user.e2e-spec.ts @@ -1,4 +1,4 @@ -import { LoginResponseDto, SharedLinkType, deleteUserAdmin, getMyUser, login } from '@immich/sdk'; +import { LoginResponseDto, SharedLinkType, deleteUserAdmin, getMyPreferences, getMyUser, login } from '@immich/sdk'; import { createUserDto } from 'src/fixtures'; import { errorDto } from 'src/responses'; import { app, asBearerAuth, utils } from 'src/utils'; @@ -69,7 +69,6 @@ describe('/users', () => { expect(body).toMatchObject({ id: admin.userId, email: 'admin@immich.cloud', - memoriesEnabled: true, quotaUsageInBytes: 0, }); }); @@ -82,7 +81,7 @@ describe('/users', () => { expect(body).toEqual(errorDto.unauthorized); }); - for (const key of ['email', 'name', 'memoriesEnabled', 'avatarColor']) { + for (const key of ['email', 'name']) { it(`should not allow null ${key}`, async () => { const dto = { [key]: null }; const { status, body } = await request(app) @@ -110,24 +109,6 @@ describe('/users', () => { }); }); - it('should update memories enabled', async () => { - const before = await getMyUser({ headers: asBearerAuth(admin.accessToken) }); - const { status, body } = await request(app) - .put(`/users/me`) - .send({ memoriesEnabled: false }) - .set('Authorization', `Bearer ${admin.accessToken}`); - - expect(status).toBe(200); - expect(body).toMatchObject({ - ...before, - updatedAt: expect.anything(), - memoriesEnabled: false, - }); - - const after = await getMyUser({ headers: asBearerAuth(admin.accessToken) }); - expect(after.memoriesEnabled).toBe(false); - }); - /** @deprecated */ it('should allow a user to change their password (deprecated)', async () => { const user = await getMyUser({ headers: asBearerAuth(nonAdmin.accessToken) }); @@ -176,6 +157,24 @@ describe('/users', () => { }); }); + describe('PUT /users/me/preferences', () => { + it('should update memories enabled', async () => { + const before = await getMyPreferences({ headers: asBearerAuth(admin.accessToken) }); + expect(before).toMatchObject({ memories: { enabled: true } }); + + const { status, body } = await request(app) + .put(`/users/me/preferences`) + .send({ memories: { enabled: false } }) + .set('Authorization', `Bearer ${admin.accessToken}`); + + expect(status).toBe(200); + expect(body).toMatchObject({ memories: { enabled: false } }); + + const after = await getMyPreferences({ headers: asBearerAuth(admin.accessToken) }); + expect(after).toMatchObject({ memories: { enabled: false } }); + }); + }); + describe('GET /users/:id', () => { it('should require authentication', async () => { const { status } = await request(app).get(`/users/${admin.userId}`); @@ -194,7 +193,6 @@ describe('/users', () => { expect(body).not.toMatchObject({ shouldChangePassword: expect.anything(), - memoriesEnabled: expect.anything(), storageLabel: expect.anything(), }); }); diff --git a/e2e/src/fixtures.ts b/e2e/src/fixtures.ts index 031985c5f..9e311c896 100644 --- a/e2e/src/fixtures.ts +++ b/e2e/src/fixtures.ts @@ -1,5 +1,3 @@ -import { UserAvatarColor } from '@immich/sdk'; - export const uuidDto = { invalid: 'invalid-uuid', // valid uuid v4 @@ -70,8 +68,6 @@ export const userDto = { updatedAt: new Date('2021-01-01'), tags: [], assets: [], - memoriesEnabled: true, - avatarColor: UserAvatarColor.Primary, quotaSizeInBytes: null, quotaUsageInBytes: 0, }, @@ -88,8 +84,6 @@ export const userDto = { updatedAt: new Date('2021-01-01'), tags: [], assets: [], - memoriesEnabled: true, - avatarColor: UserAvatarColor.Primary, quotaSizeInBytes: null, quotaUsageInBytes: 0, }, diff --git a/e2e/src/responses.ts b/e2e/src/responses.ts index afe3334a7..b7dcfca1e 100644 --- a/e2e/src/responses.ts +++ b/e2e/src/responses.ts @@ -68,7 +68,6 @@ export const signupResponseDto = { updatedAt: expect.any(String), deletedAt: null, oauthId: '', - memoriesEnabled: true, quotaUsageInBytes: 0, quotaSizeInBytes: null, status: 'active', diff --git a/mobile/lib/entities/user.entity.dart b/mobile/lib/entities/user.entity.dart index b6adcf5d8..55a19fe49 100644 --- a/mobile/lib/entities/user.entity.dart +++ b/mobile/lib/entities/user.entity.dart @@ -27,8 +27,10 @@ class User { Id get isarId => fastHash(id); - User.fromUserDto(UserAdminResponseDto dto) - : id = dto.id, + User.fromUserDto( + UserAdminResponseDto dto, + UserPreferencesResponseDto? preferences, + ) : id = dto.id, updatedAt = dto.updatedAt, email = dto.email, name = dto.name, @@ -36,7 +38,7 @@ class User { isPartnerSharedWith = false, profileImagePath = dto.profileImagePath, isAdmin = dto.isAdmin, - memoryEnabled = dto.memoriesEnabled ?? false, + memoryEnabled = preferences?.memories.enabled ?? false, avatarColor = dto.avatarColor.toAvatarColor(), inTimeline = false, quotaUsageInBytes = dto.quotaUsageInBytes ?? 0, diff --git a/mobile/lib/providers/authentication.provider.dart b/mobile/lib/providers/authentication.provider.dart index 073ee09db..b5fb25bf2 100644 --- a/mobile/lib/providers/authentication.provider.dart +++ b/mobile/lib/providers/authentication.provider.dart @@ -177,8 +177,10 @@ class AuthenticationNotifier extends StateNotifier { retResult = false; } else { UserAdminResponseDto? userResponseDto; + UserPreferencesResponseDto? userPreferences; try { userResponseDto = await _apiService.userApi.getMyUser(); + userPreferences = await _apiService.userApi.getMyPreferences(); } on ApiException catch (error, stackTrace) { _log.severe( "Error getting user information from the server [API EXCEPTION]", @@ -201,13 +203,13 @@ class AuthenticationNotifier extends StateNotifier { Store.put(StoreKey.deviceIdHash, fastHash(deviceId)); Store.put( StoreKey.currentUser, - User.fromUserDto(userResponseDto), + User.fromUserDto(userResponseDto, userPreferences), ); Store.put(StoreKey.serverUrl, serverUrl); Store.put(StoreKey.accessToken, accessToken); shouldChangePassword = userResponseDto.shouldChangePassword; - user = User.fromUserDto(userResponseDto); + user = User.fromUserDto(userResponseDto, userPreferences); retResult = true; } else { diff --git a/mobile/lib/providers/user.provider.dart b/mobile/lib/providers/user.provider.dart index bf052ebbb..276761552 100644 --- a/mobile/lib/providers/user.provider.dart +++ b/mobile/lib/providers/user.provider.dart @@ -21,10 +21,11 @@ class CurrentUserProvider extends StateNotifier { refresh() async { try { final user = await _apiService.userApi.getMyUser(); + final userPreferences = await _apiService.userApi.getMyPreferences(); if (user != null) { Store.put( StoreKey.currentUser, - User.fromUserDto(user), + User.fromUserDto(user, userPreferences), ); } } catch (_) {} diff --git a/mobile/lib/routing/tab_navigation_observer.dart b/mobile/lib/routing/tab_navigation_observer.dart index 8825e2ef0..6c0f36050 100644 --- a/mobile/lib/routing/tab_navigation_observer.dart +++ b/mobile/lib/routing/tab_navigation_observer.dart @@ -58,6 +58,8 @@ class TabNavigationObserver extends AutoRouterObserver { try { final userResponseDto = await ref.read(apiServiceProvider).userApi.getMyUser(); + final userPreferences = + await ref.read(apiServiceProvider).userApi.getMyPreferences(); if (userResponseDto == null) { return; @@ -65,7 +67,7 @@ class TabNavigationObserver extends AutoRouterObserver { Store.put( StoreKey.currentUser, - User.fromUserDto(userResponseDto), + User.fromUserDto(userResponseDto, userPreferences), ); ref.read(serverInfoProvider.notifier).getServerVersion(); } catch (e) { diff --git a/mobile/openapi/README.md b/mobile/openapi/README.md index 273585c368c90230e296d94cb9cc25bad0079344..cdc75d4f28f2eaf421ce27cfe018d4f6990c30bb 100644 GIT binary patch delta 467 zcmZ2_gK_0O#tmYQ+yOLUv!+6!+MbWC6r zN>42bElw>$HbBNnfg4$^jgt}&hUP*iWkxh>MJBIt=FmaXsi{z-prsWM8lt78pkE5K zrC1-+8IvD4vrV4lyl^s`6F-p6g<`0TlcZ>AK}uptDo8t$(UW;yP#v{d-{m)lw_{mi zNn%k@YH>k+UU6!yMoNCNzCNm;UT%semx8WBG(Dt&8G2Nh0~U0XKSqd7R>)-Gf(az2PM+v4%8wqr8#6>i5K4+6wz-t# KZ)VI?VFm#8HLI}z delta 51 zcmV-30L=fX-T|f90k9T9vwlHa0kc;_BLS1>M4z(@M)wG_R!`sulX+GPlWkW?v))&* J6SER*7Xvm36z~85 diff --git a/mobile/openapi/lib/api.dart b/mobile/openapi/lib/api.dart index d7223a1ecf38cabb8f2f634c24c5aa2c40a5427c..94303a768fefcdea817e7be24fe119f8985f31ca 100644 GIT binary patch delta 117 zcmccXbJTAG8!umCSz<|IQG8KqaY24w@nk_!Q68vJX+cV22}oeF9xwZ5US4mu$wHzc zlM95|CkF`oz}TCo30v`V6cnYVr52^;O)d~qnWV(Z4dW-L7EfNsCJz+ad_k#~82|yO BCXfIC delta 30 mcmX@=ch_eF8}DW>-XONg`-NR5iz!QNwh@ux-@HS)ml*)91`0a> diff --git a/mobile/openapi/lib/api/user_api.dart b/mobile/openapi/lib/api/user_api.dart index 3c1a3ff4e7bebb952c45462a3102e6f6fa936503..246ea422c55fd66938ed394488f05c4641e0b0ca 100644 GIT binary patch delta 1204 zcmZo)!noij;>i~owI-`s2=b?=miSf%z$GR-T14^b=cXd5 zoxH&A$mBT7PkN!nsYM8t#X+gX1^Ic!sV*h?_I3(r<`ip8{-~kBi)?i9W;t0aCT=7X zCQmdML*W-_drzL?Vgz#7=B-i!;*;mv3JB^~XQtF5xd-HS&&m6g6eh3XH$w|j5=%hA6su5*8iYWZ$rCu$yi36%pOp6n-c{ffj70kC=+vkD+f7qhQD*AkNoAS#ofHy@L52?UdmS~8R0Hwu%mUIw!m JSlSE%eG30q63hSq diff --git a/mobile/openapi/lib/api_client.dart b/mobile/openapi/lib/api_client.dart index bd3433872affc4c0b037a8ad22de36e3480fc2c3..bf306ac10a0617c451855f287fa25538cddad012 100644 GIT binary patch delta 197 zcmbPoj&a5%#tkl-ypCmwC5c5rsl^5PdBu|rwM5wvqaP%9soVGNf7`5 delta 32 qcmV+*0N?+Z(gBpt0kA|Vv*0On4wLye9h1N~j(fa^WE&#U(=iE?d9F{3a;OMn1*mQgPYkMe4JgqyZ&;5 zW@Py$6~>K!kA8bSphvYTw2|>rn|P^mc@9-kS((N%mup$Luz6PNQX6;FL*yH=wQ*Uw z*vNk?WzfA4Yy4jdgWpaXi@}XO?w;tvI#IY-rI=7Hl{D;bk0#4iA#I%NVufZd6Gi;` z*Eq?9v4a7obD)=?OIe6gMfi6y7$mu{7A_xz6XsUh64Oa|8~}{4{afiOQ&1QV2imKa`@o_ zZTEb66T?DaJ6hNgeLo=(5Jd`pMl2|2;X&k;w6HXKT}PVp5^8FR21AnE96MZuP*gec zPh@YDWvO+LFO6KH(s@Wsm7qdwOxlTYW}Hf59Is5L#Drfskm;XX0Yg?p=qqdR!(sxi zi!_AgH0uC8WZ5K2gmq6a4m{z07-*OdDhRMN|6%aY4~HTmh`2yvanI4&2@Mi`Fadq- z_$@l=vJyuwB z4GTfDXYpjra_R=8LyB66M4Ec8JDwiY2HYORwU^V>RM5b54$Uu)!gEsN-<{V3tY9t5 zqbxLqdPvZ!JH`h=nrZF=jyZpbHxo1Ve0)#7IOp{>cnU|s)?6QCdTNhszjT8*_{XFN~>*;n|-7cNp-42icxVXOfdU|_tPM4p)Ty*GsNY}$#x*DE;y8Qb9 zW^DN}7uNTGOMZRd;-6}&jg{%d*mR-_*`rdIPUfjBnN zQA%o;PU&d~@}_#yd$XzFs6#Weln>H&g2_~BX@N^#g?Fa3C?PY5JTIN6i6{yhi|jjj z!*z3PU{;k(ezgcFF{Ec;6GU1Pc^=L76o9&)X)q*!KOO@zMvvh~V0_*=X(JJ54EqTR z#3kVV(V!U%KbVb;DOdvN$XM$4+r#lUnL!wU)E-fPKv8I!DB7H*Q$P9fBYq=fZW2y! z-y$rwhmvFg144_?!#qZIfL-`65tFi&`|LlxL zgsAd~(xRYmxJ7+>V*R-O5UN%a3Q$(y9K`DD7!>?R_An$ryho(%@WcRXk_DzE<^)tv zJs6J4Hl(y+bb}q=URTrix{)TKbBY_yk@7B1jiZS*a|B}@kHR2f3rt{e$SYz5yzV=Z zm!PGka+Nx(E!x9`$qDvSSZTJ*AV8P0G*fIiud)=&aAtC*I^NjP*-6MB;IHV2b=5dB z%5l6QP!v<&C#;4BOTi?`g!51MYDej?m@E968fgb$`C%Zc_uqj_7@klb>tW015idag zLv+5Z!)KMC<}DCZHT^}Qwr1u56}WX-hp)TmG#z>j13k57$@>xF!8UTTv##5Z&0Fbs z{XwKVfk*eTCdQ7#@oaN?%yiIBqTIFd7xRT*)$sr)_C~u3YnHsM*me8720W6{-yv}e z5(=&WVNkZ@J(&)E9LrCzG$X|g&j@D?>khb;mp9x}yN0Mo^|uhEiF|Y9cW|P)SnS2W z?mEH&tL7fq3DH~zHPp!WBGSRGADMVgu^7kgeANev6z_B#UHFI+7v~OZxNBE6ZwdcW z($$?X+_9UN`3pgkVAEjZCp-e6QFQz+SG?(hf7Bo+et}H5HZ~a(!doXGWa1lasr(GL zcvkqB(?tI`n!ux4X8a<>sen&Jh=hr(X78%8^jS5#<1%1lzP(oquboB2XsTpSn*XDf zSIXculB)JqW6po781%GDY!m&2DcL~Q5Kd<^irb;=Y}6FRurtv}(-1)yCr7#AyFOeK zq~-7m6`+<+XJg6l9f!0mc|o6VhSz**su&M1BP^JBGXIl(H?Q)+Bkb@J$By_Hl4bVv literal 0 HcmV?d00001 diff --git a/mobile/openapi/lib/model/memory_response.dart b/mobile/openapi/lib/model/memory_response.dart new file mode 100644 index 0000000000000000000000000000000000000000..fb34bc1518876a70c3b39584e94c733993e8d147 GIT binary patch literal 2761 zcmbVOZExE)5dQ98aRG)z0aSV0ry{An7K<~qYhobv1`LKFFcM|4lSz%FY8a{i`|e0l zksPOQm^QD4iCf;up}!#_&Al72j}a<8UuFwbJ(J4pwd1&ZKo? zQ_cTrgvRZXZSc2d8viZV2Aykje0!#(u}s=jq zG0jS*&1i(-ET{rh%_XZvg5S&0D65z;aKkrB@9wy%F&u}75x^MBzvH&i(f|X=x3C;q ztw6ZcGM=FM@L5D207AiFOJNcWdtdQ9#z*^e3=MFvR0Zv_TX?I!aILY_@DP*L%qk6& zNi@Iz%rh*(mRz61WC|XVj8zf&kHeF%U;Pz`CxxA!y1bl=kPo{4W)4DHSaS;{Gv%); z=NQ#F=%hi4kUZnYFufy2k))CvVVqgi)|Q0*zU+RLpR5*g4OaP!$Q5=rM044?nf%aR ztj-)-H>`f+x5bd}P*ftDqNfi=N%Z3>D1>Ace8wf1z!U3-{iRD3oW)X~wE@mnXx%5x zn`x&9XG;rv;_yG{1enahw~7H_4SZo$!wnQ#ZCX!Dw}jFv(BO=ex5O$}G03K>Jgr+{ zoZ@;M)S4G4c-Jes$xtsQB_*ZUI#y&Uc3G)%;>0f;sPz|@frG7u(PtJg#AG6ums1q0 z!#cnKU1ySH%-AP5N1k}!6Vy`&8AOsW2^m~p0$&jaqBlgR;= z3CYP-f=Rcq5Hyc0o=lva`VQ%=q7o98=br1{P7i7TcK~s_%PJ}=3NT$jxN=@xp9JOg zdiDS-qWmZeAyE$rTJ>A`z)9%nKH%6v-^JSzGx2jJQY$8Z_YGVB4Z@#0$%Oi%nRl45^1$3Sg-4!RSs4)q<6?cLezAy^#l}mjrrK3ai=OTia~#4 zpq?1Uuqb)Qd-ryO5;Hh084}!}B9i{Z4zk$vk~< PV02^Hy(?Xg@BsN2j}w2k literal 0 HcmV?d00001 diff --git a/mobile/openapi/lib/model/memory_update.dart b/mobile/openapi/lib/model/memory_update.dart new file mode 100644 index 0000000000000000000000000000000000000000..f2529186c0432db7c348cb2f75849bc1e196b924 GIT binary patch literal 3157 zcmbVO?@t>!5dEINVyI3fNK5G5r;0YARe@7`g`5gK>4ebA*>#B9jlH$KL=nyZes64V z*oZ*UA+bs9@%(u2&8!a&dItye{_Ev<|Id^2lP~WtPmbyImL9zNEA9@yFAD zcVNbr?@D3)@Q=aouY3Gc-DqQFJ~cLO78ajgS_kLWZ9iTZ?Yz)FS0w_fsggDvcUyy1N=uhlM&H29&xFoj zzsif5u&&obI8SCurj}aNYJmSYy%c5#Zi4saV6;0*o zR#c6oe&dwB2~J+5ox>OF4E8dbnT5QSHVY;;}QHZfL9QMbPZ?b?QfTZ1{;fSKb3Q_ghdDiay{29Ly0(P0EmoMR#;%3V= z96y?Y(w=Ul2S9H2fw3Eof`fIRaeHZ5NauuIa(h7WGwGBIX8lwdhhS*wh+ffQ8*OLM zx{XTfmVV)FVB5qm{ZQ?0x0b8qd-z-Wa=jY>JQMZXcqpTCiMCb62o!vBM#D>#`c!FA z(Kjs7kRDjyt=@#})szCPYoK;w6|ENv9Fl;3Vr1 z+J-(3mb|=5jrhF&CZS7;&tXc4=gn%%ZoXl2j`LTE_ z9Zy1tb;I%1bJXPj1bE=N2Em-){krWDGY* z9I%E$6-YP%!l2xf_n=33JElLt{EQTLPDVIeSU12;r@Y~k`Yl9^B0^9f=!2wU+@TkFm@4r@Leu>+J*B`gBImO4zE!GYWbozk^J6q2+NWe^yy-J&KIb}cz79Mz{G3%Uwn4`N*~?94li@e Gi2nfU^82y? literal 0 HcmV?d00001 diff --git a/mobile/openapi/lib/model/user_admin_create_dto.dart b/mobile/openapi/lib/model/user_admin_create_dto.dart index daf8854e019fa2766a2430f1a75cdecfdaf7c2b2..db514a1d571b6477b41db49d958d450d60329c0f 100644 GIT binary patch delta 54 zcmV-60LlO9G2$z*qXCoV0S=SP0iLtq1LXs=^#@7;vwsO>0kZ`Q4gs^S3`GP4ZeeX@ Mld%yYvtbd42XRysZ~y=R delta 342 zcmaE){nBv5Vn&gYjLc%a+|=CsqRiA{*Sy4}oYa)ba~U^n7Gg%p*_7bHLmwgZ z)Jq?su$Ml7_y*p*2;O`NXEv$OsoldI_V@SuW^UfxzWcS4Jaf6Q+d~>^Bgh^oHjp%3 zOP-hSlkW>>old6)_!Ebcm?bFfsEZF?gOSlAGHwE!>q2tmY)lvsDNj;cs*o~xHP9C- zCAgemN0f?8q|6DekO@h1bFRq-Yj~oPFlMqKY!p_gs5RbNEiIR!624 rxwJl`SIr-Hoc?%PUyaDqpHJ%LtCfqTe}LcXFV-S*t6g5Ne|vlfrn31( diff --git a/mobile/openapi/lib/model/user_admin_update_dto.dart b/mobile/openapi/lib/model/user_admin_update_dto.dart index ecd145248f1feb1e2a907402837176e655905344..dd0db767fe6b08cc7f0c4ce10e479f6ab68e89e2 100644 GIT binary patch delta 83 zcmX?R^Uz>JKjY-3i~^g_GhSfaEX$hDI9ZmJV>36$Cg#acxn(vR@`y4{uH)6&{E9b( mkta1bF*C lex61akcci)M^daFtEn^jnt%fzs!fxh3MybdNc4gim+>4X3P diff --git a/mobile/openapi/lib/model/user_preferences_response_dto.dart b/mobile/openapi/lib/model/user_preferences_response_dto.dart new file mode 100644 index 0000000000000000000000000000000000000000..673f5bfaf856489e712dbc89386dbf35688d8ffe GIT binary patch literal 3234 zcmbVOZExE)5dQ98aRG{2!BlzMr^1=M21_#Z#qp4)9|pq^7@4-%$)ZM5F^tszeRrfN z%eAd!HDFsJ?~V67cf7%%HyFU>m;1?^Kd*1E?=J7Jui)nWr|S@|CU85shwH9O7nTbbH3t*3H`fRl~S3b8Z2M4jZVtS zB#QrA35DtfTj6iX6#g5oG#Xd>xOyrJZCGIvF-M1DA-J;Y`lz#96kI2{ES4x{GFBvS ze@@bjDc$R#I|Fh7vg8FTMTFmrUN6m=*6>Ah^_Sucu6U7heaCf)e)*M=@YDksU@Gpo zsZ^nXLBZEBJ*_bVVM9)NgsP|I0k zop0aV6Nt{_y?$*^V$IcOLoaotu(#`Dq<#N<5U(fFf_V=NO@N&`^~aT5yC= zGqoXxA!#zMg|=dWOHt(2&*3Mx?j7!S4qSgWN)$^l(mez=JN?t}PMxp#mHQ8yL&dMW zw^`qT#me8D(GcbvtPPQl(9Z`g3-sbxvJeH!;VUjd3k(dX0jM z(nElO1vW;?O?ojL|fHn#S+<^o~SaG}oVCxJ&jT?Zq`QQ*c$>r+cnhwx+@ z&{D}Yd&sexVZn0E!>!M)%dFzM$_){}!_9Q&;w6~KIH5$LVVAKKj)cCI=6Y3{jkk`- zVb=?4CvuIA+lub?J+=<_xC5_UO7Spt>L`6X)||zb%5CG+{pn~Co6jA$TAD8L9)LX< zYAqw_fPqin5d4$1V(YcW2VJ(~=n86y(B$#Gzz!Nj%uJ$!LhZ9Kvez2Rl*&qYYNv;7~Wt;xu0v~YU{aZKLS zDgMJZ)S#`t<1U5K0XwQT#fJ&NS89_sU((&%Yhf~6qKczY=QJ{JoC50Cy%;YXRHW&T z?C$#IxWyb|TUQB=NR@<+a_FVijyBrtde-TWaO{Qjc?lKkKHcgh&oT{9xKDx8I7I|y zlg<|5!lu}FWgj*ybR^pA8R)b{o&nrvyx~bRgCZq0P7Dl5P$7jLIN$}V(Jd7YgAbo4 nw=~q&UB%wI%>et6berL1^kfs`ClPli^_zRfPO%p~>{0&#SAZ+o literal 0 HcmV?d00001 diff --git a/mobile/openapi/lib/model/user_preferences_update_dto.dart b/mobile/openapi/lib/model/user_preferences_update_dto.dart new file mode 100644 index 0000000000000000000000000000000000000000..887293931c282acb441fe7979fba57b6b0f4b731 GIT binary patch literal 4054 zcmeHK-*4MC5PtVxaVd(%!BnT)Q{m1@izXe~HA&H?4}%d1j6}z5Wzr?77)F}^eczFy zEjLz^4cOZrqR7RY zdi&v}!7^JFGu=2DBf8sP6muUAyUIr{9R{b1!pTB!@^KF>?x<&igZ(<9nP zB0frATkS}YmJ1qp$zPBP;TCcL#V)BnTP5;S~O=Ay5RhJT*E#*Yim8WY_)sp(PQ~D|xdDMIvK3YYvm(kQLC5-;@r{6YR~gciCvZVguqj0NXgIL> zHATqZ)BBxhmG*QcJ-F(VTdI#qhU&nF(J$Zh*Jj@jKI=5VPdri z6hsDvaQ+UJ`f+%2g*SQaB6vSw&w`Oo!;PUZ;G~5>nY1m=?Zv6hdI{AbowOR;9nB=R zd$H0*kZj;KJexQZEdu^c9^Wf$v1`HqE`2!$PMFlpQE{YW(qR?w83n&_dttH!jSAM>MOhzb8B+ z)}v0otaFKtG)d#ct$A{%Qu%Y-)&R$b$flNNOnFNE1^|tgH@v-mO5KpsTd>kbr``BE z^G7o=--=PnMobf=+MTh}T)R~!I1tBA(Fk&*h*nq8pgXL~Jsr_=oRV=F;6r~LRvXA- z)h-jZ1O9~rY}ysW5O?-)jDx{JkZ&P&57tQBI4XXXOP&ZpKXR6X_zB=pZ8cm5iW_3c zmWd~hh4ND@ah>rcq6?L$Q5@P$&*h^h&e44LfYo(hJZX(eW?6UH9k3#cY~{{F>l-%O zth&eLzv0#cY4Z>YHvPEOJMLu|_O}Us7eWJuaFxmy(Xv%}uM0g}u`iNmqf5YLi!}SV zkI5B3(p8s0MTet_0Q`LCxs*IY9o%xc2zvMF?3C{!O_vXI6-_JNlmBGh)gkHN268wa Hq3`?+X#Fu< literal 0 HcmV?d00001 diff --git a/mobile/openapi/lib/model/user_update_me_dto.dart b/mobile/openapi/lib/model/user_update_me_dto.dart index 1b54d4a383320d8e4f8eda1f0c36939046ee8604..2d665fc7847b8bd3ca24e313ab9c5b34b335d9e4 100644 GIT binary patch delta 79 zcmeyQ*Q>mtjgd7qH!(A3^KQm2#?6OW@)eJzes0t4Wkr)Zfb6RQD$nfYhGefPHM{LZpJP~nb6|Y zB1eQudj%wInaP+PG=NN%FoZSS3oxbEZ@WOl2Hmtmx8S=vLTbx*|e0or0%C9gCmN5NjfK*0*xKt;Wb#NrI+{FGEp1?|aP>_)n1MuAL6(uyWKS(AO9 z2a-j$Do7k1h1A@{%p6-4Fr|a;NLv+j!!EN|xghCRP=r{msZhPTwT zV-;)_N-{Ew^^gQ7*K^2mBl870ZzbqGt)BtRCSr-aEs%Qhe2@Mf|> za)FfsTGG%7BU;}g% Oiqz3mZ#Lr&X9ED|KKVER diff --git a/open-api/immich-openapi-specs.json b/open-api/immich-openapi-specs.json index 558823e62..d87599486 100644 --- a/open-api/immich-openapi-specs.json +++ b/open-api/immich-openapi-specs.json @@ -432,6 +432,98 @@ ] } }, + "/admin/users/{id}/preferences": { + "get": { + "operationId": "getUserPreferencesAdmin", + "parameters": [ + { + "name": "id", + "required": true, + "in": "path", + "schema": { + "format": "uuid", + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UserPreferencesResponseDto" + } + } + }, + "description": "" + } + }, + "security": [ + { + "bearer": [] + }, + { + "cookie": [] + }, + { + "api_key": [] + } + ], + "tags": [ + "User" + ] + }, + "put": { + "operationId": "updateUserPreferencesAdmin", + "parameters": [ + { + "name": "id", + "required": true, + "in": "path", + "schema": { + "format": "uuid", + "type": "string" + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UserPreferencesUpdateDto" + } + } + }, + "required": true + }, + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UserPreferencesResponseDto" + } + } + }, + "description": "" + } + }, + "security": [ + { + "bearer": [] + }, + { + "cookie": [] + }, + { + "api_key": [] + } + ], + "tags": [ + "User" + ] + } + }, "/admin/users/{id}/restore": { "post": { "operationId": "restoreUserAdmin", @@ -6403,6 +6495,78 @@ ] } }, + "/users/me/preferences": { + "get": { + "operationId": "getMyPreferences", + "parameters": [], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UserPreferencesResponseDto" + } + } + }, + "description": "" + } + }, + "security": [ + { + "bearer": [] + }, + { + "cookie": [] + }, + { + "api_key": [] + } + ], + "tags": [ + "User" + ] + }, + "put": { + "operationId": "updateMyPreferences", + "parameters": [], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UserPreferencesUpdateDto" + } + } + }, + "required": true + }, + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UserPreferencesResponseDto" + } + } + }, + "description": "" + } + }, + "security": [ + { + "bearer": [] + }, + { + "cookie": [] + }, + { + "api_key": [] + } + ], + "tags": [ + "User" + ] + } + }, "/users/profile-image": { "delete": { "operationId": "deleteProfileImage", @@ -7621,6 +7785,25 @@ ], "type": "object" }, + "AvatarResponse": { + "properties": { + "color": { + "$ref": "#/components/schemas/UserAvatarColor" + } + }, + "required": [ + "color" + ], + "type": "object" + }, + "AvatarUpdate": { + "properties": { + "color": { + "$ref": "#/components/schemas/UserAvatarColor" + } + }, + "type": "object" + }, "BulkIdResponseDto": { "properties": { "error": { @@ -8584,6 +8767,17 @@ ], "type": "object" }, + "MemoryResponse": { + "properties": { + "enabled": { + "type": "boolean" + } + }, + "required": [ + "enabled" + ], + "type": "object" + }, "MemoryResponseDto": { "properties": { "assets": { @@ -8650,6 +8844,14 @@ ], "type": "string" }, + "MemoryUpdate": { + "properties": { + "enabled": { + "type": "boolean" + } + }, + "type": "object" + }, "MemoryUpdateDto": { "properties": { "isSaved": { @@ -10878,9 +11080,6 @@ "email": { "type": "string" }, - "memoriesEnabled": { - "type": "boolean" - }, "name": { "type": "string" }, @@ -10942,9 +11141,6 @@ "isAdmin": { "type": "boolean" }, - "memoriesEnabled": { - "type": "boolean" - }, "name": { "type": "string" }, @@ -11000,15 +11196,9 @@ }, "UserAdminUpdateDto": { "properties": { - "avatarColor": { - "$ref": "#/components/schemas/UserAvatarColor" - }, "email": { "type": "string" }, - "memoriesEnabled": { - "type": "boolean" - }, "name": { "type": "string" }, @@ -11046,6 +11236,32 @@ ], "type": "string" }, + "UserPreferencesResponseDto": { + "properties": { + "avatar": { + "$ref": "#/components/schemas/AvatarResponse" + }, + "memories": { + "$ref": "#/components/schemas/MemoryResponse" + } + }, + "required": [ + "avatar", + "memories" + ], + "type": "object" + }, + "UserPreferencesUpdateDto": { + "properties": { + "avatar": { + "$ref": "#/components/schemas/AvatarUpdate" + }, + "memories": { + "$ref": "#/components/schemas/MemoryUpdate" + } + }, + "type": "object" + }, "UserResponseDto": { "properties": { "avatarColor": { @@ -11083,15 +11299,9 @@ }, "UserUpdateMeDto": { "properties": { - "avatarColor": { - "$ref": "#/components/schemas/UserAvatarColor" - }, "email": { "type": "string" }, - "memoriesEnabled": { - "type": "boolean" - }, "name": { "type": "string" }, diff --git a/open-api/typescript-sdk/README.md b/open-api/typescript-sdk/README.md index 53a83a423..046cea769 100644 --- a/open-api/typescript-sdk/README.md +++ b/open-api/typescript-sdk/README.md @@ -13,22 +13,13 @@ npm i --save @immich/sdk For a more detailed example, check out the [`@immich/cli`](https://github.com/immich-app/immich/tree/main/cli). ```typescript -<<<<<<< HEAD -import { getAllAlbums, getAllAssets, getMyUser, init } from "@immich/sdk"; -======= -import { getAllAlbums, getMyUserInfo, init } from "@immich/sdk"; ->>>>>>> e7c8501930a988dfb6c23ce1c48b0beb076a58c2 +import { getAllAlbums, getMyUser, init } from "@immich/sdk"; const API_KEY = ""; // process.env.IMMICH_API_KEY init({ baseUrl: "https://demo.immich.app/api", apiKey: API_KEY }); -<<<<<<< HEAD const user = await getMyUser(); -const assets = await getAllAssets({ take: 1000 }); -======= -const user = await getMyUserInfo(); ->>>>>>> e7c8501930a988dfb6c23ce1c48b0beb076a58c2 const albums = await getAllAlbums({}); console.log({ user, albums }); diff --git a/open-api/typescript-sdk/src/fetch-client.ts b/open-api/typescript-sdk/src/fetch-client.ts index 2c07072f6..8030c92d4 100644 --- a/open-api/typescript-sdk/src/fetch-client.ts +++ b/open-api/typescript-sdk/src/fetch-client.ts @@ -45,7 +45,6 @@ export type UserAdminResponseDto = { email: string; id: string; isAdmin: boolean; - memoriesEnabled?: boolean; name: string; oauthId: string; profileImagePath: string; @@ -58,7 +57,6 @@ export type UserAdminResponseDto = { }; export type UserAdminCreateDto = { email: string; - memoriesEnabled?: boolean; name: string; notify?: boolean; password: string; @@ -70,15 +68,33 @@ export type UserAdminDeleteDto = { force?: boolean; }; export type UserAdminUpdateDto = { - avatarColor?: UserAvatarColor; email?: string; - memoriesEnabled?: boolean; name?: string; password?: string; quotaSizeInBytes?: number | null; shouldChangePassword?: boolean; storageLabel?: string | null; }; +export type AvatarResponse = { + color: UserAvatarColor; +}; +export type MemoryResponse = { + enabled: boolean; +}; +export type UserPreferencesResponseDto = { + avatar: AvatarResponse; + memories: MemoryResponse; +}; +export type AvatarUpdate = { + color?: UserAvatarColor; +}; +export type MemoryUpdate = { + enabled?: boolean; +}; +export type UserPreferencesUpdateDto = { + avatar?: AvatarUpdate; + memories?: MemoryUpdate; +}; export type AlbumUserResponseDto = { role: AlbumUserRole; user: UserResponseDto; @@ -1073,9 +1089,7 @@ export type TimeBucketResponseDto = { timeBucket: string; }; export type UserUpdateMeDto = { - avatarColor?: UserAvatarColor; email?: string; - memoriesEnabled?: boolean; name?: string; password?: string; }; @@ -1200,6 +1214,29 @@ export function updateUserAdmin({ id, userAdminUpdateDto }: { body: userAdminUpdateDto }))); } +export function getUserPreferencesAdmin({ id }: { + id: string; +}, opts?: Oazapfts.RequestOpts) { + return oazapfts.ok(oazapfts.fetchJson<{ + status: 200; + data: UserPreferencesResponseDto; + }>(`/admin/users/${encodeURIComponent(id)}/preferences`, { + ...opts + })); +} +export function updateUserPreferencesAdmin({ id, userPreferencesUpdateDto }: { + id: string; + userPreferencesUpdateDto: UserPreferencesUpdateDto; +}, opts?: Oazapfts.RequestOpts) { + return oazapfts.ok(oazapfts.fetchJson<{ + status: 200; + data: UserPreferencesResponseDto; + }>(`/admin/users/${encodeURIComponent(id)}/preferences`, oazapfts.json({ + ...opts, + method: "PUT", + body: userPreferencesUpdateDto + }))); +} export function restoreUserAdmin({ id }: { id: string; }, opts?: Oazapfts.RequestOpts) { @@ -2780,6 +2817,26 @@ export function updateMyUser({ userUpdateMeDto }: { body: userUpdateMeDto }))); } +export function getMyPreferences(opts?: Oazapfts.RequestOpts) { + return oazapfts.ok(oazapfts.fetchJson<{ + status: 200; + data: UserPreferencesResponseDto; + }>("/users/me/preferences", { + ...opts + })); +} +export function updateMyPreferences({ userPreferencesUpdateDto }: { + userPreferencesUpdateDto: UserPreferencesUpdateDto; +}, opts?: Oazapfts.RequestOpts) { + return oazapfts.ok(oazapfts.fetchJson<{ + status: 200; + data: UserPreferencesResponseDto; + }>("/users/me/preferences", oazapfts.json({ + ...opts, + method: "PUT", + body: userPreferencesUpdateDto + }))); +} export function deleteProfileImage(opts?: Oazapfts.RequestOpts) { return oazapfts.ok(oazapfts.fetchText("/users/profile-image", { ...opts, diff --git a/server/src/controllers/user-admin.controller.ts b/server/src/controllers/user-admin.controller.ts index 4d0b781e8..83b5156ed 100644 --- a/server/src/controllers/user-admin.controller.ts +++ b/server/src/controllers/user-admin.controller.ts @@ -1,6 +1,7 @@ import { Body, Controller, Delete, Get, Param, Post, Put, Query } from '@nestjs/common'; import { ApiTags } from '@nestjs/swagger'; import { AuthDto } from 'src/dtos/auth.dto'; +import { UserPreferencesResponseDto, UserPreferencesUpdateDto } from 'src/dtos/user-preferences.dto'; import { UserAdminCreateDto, UserAdminDeleteDto, @@ -55,6 +56,22 @@ export class UserAdminController { return this.service.delete(auth, id, dto); } + @Get(':id/preferences') + @Authenticated() + getUserPreferencesAdmin(@Auth() auth: AuthDto, @Param() { id }: UUIDParamDto): Promise { + return this.service.getPreferences(auth, id); + } + + @Put(':id/preferences') + @Authenticated() + updateUserPreferencesAdmin( + @Auth() auth: AuthDto, + @Param() { id }: UUIDParamDto, + @Body() dto: UserPreferencesUpdateDto, + ): Promise { + return this.service.updatePreferences(auth, id, dto); + } + @Post(':id/restore') @Authenticated({ admin: true }) restoreUserAdmin(@Auth() auth: AuthDto, @Param() { id }: UUIDParamDto): Promise { diff --git a/server/src/controllers/user.controller.ts b/server/src/controllers/user.controller.ts index f66807b92..66a92e1a3 100644 --- a/server/src/controllers/user.controller.ts +++ b/server/src/controllers/user.controller.ts @@ -17,6 +17,7 @@ import { import { ApiBody, ApiConsumes, ApiTags } from '@nestjs/swagger'; import { NextFunction, Response } from 'express'; import { AuthDto } from 'src/dtos/auth.dto'; +import { UserPreferencesResponseDto, UserPreferencesUpdateDto } from 'src/dtos/user-preferences.dto'; import { CreateProfileImageDto, CreateProfileImageResponseDto } from 'src/dtos/user-profile.dto'; import { UserAdminResponseDto, UserResponseDto, UserUpdateMeDto } from 'src/dtos/user.dto'; import { ILoggerRepository } from 'src/interfaces/logger.interface'; @@ -52,6 +53,21 @@ export class UserController { return this.service.updateMe(auth, dto); } + @Get('me/preferences') + @Authenticated() + getMyPreferences(@Auth() auth: AuthDto): UserPreferencesResponseDto { + return this.service.getMyPreferences(auth); + } + + @Put('me/preferences') + @Authenticated() + updateMyPreferences( + @Auth() auth: AuthDto, + @Body() dto: UserPreferencesUpdateDto, + ): Promise { + return this.service.updateMyPreferences(auth, dto); + } + @Get(':id') @Authenticated() getUser(@Param() { id }: UUIDParamDto): Promise { diff --git a/server/src/dtos/user-preferences.dto.ts b/server/src/dtos/user-preferences.dto.ts new file mode 100644 index 000000000..2dd9492d0 --- /dev/null +++ b/server/src/dtos/user-preferences.dto.ts @@ -0,0 +1,47 @@ +import { ApiProperty } from '@nestjs/swagger'; +import { Type } from 'class-transformer'; +import { IsEnum, ValidateNested } from 'class-validator'; +import { UserAvatarColor, UserPreferences } from 'src/entities/user-metadata.entity'; +import { Optional, ValidateBoolean } from 'src/validation'; + +class AvatarUpdate { + @Optional() + @IsEnum(UserAvatarColor) + @ApiProperty({ enumName: 'UserAvatarColor', enum: UserAvatarColor }) + color?: UserAvatarColor; +} + +class MemoryUpdate { + @ValidateBoolean({ optional: true }) + enabled?: boolean; +} + +export class UserPreferencesUpdateDto { + @Optional() + @ValidateNested() + @Type(() => AvatarUpdate) + avatar?: AvatarUpdate; + + @Optional() + @ValidateNested() + @Type(() => MemoryUpdate) + memories?: MemoryUpdate; +} + +class AvatarResponse { + @ApiProperty({ enumName: 'UserAvatarColor', enum: UserAvatarColor }) + color!: UserAvatarColor; +} + +class MemoryResponse { + enabled!: boolean; +} + +export class UserPreferencesResponseDto implements UserPreferences { + memories!: MemoryResponse; + avatar!: AvatarResponse; +} + +export const mapPreferences = (preferences: UserPreferences): UserPreferencesResponseDto => { + return preferences; +}; diff --git a/server/src/dtos/user.dto.ts b/server/src/dtos/user.dto.ts index 8290df6ad..63bac60d0 100644 --- a/server/src/dtos/user.dto.ts +++ b/server/src/dtos/user.dto.ts @@ -1,6 +1,6 @@ import { ApiProperty } from '@nestjs/swagger'; import { Transform } from 'class-transformer'; -import { IsBoolean, IsEmail, IsEnum, IsNotEmpty, IsNumber, IsPositive, IsString } from 'class-validator'; +import { IsBoolean, IsEmail, IsNotEmpty, IsNumber, IsPositive, IsString } from 'class-validator'; import { UserAvatarColor } from 'src/entities/user-metadata.entity'; import { UserEntity, UserStatus } from 'src/entities/user.entity'; import { getPreferences } from 'src/utils/preferences'; @@ -22,14 +22,6 @@ export class UserUpdateMeDto { @IsString() @IsNotEmpty() name?: string; - - @ValidateBoolean({ optional: true }) - memoriesEnabled?: boolean; - - @Optional() - @IsEnum(UserAvatarColor) - @ApiProperty({ enumName: 'UserAvatarColor', enum: UserAvatarColor }) - avatarColor?: UserAvatarColor; } export class UserResponseDto { @@ -37,7 +29,6 @@ export class UserResponseDto { name!: string; email!: string; profileImagePath!: string; - @IsEnum(UserAvatarColor) @ApiProperty({ enumName: 'UserAvatarColor', enum: UserAvatarColor }) avatarColor!: UserAvatarColor; } @@ -75,9 +66,6 @@ export class UserAdminCreateDto { @Transform(toSanitized) storageLabel?: string | null; - @ValidateBoolean({ optional: true }) - memoriesEnabled?: boolean; - @Optional({ nullable: true }) @IsNumber() @IsPositive() @@ -116,14 +104,6 @@ export class UserAdminUpdateDto { @ValidateBoolean({ optional: true }) shouldChangePassword?: boolean; - @ValidateBoolean({ optional: true }) - memoriesEnabled?: boolean; - - @Optional() - @IsEnum(UserAvatarColor) - @ApiProperty({ enumName: 'UserAvatarColor', enum: UserAvatarColor }) - avatarColor?: UserAvatarColor; - @Optional({ nullable: true }) @IsNumber() @IsPositive() @@ -144,7 +124,6 @@ export class UserAdminResponseDto extends UserResponseDto { deletedAt!: Date | null; updatedAt!: Date; oauthId!: string; - memoriesEnabled?: boolean; @ApiProperty({ type: 'integer', format: 'int64' }) quotaSizeInBytes!: number | null; @ApiProperty({ type: 'integer', format: 'int64' }) @@ -163,7 +142,6 @@ export function mapUserAdmin(entity: UserEntity): UserAdminResponseDto { deletedAt: entity.deletedAt, updatedAt: entity.updatedAt, oauthId: entity.oauthId, - memoriesEnabled: getPreferences(entity).memories.enabled, quotaSizeInBytes: entity.quotaSizeInBytes, quotaUsageInBytes: entity.quotaUsageInBytes, status: entity.status, diff --git a/server/src/services/user-admin.service.ts b/server/src/services/user-admin.service.ts index 1b93f96e7..72330ac9b 100644 --- a/server/src/services/user-admin.service.ts +++ b/server/src/services/user-admin.service.ts @@ -2,6 +2,7 @@ import { BadRequestException, ForbiddenException, Inject, Injectable } from '@ne import { SALT_ROUNDS } from 'src/constants'; import { UserCore } from 'src/cores/user.core'; import { AuthDto } from 'src/dtos/auth.dto'; +import { UserPreferencesResponseDto, UserPreferencesUpdateDto, mapPreferences } from 'src/dtos/user-preferences.dto'; import { UserAdminCreateDto, UserAdminDeleteDto, @@ -17,7 +18,7 @@ import { ICryptoRepository } from 'src/interfaces/crypto.interface'; import { IJobRepository, JobName } from 'src/interfaces/job.interface'; import { ILoggerRepository } from 'src/interfaces/logger.interface'; import { IUserRepository, UserFindOptions } from 'src/interfaces/user.interface'; -import { getPreferences, getPreferencesPartial } from 'src/utils/preferences'; +import { getPreferences, getPreferencesPartial, mergePreferences } from 'src/utils/preferences'; @Injectable() export class UserAdminService { @@ -40,18 +41,8 @@ export class UserAdminService { } async create(dto: UserAdminCreateDto): Promise { - const { memoriesEnabled, notify, ...rest } = dto; - let user = await this.userCore.createUser(rest); - - // TODO remove and replace with entire dto.preferences config - if (memoriesEnabled === false) { - await this.userRepository.upsertMetadata(user.id, { - key: UserMetadataKey.PREFERENCES, - value: { memories: { enabled: false } }, - }); - - user = await this.findOrFail(user.id, {}); - } + const { notify, ...rest } = dto; + const user = await this.userCore.createUser(rest); const tempPassword = user.shouldChangePassword ? rest.password : undefined; if (notify) { @@ -72,25 +63,6 @@ export class UserAdminService { await this.userRepository.syncUsage(id); } - // TODO replace with entire preferences object - if (dto.memoriesEnabled !== undefined || dto.avatarColor) { - const newPreferences = getPreferences(user); - if (dto.memoriesEnabled !== undefined) { - newPreferences.memories.enabled = dto.memoriesEnabled; - delete dto.memoriesEnabled; - } - - if (dto.avatarColor) { - newPreferences.avatar.color = dto.avatarColor; - delete dto.avatarColor; - } - - await this.userRepository.upsertMetadata(id, { - key: UserMetadataKey.PREFERENCES, - value: getPreferencesPartial(user, newPreferences), - }); - } - if (dto.email) { const duplicate = await this.userRepository.getByEmail(dto.email); if (duplicate && duplicate.id !== id) { @@ -144,6 +116,24 @@ export class UserAdminService { return mapUserAdmin(user); } + async getPreferences(auth: AuthDto, id: string): Promise { + const user = await this.findOrFail(id, { withDeleted: false }); + const preferences = getPreferences(user); + return mapPreferences(preferences); + } + + async updatePreferences(auth: AuthDto, id: string, dto: UserPreferencesUpdateDto) { + const user = await this.findOrFail(id, { withDeleted: false }); + const preferences = mergePreferences(user, dto); + + await this.userRepository.upsertMetadata(user.id, { + key: UserMetadataKey.PREFERENCES, + value: getPreferencesPartial(user, preferences), + }); + + return mapPreferences(preferences); + } + private async findOrFail(id: string, options: UserFindOptions) { const user = await this.userRepository.get(id, options); if (!user) { diff --git a/server/src/services/user.service.ts b/server/src/services/user.service.ts index 1f3650105..3920dbeaa 100644 --- a/server/src/services/user.service.ts +++ b/server/src/services/user.service.ts @@ -4,6 +4,7 @@ import { SALT_ROUNDS } from 'src/constants'; import { StorageCore, StorageFolder } from 'src/cores/storage.core'; import { SystemConfigCore } from 'src/cores/system-config.core'; import { AuthDto } from 'src/dtos/auth.dto'; +import { UserPreferencesResponseDto, UserPreferencesUpdateDto, mapPreferences } from 'src/dtos/user-preferences.dto'; import { CreateProfileImageResponseDto, mapCreateProfileImageResponse } from 'src/dtos/user-profile.dto'; import { UserAdminResponseDto, UserResponseDto, UserUpdateMeDto, mapUser, mapUserAdmin } from 'src/dtos/user.dto'; import { UserMetadataKey } from 'src/entities/user-metadata.entity'; @@ -16,7 +17,7 @@ import { IStorageRepository } from 'src/interfaces/storage.interface'; import { ISystemMetadataRepository } from 'src/interfaces/system-metadata.interface'; import { IUserRepository, UserFindOptions } from 'src/interfaces/user.interface'; import { CacheControl, ImmichFileResponse } from 'src/utils/file'; -import { getPreferences, getPreferencesPartial } from 'src/utils/preferences'; +import { getPreferences, getPreferencesPartial, mergePreferences } from 'src/utils/preferences'; @Injectable() export class UserService { @@ -45,25 +46,6 @@ export class UserService { } async updateMe({ user }: AuthDto, dto: UserUpdateMeDto): Promise { - // TODO replace with entire preferences object - if (dto.memoriesEnabled !== undefined || dto.avatarColor) { - const newPreferences = getPreferences(user); - if (dto.memoriesEnabled !== undefined) { - newPreferences.memories.enabled = dto.memoriesEnabled; - delete dto.memoriesEnabled; - } - - if (dto.avatarColor) { - newPreferences.avatar.color = dto.avatarColor; - delete dto.avatarColor; - } - - await this.userRepository.upsertMetadata(user.id, { - key: UserMetadataKey.PREFERENCES, - value: getPreferencesPartial(user, newPreferences), - }); - } - if (dto.email) { const duplicate = await this.userRepository.getByEmail(dto.email); if (duplicate && duplicate.id !== user.id) { @@ -87,6 +69,22 @@ export class UserService { return mapUserAdmin(updatedUser); } + getMyPreferences({ user }: AuthDto): UserPreferencesResponseDto { + const preferences = getPreferences(user); + return mapPreferences(preferences); + } + + async updateMyPreferences({ user }: AuthDto, dto: UserPreferencesUpdateDto) { + const preferences = mergePreferences(user, dto); + + await this.userRepository.upsertMetadata(user.id, { + key: UserMetadataKey.PREFERENCES, + value: getPreferencesPartial(user, preferences), + }); + + return mapPreferences(preferences); + } + async get(id: string): Promise { const user = await this.findOrFail(id, { withDeleted: false }); return mapUser(user); diff --git a/server/src/utils/preferences.ts b/server/src/utils/preferences.ts index ae10c24fc..f3561fa7b 100644 --- a/server/src/utils/preferences.ts +++ b/server/src/utils/preferences.ts @@ -1,4 +1,5 @@ import _ from 'lodash'; +import { UserPreferencesUpdateDto } from 'src/dtos/user-preferences.dto'; import { UserMetadataKey, UserPreferences, getDefaultPreferences } from 'src/entities/user-metadata.entity'; import { UserEntity } from 'src/entities/user.entity'; import { getKeysDeep } from 'src/utils/misc'; @@ -37,3 +38,12 @@ export const getPreferencesPartial = (user: { email: string }, newPreferences: U return partial; }; + +export const mergePreferences = (user: UserEntity, dto: UserPreferencesUpdateDto) => { + const preferences = getPreferences(user); + for (const key of getKeysDeep(dto)) { + _.set(preferences, key, _.get(dto, key)); + } + + return preferences; +}; diff --git a/web/src/lib/components/shared-components/navigation-bar/account-info-panel.svelte b/web/src/lib/components/shared-components/navigation-bar/account-info-panel.svelte index 8c73ed4d8..5d5a351de 100644 --- a/web/src/lib/components/shared-components/navigation-bar/account-info-panel.svelte +++ b/web/src/lib/components/shared-components/navigation-bar/account-info-panel.svelte @@ -1,18 +1,18 @@