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 273585c36..cdc75d4f2 100644 Binary files a/mobile/openapi/README.md and b/mobile/openapi/README.md differ diff --git a/mobile/openapi/lib/api.dart b/mobile/openapi/lib/api.dart index d7223a1ec..94303a768 100644 Binary files a/mobile/openapi/lib/api.dart and b/mobile/openapi/lib/api.dart differ diff --git a/mobile/openapi/lib/api/user_api.dart b/mobile/openapi/lib/api/user_api.dart index 3c1a3ff4e..246ea422c 100644 Binary files a/mobile/openapi/lib/api/user_api.dart and b/mobile/openapi/lib/api/user_api.dart differ diff --git a/mobile/openapi/lib/api_client.dart b/mobile/openapi/lib/api_client.dart index bd3433872..bf306ac10 100644 Binary files a/mobile/openapi/lib/api_client.dart and b/mobile/openapi/lib/api_client.dart differ diff --git a/mobile/openapi/lib/model/avatar_response.dart b/mobile/openapi/lib/model/avatar_response.dart new file mode 100644 index 000000000..edd242df4 Binary files /dev/null and b/mobile/openapi/lib/model/avatar_response.dart differ diff --git a/mobile/openapi/lib/model/avatar_update.dart b/mobile/openapi/lib/model/avatar_update.dart new file mode 100644 index 000000000..b92eb8dcb Binary files /dev/null and b/mobile/openapi/lib/model/avatar_update.dart differ diff --git a/mobile/openapi/lib/model/memory_response.dart b/mobile/openapi/lib/model/memory_response.dart new file mode 100644 index 000000000..fb34bc151 Binary files /dev/null and b/mobile/openapi/lib/model/memory_response.dart differ diff --git a/mobile/openapi/lib/model/memory_update.dart b/mobile/openapi/lib/model/memory_update.dart new file mode 100644 index 000000000..f2529186c Binary files /dev/null and b/mobile/openapi/lib/model/memory_update.dart differ diff --git a/mobile/openapi/lib/model/user_admin_create_dto.dart b/mobile/openapi/lib/model/user_admin_create_dto.dart index daf8854e0..db514a1d5 100644 Binary files a/mobile/openapi/lib/model/user_admin_create_dto.dart and b/mobile/openapi/lib/model/user_admin_create_dto.dart differ diff --git a/mobile/openapi/lib/model/user_admin_response_dto.dart b/mobile/openapi/lib/model/user_admin_response_dto.dart index 3fc8c2e27..8060fa7cf 100644 Binary files a/mobile/openapi/lib/model/user_admin_response_dto.dart and b/mobile/openapi/lib/model/user_admin_response_dto.dart differ diff --git a/mobile/openapi/lib/model/user_admin_update_dto.dart b/mobile/openapi/lib/model/user_admin_update_dto.dart index ecd145248..dd0db767f 100644 Binary files a/mobile/openapi/lib/model/user_admin_update_dto.dart and b/mobile/openapi/lib/model/user_admin_update_dto.dart differ 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 000000000..673f5bfaf Binary files /dev/null and b/mobile/openapi/lib/model/user_preferences_response_dto.dart differ 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 000000000..887293931 Binary files /dev/null and b/mobile/openapi/lib/model/user_preferences_update_dto.dart differ diff --git a/mobile/openapi/lib/model/user_update_me_dto.dart b/mobile/openapi/lib/model/user_update_me_dto.dart index 1b54d4a38..2d665fc78 100644 Binary files a/mobile/openapi/lib/model/user_update_me_dto.dart and b/mobile/openapi/lib/model/user_update_me_dto.dart differ 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 @@