From 75830a4878561046f07314dff6043cfa82b6cdfb Mon Sep 17 00:00:00 2001 From: Jason Rasmussen Date: Sun, 26 May 2024 18:15:52 -0400 Subject: [PATCH] refactor(server): user endpoints (#9730) * refactor(server): user endpoints * fix repos * fix unit tests --------- Co-authored-by: Daniel Dietzler Co-authored-by: Alex --- cli/src/commands/auth.ts | 6 +- cli/src/commands/server-info.ts | 4 +- cli/src/utils.ts | 4 +- e2e/src/api/specs/album.e2e-spec.ts | 4 +- e2e/src/api/specs/asset.e2e-spec.ts | 4 +- e2e/src/api/specs/shared-link.e2e-spec.ts | 4 +- e2e/src/api/specs/user-admin.e2e-spec.ts | 317 ++++++++ e2e/src/api/specs/user.e2e-spec.ts | 315 +++----- e2e/src/utils.ts | 8 +- mobile/lib/entities/user.entity.dart | 14 +- .../providers/authentication.provider.dart | 10 +- mobile/lib/providers/user.provider.dart | 2 +- .../lib/routing/tab_navigation_observer.dart | 2 +- mobile/lib/services/user.service.dart | 8 +- mobile/openapi/README.md | Bin 27259 -> 27685 bytes mobile/openapi/lib/api.dart | Bin 9750 -> 9821 bytes .../openapi/lib/api/authentication_api.dart | Bin 8141 -> 8171 bytes mobile/openapi/lib/api/o_auth_api.dart | Bin 7687 -> 7717 bytes mobile/openapi/lib/api/user_api.dart | Bin 15780 -> 20999 bytes mobile/openapi/lib/api_client.dart | Bin 26250 -> 26388 bytes .../lib/model/activity_response_dto.dart | Bin 6832 -> 6848 bytes .../lib/model/partner_response_dto.dart | Bin 8712 -> 4529 bytes ...er_dto.dart => user_admin_create_dto.dart} | Bin 6287 -> 6377 bytes ...er_dto.dart => user_admin_delete_dto.dart} | Bin 3147 -> 3237 bytes .../lib/model/user_admin_response_dto.dart | Bin 0 -> 8043 bytes ...er_dto.dart => user_admin_update_dto.dart} | Bin 8847 -> 7750 bytes mobile/openapi/lib/model/user_dto.dart | Bin 3626 -> 0 bytes .../openapi/lib/model/user_response_dto.dart | Bin 7953 -> 3770 bytes .../openapi/lib/model/user_update_me_dto.dart | Bin 0 -> 6002 bytes open-api/immich-openapi-specs.json | 738 ++++++++++-------- open-api/typescript-sdk/README.md | 9 + open-api/typescript-sdk/src/fetch-client.ts | 221 +++--- .../commands/reset-admin-password.command.ts | 4 +- server/src/controllers/auth.controller.ts | 8 +- server/src/controllers/index.ts | 2 + server/src/controllers/oauth.controller.ts | 6 +- .../src/controllers/user-admin.controller.ts | 63 ++ server/src/controllers/user.controller.ts | 60 +- server/src/cores/user.core.ts | 43 +- server/src/dtos/activity.dto.ts | 6 +- server/src/dtos/user.dto.spec.ts | 29 +- server/src/dtos/user.dto.ts | 112 +-- server/src/queries/api.key.repository.sql | 6 +- server/src/queries/session.repository.sql | 6 +- server/src/repositories/api-key.repository.ts | 4 +- server/src/repositories/session.repository.ts | 9 +- server/src/services/auth.service.spec.ts | 1 + server/src/services/auth.service.ts | 27 +- server/src/services/cli.service.ts | 17 +- server/src/services/index.ts | 2 + server/src/services/partner.service.spec.ts | 47 +- server/src/services/partner.service.ts | 8 +- .../src/services/user-admin.service.spec.ts | 197 +++++ server/src/services/user-admin.service.ts | 154 ++++ server/src/services/user.service.spec.ts | 264 +------ server/src/services/user.service.ts | 111 +-- server/src/validation.ts | 2 +- .../admin-page/delete-confirm-dialogue.svelte | 6 +- .../admin-page/restore-dialogue.svelte | 4 +- .../album-page/share-info-modal.svelte | 4 +- .../album-page/user-selection-modal.svelte | 8 +- .../forms/change-password-form.svelte | 11 +- .../components/forms/create-user-form.svelte | 6 +- .../components/forms/edit-user-form.svelte | 20 +- .../forms/library-user-picker-form.svelte | 4 +- .../navigation-bar/account-info-panel.svelte | 7 +- .../memories-settings.svelte | 13 +- .../user-settings-page/oauth-settings.svelte | 4 +- .../partner-selection-modal.svelte | 9 +- .../user-profile-settings.svelte | 17 +- web/src/lib/stores/user.store.ts | 6 +- web/src/lib/utils.ts | 3 +- web/src/lib/utils/auth.ts | 4 +- .../[[photos=photos]]/[[assetId=id]]/+page.ts | 4 +- .../admin/library-management/+page.svelte | 4 +- .../routes/admin/library-management/+page.ts | 4 +- .../routes/admin/user-management/+page.svelte | 22 +- web/src/routes/admin/user-management/+page.ts | 4 +- .../routes/auth/change-password/+page.svelte | 2 +- web/src/test-data/factories/user-factory.ts | 13 +- 80 files changed, 1696 insertions(+), 1341 deletions(-) create mode 100644 e2e/src/api/specs/user-admin.e2e-spec.ts rename mobile/openapi/lib/model/{create_user_dto.dart => user_admin_create_dto.dart} (80%) rename mobile/openapi/lib/model/{delete_user_dto.dart => user_admin_delete_dto.dart} (67%) create mode 100644 mobile/openapi/lib/model/user_admin_response_dto.dart rename mobile/openapi/lib/model/{update_user_dto.dart => user_admin_update_dto.dart} (73%) delete mode 100644 mobile/openapi/lib/model/user_dto.dart create mode 100644 mobile/openapi/lib/model/user_update_me_dto.dart create mode 100644 server/src/controllers/user-admin.controller.ts create mode 100644 server/src/services/user-admin.service.spec.ts create mode 100644 server/src/services/user-admin.service.ts diff --git a/cli/src/commands/auth.ts b/cli/src/commands/auth.ts index 6675201a7b..f0011c6a24 100644 --- a/cli/src/commands/auth.ts +++ b/cli/src/commands/auth.ts @@ -1,4 +1,4 @@ -import { getMyUserInfo } from '@immich/sdk'; +import { getMyUser } from '@immich/sdk'; import { existsSync } from 'node:fs'; import { mkdir, unlink } from 'node:fs/promises'; import { BaseOptions, connect, getAuthFilePath, logError, withError, writeAuthFile } from 'src/utils'; @@ -10,13 +10,13 @@ export const login = async (url: string, key: string, options: BaseOptions) => { await connect(url, key); - const [error, userInfo] = await withError(getMyUserInfo()); + const [error, user] = await withError(getMyUser()); if (error) { logError(error, 'Failed to load user info'); process.exit(1); } - console.log(`Logged in as ${userInfo.email}`); + console.log(`Logged in as ${user.email}`); if (!existsSync(configDir)) { // Create config folder if it doesn't exist diff --git a/cli/src/commands/server-info.ts b/cli/src/commands/server-info.ts index 074513bd61..bea49231c9 100644 --- a/cli/src/commands/server-info.ts +++ b/cli/src/commands/server-info.ts @@ -1,4 +1,4 @@ -import { getAssetStatistics, getMyUserInfo, getServerVersion, getSupportedMediaTypes } from '@immich/sdk'; +import { getAssetStatistics, getMyUser, getServerVersion, getSupportedMediaTypes } from '@immich/sdk'; import { BaseOptions, authenticate } from 'src/utils'; export const serverInfo = async (options: BaseOptions) => { @@ -8,7 +8,7 @@ export const serverInfo = async (options: BaseOptions) => { getServerVersion(), getSupportedMediaTypes(), getAssetStatistics({}), - getMyUserInfo(), + getMyUser(), ]); console.log(`Server Info (via ${userInfo.email})`); diff --git a/cli/src/utils.ts b/cli/src/utils.ts index 3b239bacc4..4919a2b3ca 100644 --- a/cli/src/utils.ts +++ b/cli/src/utils.ts @@ -1,4 +1,4 @@ -import { getMyUserInfo, init, isHttpError } from '@immich/sdk'; +import { getMyUser, init, isHttpError } from '@immich/sdk'; import { glob } from 'fast-glob'; import { createHash } from 'node:crypto'; import { createReadStream } from 'node:fs'; @@ -48,7 +48,7 @@ export const connect = async (url: string, key: string) => { init({ baseUrl: url, apiKey: key }); - const [error] = await withError(getMyUserInfo()); + const [error] = await withError(getMyUser()); if (isHttpError(error)) { logError(error, 'Failed to connect to server'); process.exit(1); diff --git a/e2e/src/api/specs/album.e2e-spec.ts b/e2e/src/api/specs/album.e2e-spec.ts index 4a231dbf9b..319cc4033d 100644 --- a/e2e/src/api/specs/album.e2e-spec.ts +++ b/e2e/src/api/specs/album.e2e-spec.ts @@ -4,7 +4,7 @@ import { AlbumUserRole, AssetFileUploadResponseDto, AssetOrder, - deleteUser, + deleteUserAdmin, getAlbumInfo, LoginResponseDto, SharedLinkType, @@ -107,7 +107,7 @@ describe('/albums', () => { }), ]); - await deleteUser({ id: user3.userId, deleteUserDto: {} }, { headers: asBearerAuth(admin.accessToken) }); + await deleteUserAdmin({ id: user3.userId, userAdminDeleteDto: {} }, { headers: asBearerAuth(admin.accessToken) }); }); describe('GET /albums', () => { diff --git a/e2e/src/api/specs/asset.e2e-spec.ts b/e2e/src/api/specs/asset.e2e-spec.ts index 98dca464bc..caf032e130 100644 --- a/e2e/src/api/specs/asset.e2e-spec.ts +++ b/e2e/src/api/specs/asset.e2e-spec.ts @@ -5,7 +5,7 @@ import { LoginResponseDto, SharedLinkType, getAssetInfo, - getMyUserInfo, + getMyUser, updateAssets, } from '@immich/sdk'; import { exiftool } from 'exiftool-vendored'; @@ -1162,7 +1162,7 @@ describe('/asset', () => { expect(body).toEqual({ id: expect.any(String), duplicate: false }); expect(status).toBe(201); - const user = await getMyUserInfo({ headers: asBearerAuth(quotaUser.accessToken) }); + const user = await getMyUser({ headers: asBearerAuth(quotaUser.accessToken) }); expect(user).toEqual(expect.objectContaining({ quotaUsageInBytes: 70 })); }); diff --git a/e2e/src/api/specs/shared-link.e2e-spec.ts b/e2e/src/api/specs/shared-link.e2e-spec.ts index aa4ec7e349..0d76fb6efe 100644 --- a/e2e/src/api/specs/shared-link.e2e-spec.ts +++ b/e2e/src/api/specs/shared-link.e2e-spec.ts @@ -5,7 +5,7 @@ import { SharedLinkResponseDto, SharedLinkType, createAlbum, - deleteUser, + deleteUserAdmin, } from '@immich/sdk'; import { createUserDto, uuidDto } from 'src/fixtures'; import { errorDto } from 'src/responses'; @@ -86,7 +86,7 @@ describe('/shared-links', () => { }), ]); - await deleteUser({ id: user2.userId, deleteUserDto: {} }, { headers: asBearerAuth(admin.accessToken) }); + await deleteUserAdmin({ id: user2.userId, userAdminDeleteDto: {} }, { headers: asBearerAuth(admin.accessToken) }); }); describe('GET /share/${key}', () => { diff --git a/e2e/src/api/specs/user-admin.e2e-spec.ts b/e2e/src/api/specs/user-admin.e2e-spec.ts new file mode 100644 index 0000000000..ac2b3e693a --- /dev/null +++ b/e2e/src/api/specs/user-admin.e2e-spec.ts @@ -0,0 +1,317 @@ +import { LoginResponseDto, deleteUserAdmin, getMyUser, getUserAdmin, login } from '@immich/sdk'; +import { Socket } from 'socket.io-client'; +import { createUserDto, uuidDto } from 'src/fixtures'; +import { errorDto } from 'src/responses'; +import { app, asBearerAuth, utils } from 'src/utils'; +import request from 'supertest'; +import { afterAll, beforeAll, describe, expect, it } from 'vitest'; + +describe('/admin/users', () => { + let websocket: Socket; + + let admin: LoginResponseDto; + let nonAdmin: LoginResponseDto; + let deletedUser: LoginResponseDto; + let userToDelete: LoginResponseDto; + let userToHardDelete: LoginResponseDto; + + beforeAll(async () => { + await utils.resetDatabase(); + admin = await utils.adminSetup({ onboarding: false }); + + [websocket, nonAdmin, deletedUser, userToDelete, userToHardDelete] = await Promise.all([ + utils.connectWebsocket(admin.accessToken), + utils.userSetup(admin.accessToken, createUserDto.user1), + utils.userSetup(admin.accessToken, createUserDto.user2), + utils.userSetup(admin.accessToken, createUserDto.user3), + utils.userSetup(admin.accessToken, createUserDto.user4), + ]); + + await deleteUserAdmin( + { id: deletedUser.userId, userAdminDeleteDto: {} }, + { headers: asBearerAuth(admin.accessToken) }, + ); + }); + + afterAll(() => { + utils.disconnectWebsocket(websocket); + }); + + describe('GET /admin/users', () => { + it('should require authentication', async () => { + const { status, body } = await request(app).get(`/admin/users`); + expect(status).toBe(401); + expect(body).toEqual(errorDto.unauthorized); + }); + + it('should require authorization', async () => { + const { status, body } = await request(app) + .get(`/admin/users`) + .set('Authorization', `Bearer ${nonAdmin.accessToken}`); + expect(status).toBe(403); + expect(body).toEqual(errorDto.forbidden); + }); + + it('should hide deleted users by default', async () => { + const { status, body } = await request(app) + .get(`/admin/users`) + .set('Authorization', `Bearer ${admin.accessToken}`); + expect(status).toBe(200); + expect(body).toHaveLength(4); + expect(body).toEqual( + expect.arrayContaining([ + expect.objectContaining({ email: admin.userEmail }), + expect.objectContaining({ email: nonAdmin.userEmail }), + expect.objectContaining({ email: userToDelete.userEmail }), + expect.objectContaining({ email: userToHardDelete.userEmail }), + ]), + ); + }); + + it('should include deleted users', async () => { + const { status, body } = await request(app) + .get(`/admin/users?withDeleted=true`) + .set('Authorization', `Bearer ${admin.accessToken}`); + + expect(status).toBe(200); + expect(body).toHaveLength(5); + expect(body).toEqual( + expect.arrayContaining([ + expect.objectContaining({ email: admin.userEmail }), + expect.objectContaining({ email: nonAdmin.userEmail }), + expect.objectContaining({ email: userToDelete.userEmail }), + expect.objectContaining({ email: userToHardDelete.userEmail }), + expect.objectContaining({ email: deletedUser.userEmail }), + ]), + ); + }); + }); + + describe('POST /admin/users', () => { + it('should require authentication', async () => { + const { status, body } = await request(app).post(`/admin/users`).send(createUserDto.user1); + expect(status).toBe(401); + expect(body).toEqual(errorDto.unauthorized); + }); + + it('should require authorization', async () => { + const { status, body } = await request(app) + .post(`/admin/users`) + .set('Authorization', `Bearer ${nonAdmin.accessToken}`) + .send(createUserDto.user1); + expect(status).toBe(403); + expect(body).toEqual(errorDto.forbidden); + }); + + for (const key of [ + 'password', + 'email', + 'name', + 'quotaSizeInBytes', + 'shouldChangePassword', + 'memoriesEnabled', + 'notify', + ]) { + it(`should not allow null ${key}`, async () => { + const { status, body } = await request(app) + .post(`/admin/users`) + .set('Authorization', `Bearer ${admin.accessToken}`) + .send({ ...createUserDto.user1, [key]: null }); + expect(status).toBe(400); + expect(body).toEqual(errorDto.badRequest()); + }); + } + + it('should ignore `isAdmin`', async () => { + const { status, body } = await request(app) + .post(`/admin/users`) + .send({ + isAdmin: true, + email: 'user5@immich.cloud', + password: 'password123', + name: 'Immich', + }) + .set('Authorization', `Bearer ${admin.accessToken}`); + expect(body).toMatchObject({ + email: 'user5@immich.cloud', + isAdmin: false, + shouldChangePassword: true, + }); + 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', () => { + it('should require authentication', async () => { + const { status, body } = await request(app).put(`/admin/users/${uuidDto.notFound}`); + expect(status).toBe(401); + expect(body).toEqual(errorDto.unauthorized); + }); + + it('should require authorization', async () => { + const { status, body } = await request(app) + .put(`/admin/users/${uuidDto.notFound}`) + .set('Authorization', `Bearer ${nonAdmin.accessToken}`); + expect(status).toBe(403); + expect(body).toEqual(errorDto.forbidden); + }); + + for (const key of ['password', 'email', 'name', 'shouldChangePassword', 'memoriesEnabled']) { + it(`should not allow null ${key}`, async () => { + const { status, body } = await request(app) + .put(`/admin/users/${uuidDto.notFound}`) + .set('Authorization', `Bearer ${admin.accessToken}`) + .send({ [key]: null }); + expect(status).toBe(400); + expect(body).toEqual(errorDto.badRequest()); + }); + } + + it('should not allow a non-admin to become an admin', async () => { + const { status, body } = await request(app) + .put(`/admin/users/${nonAdmin.userId}`) + .send({ isAdmin: true }) + .set('Authorization', `Bearer ${admin.accessToken}`); + + expect(status).toBe(200); + expect(body).toMatchObject({ isAdmin: false }); + }); + + it('ignores updates to profileImagePath', async () => { + const { status, body } = await request(app) + .put(`/admin/users/${admin.userId}`) + .send({ profileImagePath: 'invalid.jpg' }) + .set('Authorization', `Bearer ${admin.accessToken}`); + + expect(status).toBe(200); + expect(body).toMatchObject({ id: admin.userId, profileImagePath: '' }); + }); + + it('should update first and last name', async () => { + const before = await getUserAdmin({ id: admin.userId }, { headers: asBearerAuth(admin.accessToken) }); + + const { status, body } = await request(app) + .put(`/admin/users/${admin.userId}`) + .send({ name: 'Name' }) + .set('Authorization', `Bearer ${admin.accessToken}`); + + expect(status).toBe(200); + expect(body).toEqual({ + ...before, + updatedAt: expect.any(String), + name: 'Name', + }); + 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}`) + .send({ password: 'super-secret' }) + .set('Authorization', `Bearer ${admin.accessToken}`); + + expect(status).toBe(200); + expect(body).toMatchObject({ email: nonAdmin.userEmail }); + + const token = await login({ loginCredentialDto: { email: nonAdmin.userEmail, password: 'super-secret' } }); + expect(token.accessToken).toBeDefined(); + + const user = await getMyUser({ headers: asBearerAuth(token.accessToken) }); + expect(user).toMatchObject({ email: nonAdmin.userEmail }); + }); + }); + + describe('DELETE /admin/users/:id', () => { + it('should require authentication', async () => { + const { status, body } = await request(app).delete(`/admin/users/${userToDelete.userId}`); + expect(status).toBe(401); + expect(body).toEqual(errorDto.unauthorized); + }); + + it('should require authorization', async () => { + const { status, body } = await request(app) + .delete(`/admin/users/${userToDelete.userId}`) + .set('Authorization', `Bearer ${nonAdmin.accessToken}`); + expect(status).toBe(403); + expect(body).toEqual(errorDto.forbidden); + }); + + it('should delete user', async () => { + const { status, body } = await request(app) + .delete(`/admin/users/${userToDelete.userId}`) + .set('Authorization', `Bearer ${admin.accessToken}`); + + expect(status).toBe(200); + expect(body).toMatchObject({ + id: userToDelete.userId, + updatedAt: expect.any(String), + deletedAt: expect.any(String), + }); + }); + + it('should hard delete a user', async () => { + const { status, body } = await request(app) + .delete(`/admin/users/${userToHardDelete.userId}`) + .send({ force: true }) + .set('Authorization', `Bearer ${admin.accessToken}`); + + expect(status).toBe(200); + expect(body).toMatchObject({ + id: userToHardDelete.userId, + updatedAt: expect.any(String), + deletedAt: expect.any(String), + }); + + await utils.waitForWebsocketEvent({ event: 'userDelete', id: userToHardDelete.userId, timeout: 5000 }); + }); + }); + + describe('POST /admin/users/:id/restore', () => { + it('should require authentication', async () => { + const { status, body } = await request(app).post(`/admin/users/${userToDelete.userId}/restore`); + expect(status).toBe(401); + expect(body).toEqual(errorDto.unauthorized); + }); + + it('should require authorization', async () => { + const { status, body } = await request(app) + .post(`/admin/users/${userToDelete.userId}/restore`) + .set('Authorization', `Bearer ${nonAdmin.accessToken}`); + expect(status).toBe(403); + expect(body).toEqual(errorDto.forbidden); + }); + }); +}); diff --git a/e2e/src/api/specs/user.e2e-spec.ts b/e2e/src/api/specs/user.e2e-spec.ts index 08b2d34ef6..0cc08479d3 100644 --- a/e2e/src/api/specs/user.e2e-spec.ts +++ b/e2e/src/api/specs/user.e2e-spec.ts @@ -1,37 +1,28 @@ -import { LoginResponseDto, deleteUser, getUserById } from '@immich/sdk'; -import { Socket } from 'socket.io-client'; -import { createUserDto, userDto } from 'src/fixtures'; +import { LoginResponseDto, SharedLinkType, deleteUserAdmin, getMyUser, login } from '@immich/sdk'; +import { createUserDto } from 'src/fixtures'; import { errorDto } from 'src/responses'; import { app, asBearerAuth, utils } from 'src/utils'; import request from 'supertest'; -import { afterAll, beforeAll, describe, expect, it } from 'vitest'; +import { beforeAll, describe, expect, it } from 'vitest'; describe('/users', () => { - let websocket: Socket; - let admin: LoginResponseDto; let deletedUser: LoginResponseDto; - let userToDelete: LoginResponseDto; - let userToHardDelete: LoginResponseDto; let nonAdmin: LoginResponseDto; beforeAll(async () => { await utils.resetDatabase(); admin = await utils.adminSetup({ onboarding: false }); - [websocket, deletedUser, nonAdmin, userToDelete, userToHardDelete] = await Promise.all([ - utils.connectWebsocket(admin.accessToken), + [deletedUser, nonAdmin] = await Promise.all([ utils.userSetup(admin.accessToken, createUserDto.user1), utils.userSetup(admin.accessToken, createUserDto.user2), - utils.userSetup(admin.accessToken, createUserDto.user3), - utils.userSetup(admin.accessToken, createUserDto.user4), ]); - await deleteUser({ id: deletedUser.userId, deleteUserDto: {} }, { headers: asBearerAuth(admin.accessToken) }); - }); - - afterAll(() => { - utils.disconnectWebsocket(websocket); + await deleteUserAdmin( + { id: deletedUser.userId, userAdminDeleteDto: {} }, + { headers: asBearerAuth(admin.accessToken) }, + ); }); describe('GET /users', () => { @@ -44,71 +35,14 @@ describe('/users', () => { it('should get users', async () => { const { status, body } = await request(app).get('/users').set('Authorization', `Bearer ${admin.accessToken}`); expect(status).toEqual(200); - expect(body).toHaveLength(5); - expect(body).toEqual( - expect.arrayContaining([ - expect.objectContaining({ email: 'admin@immich.cloud' }), - expect.objectContaining({ email: 'user1@immich.cloud' }), - expect.objectContaining({ email: 'user2@immich.cloud' }), - expect.objectContaining({ email: 'user3@immich.cloud' }), - expect.objectContaining({ email: 'user4@immich.cloud' }), - ]), - ); - }); - - it('should hide deleted users', async () => { - const { status, body } = await request(app) - .get(`/users`) - .query({ isAll: true }) - .set('Authorization', `Bearer ${admin.accessToken}`); - expect(status).toBe(200); - expect(body).toHaveLength(4); + expect(body).toHaveLength(2); expect(body).toEqual( expect.arrayContaining([ expect.objectContaining({ email: 'admin@immich.cloud' }), expect.objectContaining({ email: 'user2@immich.cloud' }), - expect.objectContaining({ email: 'user3@immich.cloud' }), - expect.objectContaining({ email: 'user4@immich.cloud' }), ]), ); }); - - it('should include deleted users', async () => { - const { status, body } = await request(app) - .get(`/users`) - .query({ isAll: false }) - .set('Authorization', `Bearer ${admin.accessToken}`); - - expect(status).toBe(200); - expect(body).toHaveLength(5); - expect(body).toEqual( - expect.arrayContaining([ - expect.objectContaining({ email: 'admin@immich.cloud' }), - expect.objectContaining({ email: 'user1@immich.cloud' }), - expect.objectContaining({ email: 'user2@immich.cloud' }), - expect.objectContaining({ email: 'user3@immich.cloud' }), - expect.objectContaining({ email: 'user4@immich.cloud' }), - ]), - ); - }); - }); - - describe('GET /users/:id', () => { - it('should require authentication', async () => { - const { status } = await request(app).get(`/users/${admin.userId}`); - expect(status).toEqual(401); - }); - - it('should get the user info', async () => { - const { status, body } = await request(app) - .get(`/users/${admin.userId}`) - .set('Authorization', `Bearer ${admin.accessToken}`); - expect(status).toBe(200); - expect(body).toMatchObject({ - id: admin.userId, - email: 'admin@immich.cloud', - }); - }); }); describe('GET /users/me', () => { @@ -118,154 +52,54 @@ describe('/users', () => { expect(body).toEqual(errorDto.unauthorized); }); - it('should get my info', async () => { + it('should not work for shared links', async () => { + const album = await utils.createAlbum(admin.accessToken, { albumName: 'Album' }); + const sharedLink = await utils.createSharedLink(admin.accessToken, { + type: SharedLinkType.Album, + albumId: album.id, + }); + const { status, body } = await request(app).get(`/users/me?key=${sharedLink.key}`); + expect(status).toBe(403); + expect(body).toEqual(errorDto.forbidden); + }); + + it('should get my user', async () => { const { status, body } = await request(app).get(`/users/me`).set('Authorization', `Bearer ${admin.accessToken}`); expect(status).toBe(200); expect(body).toMatchObject({ id: admin.userId, email: 'admin@immich.cloud', + memoriesEnabled: true, + quotaUsageInBytes: 0, }); }); }); - describe('POST /users', () => { + describe('PUT /users/me', () => { it('should require authentication', async () => { - const { status, body } = await request(app).post(`/users`).send(createUserDto.user1); + const { status, body } = await request(app).put(`/users/me`); expect(status).toBe(401); expect(body).toEqual(errorDto.unauthorized); }); - for (const key of Object.keys(createUserDto.user1)) { + for (const key of ['email', 'name', 'memoriesEnabled', 'avatarColor']) { it(`should not allow null ${key}`, async () => { + const dto = { [key]: null }; const { status, body } = await request(app) - .post(`/users`) + .put(`/users/me`) .set('Authorization', `Bearer ${admin.accessToken}`) - .send({ ...createUserDto.user1, [key]: null }); + .send(dto); expect(status).toBe(400); expect(body).toEqual(errorDto.badRequest()); }); } - it('should ignore `isAdmin`', async () => { - const { status, body } = await request(app) - .post(`/users`) - .send({ - isAdmin: true, - email: 'user5@immich.cloud', - password: 'password123', - name: 'Immich', - }) - .set('Authorization', `Bearer ${admin.accessToken}`); - expect(body).toMatchObject({ - email: 'user5@immich.cloud', - isAdmin: false, - shouldChangePassword: true, - }); - expect(status).toBe(201); - }); - - it('should create a user without memories enabled', async () => { - const { status, body } = await request(app) - .post(`/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('DELETE /users/:id', () => { - it('should require authentication', async () => { - const { status, body } = await request(app).delete(`/users/${userToDelete.userId}`); - expect(status).toBe(401); - expect(body).toEqual(errorDto.unauthorized); - }); - - it('should delete user', async () => { - const { status, body } = await request(app) - .delete(`/users/${userToDelete.userId}`) - .set('Authorization', `Bearer ${admin.accessToken}`); - - expect(status).toBe(200); - expect(body).toMatchObject({ - id: userToDelete.userId, - updatedAt: expect.any(String), - deletedAt: expect.any(String), - }); - }); - - it('should hard delete user', async () => { - const { status, body } = await request(app) - .delete(`/users/${userToHardDelete.userId}`) - .send({ force: true }) - .set('Authorization', `Bearer ${admin.accessToken}`); - - expect(status).toBe(200); - expect(body).toMatchObject({ - id: userToHardDelete.userId, - updatedAt: expect.any(String), - deletedAt: expect.any(String), - }); - - await utils.waitForWebsocketEvent({ event: 'userDelete', id: userToHardDelete.userId, timeout: 5000 }); - }); - }); - - describe('PUT /users', () => { - it('should require authentication', async () => { - const { status, body } = await request(app).put(`/users`); - expect(status).toBe(401); - expect(body).toEqual(errorDto.unauthorized); - }); - - for (const key of Object.keys(userDto.admin)) { - it(`should not allow null ${key}`, async () => { - const { status, body } = await request(app) - .put(`/users`) - .set('Authorization', `Bearer ${admin.accessToken}`) - .send({ ...userDto.admin, [key]: null }); - expect(status).toBe(400); - expect(body).toEqual(errorDto.badRequest()); - }); - } - - it('should not allow a non-admin to become an admin', async () => { - const { status, body } = await request(app) - .put(`/users`) - .send({ isAdmin: true, id: nonAdmin.userId }) - .set('Authorization', `Bearer ${admin.accessToken}`); - - expect(status).toBe(400); - expect(body).toEqual(errorDto.alreadyHasAdmin); - }); - - it('ignores updates to profileImagePath', async () => { - const { status, body } = await request(app) - .put(`/users`) - .send({ id: admin.userId, profileImagePath: 'invalid.jpg' }) - .set('Authorization', `Bearer ${admin.accessToken}`); - - expect(status).toBe(200); - expect(body).toMatchObject({ id: admin.userId, profileImagePath: '' }); - }); - it('should update first and last name', async () => { - const before = await getUserById({ id: admin.userId }, { headers: asBearerAuth(admin.accessToken) }); + const before = await getMyUser({ headers: asBearerAuth(admin.accessToken) }); const { status, body } = await request(app) - .put(`/users`) - .send({ - id: admin.userId, - name: 'Name', - }) + .put(`/users/me`) + .send({ name: 'Name' }) .set('Authorization', `Bearer ${admin.accessToken}`); expect(status).toBe(200); @@ -274,17 +108,13 @@ describe('/users', () => { updatedAt: expect.any(String), name: 'Name', }); - expect(before.updatedAt).not.toEqual(body.updatedAt); }); it('should update memories enabled', async () => { - const before = await getUserById({ id: admin.userId }, { headers: asBearerAuth(admin.accessToken) }); + const before = await getMyUser({ headers: asBearerAuth(admin.accessToken) }); const { status, body } = await request(app) - .put(`/users`) - .send({ - id: admin.userId, - memoriesEnabled: false, - }) + .put(`/users/me`) + .send({ memoriesEnabled: false }) .set('Authorization', `Bearer ${admin.accessToken}`); expect(status).toBe(200); @@ -293,7 +123,80 @@ describe('/users', () => { updatedAt: expect.anything(), memoriesEnabled: false, }); - expect(before.updatedAt).not.toEqual(body.updatedAt); + + 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) }); + + expect(user.shouldChangePassword).toBe(true); + + const { status, body } = await request(app) + .put(`/users/me`) + .send({ password: 'super-secret' }) + .set('Authorization', `Bearer ${nonAdmin.accessToken}`); + + expect(status).toBe(200); + expect(body).toMatchObject({ + email: nonAdmin.userEmail, + shouldChangePassword: false, + }); + + const token = await login({ loginCredentialDto: { email: nonAdmin.userEmail, password: 'super-secret' } }); + + expect(token.accessToken).toBeDefined(); + }); + + it('should not allow user to change to a taken email', async () => { + const { status, body } = await request(app) + .put(`/users/me`) + .send({ email: 'admin@immich.cloud' }) + .set('Authorization', `Bearer ${nonAdmin.accessToken}`); + + expect(status).toBe(400); + expect(body).toMatchObject(errorDto.badRequest('Email already in use by another account')); + }); + + it('should update my email', async () => { + const before = await getMyUser({ headers: asBearerAuth(nonAdmin.accessToken) }); + const { status, body } = await request(app) + .put(`/users/me`) + .send({ email: 'non-admin@immich.cloud' }) + .set('Authorization', `Bearer ${nonAdmin.accessToken}`); + + expect(status).toBe(200); + expect(body).toMatchObject({ + ...before, + email: 'non-admin@immich.cloud', + updatedAt: expect.anything(), + }); + }); + }); + + describe('GET /users/:id', () => { + it('should require authentication', async () => { + const { status } = await request(app).get(`/users/${admin.userId}`); + expect(status).toEqual(401); + }); + + it('should get the user', async () => { + const { status, body } = await request(app) + .get(`/users/${admin.userId}`) + .set('Authorization', `Bearer ${admin.accessToken}`); + expect(status).toBe(200); + expect(body).toMatchObject({ + id: admin.userId, + email: 'admin@immich.cloud', + }); + + expect(body).not.toMatchObject({ + shouldChangePassword: expect.anything(), + memoriesEnabled: expect.anything(), + storageLabel: expect.anything(), + }); }); }); }); diff --git a/e2e/src/utils.ts b/e2e/src/utils.ts index 1454135c12..f9bc7a4445 100644 --- a/e2e/src/utils.ts +++ b/e2e/src/utils.ts @@ -5,10 +5,10 @@ import { CreateAlbumDto, CreateAssetDto, CreateLibraryDto, - CreateUserDto, MetadataSearchDto, PersonCreateDto, SharedLinkCreateDto, + UserAdminCreateDto, ValidateLibraryDto, createAlbum, createApiKey, @@ -16,7 +16,7 @@ import { createPartner, createPerson, createSharedLink, - createUser, + createUserAdmin, deleteAssets, getAllJobsStatus, getAssetInfo, @@ -273,8 +273,8 @@ export const utils = { return response; }, - userSetup: async (accessToken: string, dto: CreateUserDto) => { - await createUser({ createUserDto: dto }, { headers: asBearerAuth(accessToken) }); + userSetup: async (accessToken: string, dto: UserAdminCreateDto) => { + await createUserAdmin({ userAdminCreateDto: dto }, { headers: asBearerAuth(accessToken) }); return login({ loginCredentialDto: { email: dto.email, password: dto.password }, }); diff --git a/mobile/lib/entities/user.entity.dart b/mobile/lib/entities/user.entity.dart index d02be2f30a..b6adcf5d87 100644 --- a/mobile/lib/entities/user.entity.dart +++ b/mobile/lib/entities/user.entity.dart @@ -27,7 +27,7 @@ class User { Id get isarId => fastHash(id); - User.fromUserDto(UserResponseDto dto) + User.fromUserDto(UserAdminResponseDto dto) : id = dto.id, updatedAt = dto.updatedAt, email = dto.email, @@ -44,21 +44,21 @@ class User { User.fromPartnerDto(PartnerResponseDto dto) : id = dto.id, - updatedAt = dto.updatedAt, + updatedAt = DateTime.now(), email = dto.email, name = dto.name, isPartnerSharedBy = false, isPartnerSharedWith = false, profileImagePath = dto.profileImagePath, - isAdmin = dto.isAdmin, - memoryEnabled = dto.memoriesEnabled ?? false, + isAdmin = false, + memoryEnabled = false, avatarColor = dto.avatarColor.toAvatarColor(), inTimeline = dto.inTimeline ?? false, - quotaUsageInBytes = dto.quotaUsageInBytes ?? 0, - quotaSizeInBytes = dto.quotaSizeInBytes ?? 0; + quotaUsageInBytes = 0, + quotaSizeInBytes = 0; /// Base user dto used where the complete user object is not required - User.fromSimpleUserDto(UserDto dto) + User.fromSimpleUserDto(UserResponseDto dto) : id = dto.id, email = dto.email, name = dto.name, diff --git a/mobile/lib/providers/authentication.provider.dart b/mobile/lib/providers/authentication.provider.dart index a595d43c86..073ee09db1 100644 --- a/mobile/lib/providers/authentication.provider.dart +++ b/mobile/lib/providers/authentication.provider.dart @@ -138,11 +138,9 @@ class AuthenticationNotifier extends StateNotifier { Future changePassword(String newPassword) async { try { - await _apiService.userApi.updateUser( - UpdateUserDto( - id: state.userId, + await _apiService.userApi.updateMyUser( + UserUpdateMeDto( password: newPassword, - shouldChangePassword: false, ), ); @@ -178,9 +176,9 @@ class AuthenticationNotifier extends StateNotifier { user = offlineUser; retResult = false; } else { - UserResponseDto? userResponseDto; + UserAdminResponseDto? userResponseDto; try { - userResponseDto = await _apiService.userApi.getMyUserInfo(); + userResponseDto = await _apiService.userApi.getMyUser(); } on ApiException catch (error, stackTrace) { _log.severe( "Error getting user information from the server [API EXCEPTION]", diff --git a/mobile/lib/providers/user.provider.dart b/mobile/lib/providers/user.provider.dart index eb2824ec3f..bf052ebbba 100644 --- a/mobile/lib/providers/user.provider.dart +++ b/mobile/lib/providers/user.provider.dart @@ -20,7 +20,7 @@ class CurrentUserProvider extends StateNotifier { refresh() async { try { - final user = await _apiService.userApi.getMyUserInfo(); + final user = await _apiService.userApi.getMyUser(); if (user != null) { Store.put( StoreKey.currentUser, diff --git a/mobile/lib/routing/tab_navigation_observer.dart b/mobile/lib/routing/tab_navigation_observer.dart index f88adbda91..8825e2ef02 100644 --- a/mobile/lib/routing/tab_navigation_observer.dart +++ b/mobile/lib/routing/tab_navigation_observer.dart @@ -57,7 +57,7 @@ class TabNavigationObserver extends AutoRouterObserver { // Update user info try { final userResponseDto = - await ref.read(apiServiceProvider).userApi.getMyUserInfo(); + await ref.read(apiServiceProvider).userApi.getMyUser(); if (userResponseDto == null) { return; diff --git a/mobile/lib/services/user.service.dart b/mobile/lib/services/user.service.dart index 81100f1624..4e88bab12c 100644 --- a/mobile/lib/services/user.service.dart +++ b/mobile/lib/services/user.service.dart @@ -37,10 +37,10 @@ class UserService { this._partnerService, ); - Future?> _getAllUsers({required bool isAll}) async { + Future?> _getAllUsers() async { try { - final dto = await _apiService.userApi.getAllUsers(isAll); - return dto?.map(User.fromUserDto).toList(); + final dto = await _apiService.userApi.searchUsers(); + return dto?.map(User.fromSimpleUserDto).toList(); } catch (e) { _log.warning("Failed get all users", e); return null; @@ -71,7 +71,7 @@ class UserService { } Future?> getUsersFromServer() async { - final List? users = await _getAllUsers(isAll: true); + final List? users = await _getAllUsers(); final List? sharedBy = await _partnerService.getPartners(PartnerDirection.sharedBy); final List? sharedWith = diff --git a/mobile/openapi/README.md b/mobile/openapi/README.md index dbbbdc2fee08b960c01a192bbfdceb8f143f3af4..273585c368c90230e296d94cb9cc25bad0079344 100644 GIT binary patch delta 670 zcmex;g>mT(#tpCRSshbyGxH`pIEYOCXU`><2;ylf)F^0a1^5StXlW_vgN5`dcf{uwDm8PLNfc>8T~Tl^`Q0=Q+*< zTQ2X!K3U62YmyT?+;EY}KtXXN!`)pG4w`J{q=`_eT$EZ|l3$bxG7?DxiWeq#JE?IO zrzRF9XM}=119S)*gg;r#QA+?x$`KYO8W2IS=aI}sb_gP5cuEUW5=&BjD?!GCJ$2rR zjSu2aXpn|ZE^}1iho}Rof?G8Cyc3rak_`c&2(LnH00-jc*G{qAo9FliN=z0{H;@52 z)43=WXsJs{eym1HezLwk%-G3{;lklCPM%9@PAWE42noI16iqG#U4>}4o}kp?g8aN< xY-SsVi)z5tg@V0}>4H3n#ALH{yUCN%{S-kOAVR)qW`MJ=x+c3 delta 369 zcmZ2_gYowj#tpCRCo4LM2qzb%CYGd@7N-_zD%2=wX$ANPPZn^Noowe&0g|75-hqn; zBIDxf;~L^R`HQ0phht7oC{RZ+P=z>iVouIvW@jzl^wbjHN|3N;URpj#hAX!cWNs#i zJJC^i@@Z$$$?=YbEKZf4DL{E?mZZwe6rhawWF{wJ?xNJMFpllApc#Po7g zG`SRX6{1~Ib5cvdmLgOl#5Onh$4bb8twS{|6fQRTLb~nbhZ(wp2>no{lNmFkH+N;u GW&!|GP=Hzh diff --git a/mobile/openapi/lib/api.dart b/mobile/openapi/lib/api.dart index 7e71c9db3ea8258a9f701a1e0acbaa12db736627..d7223a1ecf38cabb8f2f634c24c5aa2c40a5427c 100644 GIT binary patch delta 108 zcmbQ{bJu6XJHE-W{G6L@`JW0+c2&}1Ny*L3n{3D?Kl!{8D|aG@7oU=vlUg!4kVg_E u#sU(X+#scd;3SGEfH;%$lvF3LSF#7O*-Hyj5=&C!CmTvhZGNYu!3+RPqb3&s delta 64 zcmV-G0Kfm;OqNWr?hcbp4hNHx9~=f`Wo%`1Ws{K~9kcEZ?h=z9AqJBIAsv(4Au5xs WA|#U%A{>*@B0rPRBMGxUBOe1DOBU<^ diff --git a/mobile/openapi/lib/api/authentication_api.dart b/mobile/openapi/lib/api/authentication_api.dart index c2aa50e7e7474c6c9fbe8358023eadc2d14be561..cb81867425693a0a6991f1da50d52c3ce71491bb 100644 GIT binary patch delta 75 zcmX?W|Jr_oJu9nYN^WM}W_#8HOc3Ve|LkJSK*7lkd|{h2Id1VmRXB?;!c-Bw*+;68 F6#xp68fyRm delta 43 tcmaEDf7X73J?mx{)&oqF|4E8Xp2!!nxq{;s-{x@fMKF=T%~?{7tN@Hn5f1N0%;BEf)ZdULgPg diff --git a/mobile/openapi/lib/api/user_api.dart b/mobile/openapi/lib/api/user_api.dart index 301169cb9a8ba5996011a4843b04942e58c5af1c..3c1a3ff4e7bebb952c45462a3102e6f6fa936503 100644 GIT binary patch delta 2141 zcma)7O-vI}5Ke0#b{j0%A1JihM@gm7LZzZq3PmCoQAmt}29?B07iiKyyW1E;YVcqr z5e;Ud!5B?UICvq!tOw%-4(h>^-ZW8&G0~GJJ#f?gdAr?Rwt+ppH}huZoA15(c6s*B zvSHO_GRG6?WF}aksXPsz3`0$0+^V0q9nVv7h8ke96NvXJY;eorgnwQlIw>5HBQ7MF znUf5Y8_G;&eX>=~vn@?De7Zs?4Q(G+3R#0$eN(YS{MbZ03#m(9>T=DsM>fZGKi zE-nBsJzl>iRMnRjW_zT=SRs^$#tsKwecT&^pZoX0TSFJSWI4YFx4Gy~!zD*msjC4h zMQJ$}Z4$?pgm6S=6IQHs9(gCnm$=2@C2pH-(8YL3}v@;fo)}W>p6|HAOYp8e7J)JP1T7)8N2KNYa( zzl{^ESZm>6U=ZJJvn_#RKV$!5yB)7^E|bg^`>V58@6`n5!Rt+do!&1O>*=sKqo;sqz*fysVIQBUYf^(A3`y`-B*IhIRs;yqH5Z^b%7 delta 1102 zcmZo)!nmY*gAC_nB~Cv{=c3falGM=R)FPLX{8)u#ICrw3xa8zDoFW?GnI#z>B_#!( zd1?6?D9Vvk12s=BQ1hKU+rpYx!4^qkvYhDW$z9xA@meHn7?fIEke^qa3N**w4oN#s z3n%OQYfgU4!zG2{JcRRs25mOf*JGL-r5&?*J^yUx$p^J_CKq_~O1h-xq$2q^12-jpe5eaD&l?n)j5YMg#c@!bISx)o42TGC}cPjA9f> z9aI@4J`YcBwpZr|YgE^P$Y^SAUaKg`h-RkPi{nuQ=QP`z&@$c3Pkw|SqGD3c6o0HP?+nEX*gg9}N6oU9cn_7gWB zmY>eX;#BFG0*qAg$+brAlXoj{ZQh~7&oud>hA2da-_(E`XdK8kkjl;erncauGkFIi z&*bZdT}WEFC*LvT*nHOrsQsgfGHt zRRSYX0ig(z;=tK!@&O+fPPj5~_Il;E6`zghxeH;e1}^)6iOm=_XCX8pB`;^DS}p)L C&yT=>Ci v`AOtbkTUkrf|SIPR9~nK2)g03L?2`{iiB0ZN5}7O?&9V7#)NyVAd`U?Z delta 113 zcmbPojP;56X&9meXaMydg-P%Oy1@6(P(9;euo~FV~IL k;w(r((!>@D=7MA=Ge?9@-V>PsHVed;h+4T>IGT|M0I2mU+5i9m diff --git a/mobile/openapi/lib/model/activity_response_dto.dart b/mobile/openapi/lib/model/activity_response_dto.dart index d276d19e6cdb40e8c2fe0734f2db5af399e4e127..cd7a4f482fe8c66d008c7c7dbf15226138fc15f0 100644 GIT binary patch delta 35 icmdmBdcbtU3?`1C)Z&8tyyDc&Gnky&5xm=+R|Nq1FAe4Z delta 17 ZcmX?Ly1{hA45rNsn4H-+zvjFu002cQ2T%Y2 diff --git a/mobile/openapi/lib/model/partner_response_dto.dart b/mobile/openapi/lib/model/partner_response_dto.dart index 1efd91c346cacd79a767cbd49d26862e1aa84c26..7c3cf03bd9b44785062f0cfd4093c609ac5ecbe4 100644 GIT binary patch delta 200 zcmeBh*{Hl>JLBY!j4qS2m?S38WfGZO$*jKlJo7|G?%Cr;Ml5t|&zBQ?2)H)JvopB@)TgRKf!TNj_osNY;wvXFmN1(vD(SPCQJRy@E;TnXGe=#AO92SfGgDwJ gkSQ>BK~a8MW=^VSZen_BKw?P-SiIJntCouk0H?n{I{*Lx literal 8712 zcmeHMZExJT5&rI9K_3>kYgAe1`k@GXm3?mP9DGgUAa)8AhOwX}u9tUOX^*60Y;*qa zogpcbBDuC(px+uGUWxNUa^{&AQiq3ohlk|!%k|}}pU*#@e?GlFKO^rh-k%rb?2>%E zye1zm&o18mb064{^QW2$RsMPY!<#*P<@aqT*kaj<#gaGdi1ckQS-oHlTeDUb!#iJZ zI-#->w0WRgx!Ck-A=vLd7a-o!HT>OB0l$g~3Ce}ci&tGM6>Zgm*DxVpawfF6%ud$u zmdQoawfDfLR4wkY|BrinRYRpDpMcgf@tMgD%*W1D zN51V5atJ2)%v3L0NhoRA1Gz~#Zwc2+tk@hxQu+)v5i)F^s1=uU`jsjw-gZqViVV3D zj4D>2svNNf>+dA8HRVlCiq~dTPtV(HzGi5xEUP@N*Srn**KFMh&g6MZ?;2(`jdz4T z%_-|>uT~dXk&WmORTpb|&puJL%8CEpcZy!|f3b`9Z(GIWPM$9kv^>EiS6$!KZvhiH znUoKm$Sw-druytOQgs66cu((G*24X!_D*{|0B{lVMKW>feN6(tYx)^Tpeu;;_-Ocq zIA`neDVBI@5s3!ne!ufJrEPx5+0Qij$6}@m8IXIMqayFu`b&_I)m`XevhjmChx^ zq!c}B0{8@md@j)hmk3-I(ZfG=5GDc;2{6?2VGvUgCS^Ihy!(|^P}3mJ!~rRfiK&ql zZDxqyVxau-hj{{^X6h69{(DeP3`m(CwBf94fMDteoWF0K7Vl0agFvyLUT|`ZqZ74qbjZ zagVoC?b{D@^H<|aaKRSLz^h~ED1VV%3%)hSz$I^KLwmLT#86|v4blWg@kRkgf?+t5rt8yHE z8aK56+&qtjHE=XB=71bNY}IMdN-@&Rx*o?C(eU;2>et8$y!LcF8DV$hcIPp^b90FBH5kWuu&CEV8Sd~Z4_cVmmKkVwGrXz#YfCq%g!BOnEQF~%_w&cwSn(> z>_Eg8CjrkNQzF~MnPI1KVn#nhOnKToF}Kze+s6S-bB8(U>Gg%2(E>x#3sxAiXP4+a zl*@*G?i@Apok^I1h)$r9pThjP(*_|^sL!X~r+1#n$9{2}c3Im`WqcU zdBiIoymt5xE(r;3Yhvz8CE-e%D-%hUqFY;SH~JO@?iH87FvY9K8z?QeP+zeL)yKUD zF74HXy`w#X^2(NQ>!~kHMPEVDk_$XNF3?H5TEJE7s;jXlHUpWX;E7&^u2;5xnbi#i z0I_Xuc;F-nOB%)r+zth0O|#4^DpgY8X69N9GqSa1+V0F?iRNEW=#Jv6v^g3~FBOYv zTq+Ot%|#*VaTV~0tm$SrXwOirAY$ssQ+%6-lCC7>B^}vH`YCl)(n9~h3$e@PHQZ5I zV;sAgO(_c}x2+}53Jygml#T1kvr1xtks;!$kZ6PRtKUe>9ay>y6qDS|QoO_20}Y?;h|r8$~^RNXI5smzhNG z{RWG-CPFj$1fR72NH$xU!`-?m1A*kwrJLW8xviAyaAUK4 z72Cm+b9{rZZNq-h^kz>l@u*fc*PMZWp7AUg>&6KF#@QS2^7 zgin#o;lO=A2FOXc*`VNT{M>iPu$PrEEd~T7zg%5@#LsXxQ|kuTeNO*7%DS7B<8Rs^ zqiYk>?1XKe;R(7oGho^=%MNYU@MN1G`>Hz;`#dxk_BCk}?08c1!|_o8w}fmZ9%#Do U4vQO=OUn_|Lf9DGC_!%hH_)93T>t<8 diff --git a/mobile/openapi/lib/model/create_user_dto.dart b/mobile/openapi/lib/model/user_admin_create_dto.dart similarity index 80% rename from mobile/openapi/lib/model/create_user_dto.dart rename to mobile/openapi/lib/model/user_admin_create_dto.dart index 4b0bdd55da46b4bf9c5d0b782daefec593cb4016..daf8854e019fa2766a2430f1a75cdecfdaf7c2b2 100644 GIT binary patch delta 393 zcmeA-d}+8rosmDZIJL+zB{wtAxhOTUBz3YOqqIJnm`h21tU_j9aYW{V+~Di((!c2?{JhS+@Z4*-Rp BkJA7E delta 331 zcmaE9*l)N&ozcs=C^fMpHMBUj$fYD-p_)rUL0?~AAt<$^v?#AwAyFYOwOk<@MNzCm zW?pegVqS8p9#AzxYI6f4CmRBw9SfpR47=5+#YOachg zfUdJs$eFxf%t53mwYW5=M8Q_U1|dB;NjMWp_vBl`YmvFrM68gx_eJ(0bJvKjL*{0R V*&=hdip3#wmBddVbAO6|002bXd1e3r diff --git a/mobile/openapi/lib/model/delete_user_dto.dart b/mobile/openapi/lib/model/user_admin_delete_dto.dart similarity index 67% rename from mobile/openapi/lib/model/delete_user_dto.dart rename to mobile/openapi/lib/model/user_admin_delete_dto.dart index a758991fa95bebb8e787e37e0f3a1585b79e6827..7778b15775d0ff16aea9bc318196a4f81a404321 100644 GIT binary patch delta 380 zcmX>tu~c${IwOB*acYrcN^WMJOKMJPN$O-nMrnOCF_)73ScS~I;*!L?5aiVu*FHuf-6v=di*M hYv$O8A?D4w4nvHY%N9e-iz^O8Y!lZB46zpO;{ahoj7k6i delta 319 zcmZ1~d0JwFI-{3MYEEiNYG`q4kxNOwLN%9yg1)}KLQrZ+X;EIWLZU)mYPmu*ilSJB z%)H`~#JuEGJ)mlY)aC}pyNp~2{^T6y-N;-smfy%+2iEDx+-I!i$lM&ZTx9MGwg6?mh0~ E0Nku~EC2ui diff --git a/mobile/openapi/lib/model/user_admin_response_dto.dart b/mobile/openapi/lib/model/user_admin_response_dto.dart new file mode 100644 index 0000000000000000000000000000000000000000..3fc8c2e274bff3eff66c0a86b53e271869c39fc7 GIT binary patch literal 8043 zcmbVRZExH*68`RAK^Kee8mFw&`)~-{lighG6z(N)5jzJQ3}Zn{TrXR#xD~lEe5wEY z%?v4$qPVv20>mqEUP#V7^Fr$IaPRPtz5Q}^@#^QZPiLRsUY(t?_vatZ3U+$IK3!a~ zj~A!s@Bh6IY{>aj%{x>6b^gPfJ$#jSt?I;Lsk+5dHsXkF+pQM$LNsD6T2u7za=lTV z$x86%o=bG$c9(lRSC2caCt47D-Vn}C^>)N}ro8{WNBjp~XF zx#|QrqJC>~#2S3ClPK0)HaRI-XGb+{-jZl#Yq3_H6#A^?w~fdOp%Zh;6yKWFc~)f8 zDTLAan%{|Exmo4J|JW+SFXey4dHatCBlJ$5FB-Hw!K7De+tlv>1UQ-2_o~Y-3gBh7 z*=e*<9nA58--@g;x0^cn=kWm0)_XJnc!fnEYJY76*MhamK-gXZ+89`WBRccItYz4We--Rjh!&XISM9cG z(AWFyQnr-`_iC&DCg z{0#Yg{2*6&8R+t{ggoJ8z(b*hbo(%%$r=gpcQ|U}jY#fsKu-XIeCg1DA5nu$^gEz9 z{2;M^9MDrRA>(LlAd!L$B}QXoi5PrdWH308$)OU(vg1QZ3t8l~PYE@^mh?q19{R|` z7}@;G2-6UF>`oX+DSGt|vrTzSQ1q=2c08@9gx0)!z>Wo)kd@F<6Wc7x?(<&90sD(3 zdQgr@0gJFaOk?bXy=F%Nqx)kS!smEc)|oL2%P}Fv;c)hZnpp!vbgb23b4*CFIOZ5( zZ@8kAoLLgF44{}%5z{j|a=09?)gm%CK15~Y&p6L!=Jmh~N=FcN9e`2xVMdvDnrkty zBQ%zi=%onxv7C5R76EPL!~?Rhd9a)i+zLp3zR7?Wi>)HfY2_q=PeBQ-oFHbP z7TB(^oVbT#l&H!{3YTJ(n#xIv2*(pU*fGUJMK<-6lbqv$mr@T<=@;LFgK;#x!2zi6 z0n3ShyqS_0Kl07rt<%8N4`2pa9YcHZi&iao(VhpEvgHl?4Tf0BM%>>Xf235xh~v7O zMoyjHfCTmG(O|;veI1zn(A=mmrkWkV@3~fQ{`UHZ9$;39Trw)-a}N!+Z*x_@cE2jm z5vK7&`_Iku$V3Busx=4X-F~ZH11ZIrvg>*rT7+O4=B3xf3bGD#?3=OI@Y6UrM;sR4 zmdzU>!S+s5Bz!?_LU4Q8vG;_TwCuD1##fjJ-;yo5hzmS!AYn_RFYu?7#5QS2?lexy z7-mQ*Pn##@COxTryz7WNP6kh}FXl`Z7?WPG!k9g~L=VDT8VYmgsEO}vriw%;b0vO? z6W2}~#7wcikn^72`A9zXi{G?Qmth(|^jy;D=t_oC1uo|iPu_U=`=73A7;kH4Z>}_x zM%$ySX3I{koxW>2&BE=@5*Vg<1#}Hr{RT=K_H1GuKtjV43z`RA-LtlEXd69F0bc#afnDs)K!di-UT60Xkx9zUj}M zGZZUWH}&KxzD;9EUlH<>p6n#UlvET*=wEm_ak0FD`vPZdx|gnN1rHj4#dByCH3SOL|HczRb7o)zqCNi3U|hi8?f0s|AK z$$g{^&TL^LDR*p%QR07rszJ_s4UpdV*`iNvHvGTQ z9n)Q>?(rrP`dhg7)1r9nbDPao()g6o@5_fRcfJK=KRf`AIfTp>I^^ggTFj9X?icJLl-z!?gS6*Bdxfkv|3&K#~QZ9b1A`(D@n z4vg-kjKZzWdXpWo{)x229*IvQS-W96&ibXdf;A6t#YdH_s{Y)9JMUcp^FQeU;w!C( zc9kor@uA4}Uyl}Ww-4W`@OzZKl^J*wM!SdJpL2YJuNwW1(EN5+FY_xYn-g#3rDyyL z2D`R`zl#Svw2{2UC@$a$Cxd9z-k^i91tCi*d--}V%?gGvrtsTV&Ks{RD@eKy2d}u{ zz8*h^fyU5yN|ecvpi8tW(RDK<=CNj>Yr@e)W1@9 t;z)hN;a*3rgBu_90rB{ts5?ToI`=dx#{FsrCDd_5^$|7(w=|Gx{|nqGF75yT literal 0 HcmV?d00001 diff --git a/mobile/openapi/lib/model/update_user_dto.dart b/mobile/openapi/lib/model/user_admin_update_dto.dart similarity index 73% rename from mobile/openapi/lib/model/update_user_dto.dart rename to mobile/openapi/lib/model/user_admin_update_dto.dart index caa0600793f5b0f61a52b4c52d88ac6323ea4e61..ecd145248f1feb1e2a907402837176e655905344 100644 GIT binary patch delta 427 zcmeBoJ!Z2(gORT|wa76gH#0A^ASJORb+RF&j2^0xOG$pLLS|lZNn&1dsvehuLMW>I z645is4fKhz)m4&a-V|3WNTS&bWQ%US?EHC xWY?h!RmxeT3+2Nks6`CXSy8M) zW?pegVqS8p9#AzxYH|akfl^UwVQFSjYKlThMrN^IW{M6_i2{^U?3j|9nYVcrV=FU9 za7j^SUb;f&<^^oYjJ!$t`8oCqnNS4_*^(GH_pmKz*}RM`m2t8)cQPm3{>f{&Lp3r} z6l`r3fTWrl*!cXCjMO4MsCKXv)TGbc+ZhcrQ}i+ti!+?_Q&Ke*w80uQkksat=Hw{Y zD;OwP!5lD|kxzK@D&8hWBx|ksbtE!VY*m0hf++)Xpw^`D|7FxwP{=CI&x$nCY(T(w+W E0HNIH)c^nh diff --git a/mobile/openapi/lib/model/user_dto.dart b/mobile/openapi/lib/model/user_dto.dart deleted file mode 100644 index 1c4c4eb0b4e03eb16e32e30d25382c2beb371f7b..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 3626 zcmbVPQE%He5PtWsxB@}#V5+?BsYoZUL6Z#Wns{i_0fP|;j78h*WYHz*7)F}^zPlqO z(X!hF3lLd6-jR3TeRrgW!`^TRr=M@eul_i{KL2=nbAARFmmkg(I2*(D_y(@VXO|cM zyg)V5d|L?PvR~6*UiWCI=9M-wpJ|iNR4GqjU9GJwa#_lytXxu$)pDhcJ7^)wjo8|J zwRX9YU)Rc@c_o(kTnU4}P8y4g8++J%sw?Y6<#JWvhH9pyVRL(Mvr<*k=B2LYsOA=; z%3uGSPZz@2UJti(pl6_0vJxwm;{TgoZ(0g#;j@+I%xQS&0SqzjkJ7D8WkEnCH!um) zx1iXWsZ3EX2nS>XP{#&tp=>Jdg%jqTF11MxOv|NE<)H~x9Fh`ThvX}xDJz#tF_(V{ zw;-LzV+_hWciK7ukwZNH$r)A6fgOnjs)$FlaXjvB$a8P?y)*{XgWKL|T@t`XIBg)y z2IIRga*D9Kg*3;IjlidID#`%?mDDrezWFy$Z8BcIWa!v7MikeLQn5d$V}Ym&@NL?Hau)7Ixt11YMlZt>6Bc2Uo1sFVn%@KyaErNHmsr(Q!W(6| z_eEr089Bpd%YE9cr&#wkr;dIA*x8_ua)OwfdyrLHac8FR6 z`ccaPb~4T?=~P&^m-~Tlg_MneTHUhI8UGit;e;JKZF^=)(6E0*t}bo6-$ zU@2CA6R6|a&2}Y&JEH+vCt&)XO0k_Po-{&o#TG=MCvBK>u~nBzPp;54hvVfy9{9|d8@&;CC3Z`giPFmCvCg;8wvl#SI;!w` z&B?9zgm^+L5lv`QZ0=kgsj}DFoiA5zTQ7yw!Bc4vR@56j&RMmcugT?!m(^Ttseo<< z1PFzeqc8CMKosJYh;`(2DvH80IkNQl)i~~;8LXV9Xa+SfCs4ofDqQyg>bOhR=nxqX z0$zLQK|rmzYu46v*Nm~nU495H=OFXYf3F;|Q-v$#2{gCA94}ya&!qdLKLU5sgi^Du z6P40PNm~a&Iq-peH(_j_@^l3M6Ny7uCf@L0xusKr?LFUU&mUwDO`B4{XmLo5l)=&d zF0@f@fg&mix{e0SU-?;74y_r_In<)-I4#n)&#UN6B!)BvF(N&Z9L#$H&nq-sBWA?z z4*!Q2&rJDq=o8v@RK!oR1H;+PWPm#*_#2=ik|a%?wH22%%<+leDOcPfO85|N7P850 z^HG2_vUt@Hn9DV${Z8T?NLt*Mm@VM_r|~s?QiOu$v$Xl5_>*j#tIg(9c XFkyfPM|k7oAiVE2c9P>23ERnkg~fx% diff --git a/mobile/openapi/lib/model/user_response_dto.dart b/mobile/openapi/lib/model/user_response_dto.dart index 063b3d33b669931209b51844acd053a872f07d4a..41c18998483b9ad929949fa9910669a02721a807 100644 GIT binary patch delta 174 zcmbPew@Y@zI>yN_81*OnFiA|VXA+ql&8$9oBeOhra7j^SUb;eFVs7f>GFHXO25i$N z^RXLEp1>|O`4qd#t(AIwc8h7&p(}BpP!L;7w^vta&}2RUS5+A zmuDC6e%l8&P<$3(5ZMxCHT*uHo;7O8C`GDo`#}Uc3^m(zMkJUc-cZ$(c0bDmz)jTc#F` zXzzhduV}k?^LkOOs8oAB(gQ-O-_o}*->>`uX$@pW@T%(7Lqe{-qO2%W<Mi$JXY(lDwRX@!P3y#}-qu>OWgy(QK%WJSL)a>rN;Ozo?-YZ~m;s12(i#wx10XbHN`DX#@ns2UJoHC@dhB5JmzU8Bht+H{P}I)&>-CEqy8(Kmht z2L)M)2lj=@!m!-4mPs_FQ?EoPVF{H0&FfBSvZPHz?r8M~foOzvqizU7w`8KX8xwQ5 z@3#=NkQ^kTb#2))3xIX+*r1sJ9Pu~#r8;&&Gc@lYB9hQ?cc zLQ>!-lo;-YWPG0kxuIhExiZKm7T{*o!s!Ht0Y%CGu&Wj8N2QoQ8qF8o(C~47(y!I)f2E-bD5rT(4@(4yY|1yFz z#2mS28nTI6y~RXR9vdk7HV3<#mQ_qCD!163K*OaaN`q*xqtjIcLcQA*A% z$*>Hdm{AeaGdgm(9Iw?PGB-X%W#rE|#b)O9zzj-95Ou|03;QrLNp_kvmNyX)%SrT7 zgw$A0JRpkzv~uFHSlAR;P7Klt$b01kTE)J#fENp^B28!IBtcGLa#=Y+xIhK4yPW=5x*mrXF)$7Dn%Be%ybg5ima)z7(>ORs92Vb}%^M-X z_D)kId_iqOaC_OY_k`JK*=YfcuP_h3C0le67kJ!2!j?u~;7=)uZPJk3X`GZX%#c!^ zHc!fJ^rZIjt~15z~wyRc^i*>FTIu_w5^G~n^J^pWzVXL zETvdGRX67N3O6}RV3^`f&kZE>Tc~Q-shOkB16S@$=kD2A!BCm=fVihFw~pa}m|{Tn$GkSWiDr;7lG>62mgDsM4dF zGPAc6m;#*@lQv=oi?jct!g6F5dK#`X=4dLjPApz&X*$@qmo=!zSDYiVrknnFIYY66 z$f+k!@ogGQ`Vx?r^kgR)rZiQ+2>pt85SPnqxE64hIQBE!(h*O{>t}_am<%LrwHS%v z5jo7%7nxWx$??xkyUd^ccgCU<($Z1r#4I5P`WZ1dK5*LWqKQ%J5H&iC8Umy znPc1d)M_-0qR^AmD1>Z*L2SD&moWO2ZqlyHWA8cSnk17PlLkiL^@*ZSXg2b{np>oM zDIV~y5qep;*;A}|>@%9pXObzrN`&4x)qQgt${T9LJ0)BMTmhK6Fz4!{(C6!o-u5aX zNy2^A%xM-6c=v~*f!?Q4n_|l}I|p~cvYlnnIw32MT4W^KP0ZmM(&p|+vhQZ?@4(7V zN(7R_2*ah)L8IFZJ?luLXKvUcw>;xeFw>0{ z{DW=r9A@M#9&zp`yb7XG$AWIb*1{M`*jv;Gu2(RGp@Uzg5+A&>Tp;9{7QCj0J9qqs z1O3QFAGr65hEr)(?7w@3nB3mc)5lq3jSx&1KS#UIB~-q z-Qx`xfvE8D86vgt$jXE{%oG9Q5I)c_?|c7^5MZX@uus9q_z~oeVWX*FT8scne!jZ= zh#x0hE;ik#|G@Y^l=YVj$6t*#1~&<|mxwzt%M&$NcEEN_jvYFo{>e5${j2FU9I0;~ o-0P?jaO0z@9*+-7v?F9|ZBH|$wO`GkAUcky+QG))k_FP}-!MZAi~s-t diff --git a/mobile/openapi/lib/model/user_update_me_dto.dart b/mobile/openapi/lib/model/user_update_me_dto.dart new file mode 100644 index 0000000000000000000000000000000000000000..1b54d4a383320d8e4f8eda1f0c36939046ee8604 GIT binary patch literal 6002 zcmeHLUvJws5P$clxD-L{V5-yYscUu%7o!({oSdC}etU6p4DUaDI!WMo1ZSfQI2|2- zc>m8Hk`d*bf+>^!*8lZYhhD{XB^A#mQe_iS@&VLUt$C60lFxW$68|n{bE(W$4puJM zQfKqpWQu>Ug+lg<&G2{56#h3{X%w#XcJ^FW+OW!GqQDKsL~v!<_14WwQE{D>vbshx zGi6ow>SdNsnbMsOZf8JFK+bu^=Aw^(uREQ*WLm=)&DEEA!3;m;$40_!2jCFT|CyUw zRT>ynd;#N#>=J}kF6Vuuj~06*1Hep$?z0?P)UR4y1?CGuJFjpEJ06YT^2B9Hyo}wuaKyE zQPpKh`|ZKGsB(@9bI^01Bh)!FLRLVQZ-f_;YZ8qI&&#^t2u#5ztS$}Qu(IaRtF@@E zt&nHU%HY{bgM$R7a=~x7N-W7Wued_ci$=VbwL%qgiIQivHZWmj30Ext251!2n`EOc z8kNB;=N5W0)jM<)^brNDC>+@q1yJuD2eca?KQ1F=gdW)x1>-MS_HApzBM9vIguV@w zpNRgsQE1O2u;KCq_s@p=O3LyGqFnR@`_G2mdpV{0C$#@Bw0$MDh`*6om=x9kO!*WA z)G1(fk_ytaH@f=DbL=0mJgNgohv52=oRvMQ7>Qr`@xwg>Iyki;fW z_oGuL*52tpog-OUH{JJ|UUsd5r)>Va(7+U}6F`DAs&@&l$o?0xM;#y1~J^mnb!En4QQw;3amoP7Cz6UEE~rhGSh7=R>=c zu&eF%_VxaaTaKMuMrMFrn@4o58qvc23Pr?L%1+g+m+#Vt7F#9g?f#ut^gFlnp0*PL zPtb1bzudpIr`md2WrsKGDJ12tnUfwp)B&psaFeYDVYC}@G)$D7p*$Ko#&+t#IdFmm zU78ce7)F;Eag;u6u~-P*j1tW>J-~5@PjEi9qphlQtS~yG+LPg=Yeb&mP&}0dB~AAs zE+j`*1sj-6pgPrjY*GBVaSV$P>f^ryj&ec#T9SUwwAqN{o|`JES~Vy}$*E_fi`Ikq z9mJDTpP;gOQp$*f{lJa1B*aG30A_6dH;s{_$pub!jaI`RF(;t9F6IKZ$mcf%P!(_4 zBkFD-h+KGs+a<%6-}-sL-nNTJz-2yMS{5#WjuWn8IV(<4dO*xe!UDHineIp2P-HU+6Pw zPG)nn^dix#utn-MlI4PCY+`NKz27;XOk;O$y#&>6kRfzhH{QrpwCoPi$8p~|d%)>% z_4ux#+;AC5r|Nm22H>Bpl+XlWS&Y#lhuvxje|857QibI*mJ*gtk2&soFXWakbtc&r z!I+`il;iK(GQ2y&TRpnHb6we*`S7b+dIR?94ez7;VF+}0pa%_U<@7CCV3r3AiZG2G z<2SyfR>1N*f26(du`03#DW1$ix!8gvUE{tGW{RIEbm$T=vcU^Sg-lJBr|TfR+oCH& z6xL?q)1XNr584QzA<@&yR=-H#aRG`lLXX+L(|_@sN4msg+^eYoLGVB5J$S5j3rA+? zlX+Ia1x}Q#;>>>>>> e7c8501930a988dfb6c23ce1c48b0beb076a58c2 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 adbae62bbd..2c07072f68 100644 --- a/open-api/typescript-sdk/src/fetch-client.ts +++ b/open-api/typescript-sdk/src/fetch-client.ts @@ -14,7 +14,7 @@ const oazapfts = Oazapfts.runtime(defaults); export const servers = { server1: "/api" }; -export type UserDto = { +export type UserResponseDto = { avatarColor: UserAvatarColor; email: string; id: string; @@ -27,7 +27,7 @@ export type ActivityResponseDto = { createdAt: string; id: string; "type": Type; - user: UserDto; + user: UserResponseDto; }; export type ActivityCreateDto = { albumId: string; @@ -38,7 +38,7 @@ export type ActivityCreateDto = { export type ActivityStatisticsResponseDto = { comments: number; }; -export type UserResponseDto = { +export type UserAdminResponseDto = { avatarColor: UserAvatarColor; createdAt: string; deletedAt: string | null; @@ -56,6 +56,29 @@ export type UserResponseDto = { storageLabel: string | null; updatedAt: string; }; +export type UserAdminCreateDto = { + email: string; + memoriesEnabled?: boolean; + name: string; + notify?: boolean; + password: string; + quotaSizeInBytes?: number | null; + shouldChangePassword?: boolean; + storageLabel?: string | null; +}; +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 AlbumUserResponseDto = { role: AlbumUserRole; user: UserResponseDto; @@ -517,22 +540,11 @@ export type OAuthCallbackDto = { }; export type PartnerResponseDto = { avatarColor: UserAvatarColor; - createdAt: string; - deletedAt: string | null; email: string; id: string; inTimeline?: boolean; - isAdmin: boolean; - memoriesEnabled?: boolean; name: string; - oauthId: string; profileImagePath: string; - quotaSizeInBytes: number | null; - quotaUsageInBytes: number | null; - shouldChangePassword: boolean; - status: UserStatus; - storageLabel: string | null; - updatedAt: string; }; export type UpdatePartnerDto = { inTimeline: boolean; @@ -1060,27 +1072,12 @@ export type TimeBucketResponseDto = { count: number; timeBucket: string; }; -export type CreateUserDto = { - email: string; - memoriesEnabled?: boolean; - name: string; - notify?: boolean; - password: string; - quotaSizeInBytes?: number | null; - shouldChangePassword?: boolean; - storageLabel?: string | null; -}; -export type UpdateUserDto = { +export type UserUpdateMeDto = { avatarColor?: UserAvatarColor; email?: string; - id: string; - isAdmin?: boolean; memoriesEnabled?: boolean; name?: string; password?: string; - quotaSizeInBytes?: number | null; - shouldChangePassword?: boolean; - storageLabel?: string; }; export type CreateProfileImageDto = { file: Blob; @@ -1089,9 +1086,6 @@ export type CreateProfileImageResponseDto = { profileImagePath: string; userId: string; }; -export type DeleteUserDto = { - force?: boolean; -}; export function getActivities({ albumId, assetId, level, $type, userId }: { albumId: string; assetId?: string; @@ -1146,6 +1140,77 @@ export function deleteActivity({ id }: { method: "DELETE" })); } +export function searchUsersAdmin({ withDeleted }: { + withDeleted?: boolean; +}, opts?: Oazapfts.RequestOpts) { + return oazapfts.ok(oazapfts.fetchJson<{ + status: 200; + data: UserAdminResponseDto[]; + }>(`/admin/users${QS.query(QS.explode({ + withDeleted + }))}`, { + ...opts + })); +} +export function createUserAdmin({ userAdminCreateDto }: { + userAdminCreateDto: UserAdminCreateDto; +}, opts?: Oazapfts.RequestOpts) { + return oazapfts.ok(oazapfts.fetchJson<{ + status: 201; + data: UserAdminResponseDto; + }>("/admin/users", oazapfts.json({ + ...opts, + method: "POST", + body: userAdminCreateDto + }))); +} +export function deleteUserAdmin({ id, userAdminDeleteDto }: { + id: string; + userAdminDeleteDto: UserAdminDeleteDto; +}, opts?: Oazapfts.RequestOpts) { + return oazapfts.ok(oazapfts.fetchJson<{ + status: 200; + data: UserAdminResponseDto; + }>(`/admin/users/${encodeURIComponent(id)}`, oazapfts.json({ + ...opts, + method: "DELETE", + body: userAdminDeleteDto + }))); +} +export function getUserAdmin({ id }: { + id: string; +}, opts?: Oazapfts.RequestOpts) { + return oazapfts.ok(oazapfts.fetchJson<{ + status: 200; + data: UserAdminResponseDto; + }>(`/admin/users/${encodeURIComponent(id)}`, { + ...opts + })); +} +export function updateUserAdmin({ id, userAdminUpdateDto }: { + id: string; + userAdminUpdateDto: UserAdminUpdateDto; +}, opts?: Oazapfts.RequestOpts) { + return oazapfts.ok(oazapfts.fetchJson<{ + status: 200; + data: UserAdminResponseDto; + }>(`/admin/users/${encodeURIComponent(id)}`, oazapfts.json({ + ...opts, + method: "PUT", + body: userAdminUpdateDto + }))); +} +export function restoreUserAdmin({ id }: { + id: string; +}, opts?: Oazapfts.RequestOpts) { + return oazapfts.ok(oazapfts.fetchJson<{ + status: 201; + data: UserAdminResponseDto; + }>(`/admin/users/${encodeURIComponent(id)}/restore`, { + ...opts, + method: "POST" + })); +} export function getAllAlbums({ assetId, shared }: { assetId?: string; shared?: boolean; @@ -1589,7 +1654,7 @@ export function signUpAdmin({ signUpDto }: { }, opts?: Oazapfts.RequestOpts) { return oazapfts.ok(oazapfts.fetchJson<{ status: 201; - data: UserResponseDto; + data: UserAdminResponseDto; }>("/auth/admin-sign-up", oazapfts.json({ ...opts, method: "POST", @@ -1601,7 +1666,7 @@ export function changePassword({ changePasswordDto }: { }, opts?: Oazapfts.RequestOpts) { return oazapfts.ok(oazapfts.fetchJson<{ status: 200; - data: UserResponseDto; + data: UserAdminResponseDto; }>("/auth/change-password", oazapfts.json({ ...opts, method: "POST", @@ -1934,7 +1999,7 @@ export function linkOAuthAccount({ oAuthCallbackDto }: { }, opts?: Oazapfts.RequestOpts) { return oazapfts.ok(oazapfts.fetchJson<{ status: 201; - data: UserResponseDto; + data: UserAdminResponseDto; }>("/oauth/link", oazapfts.json({ ...opts, method: "POST", @@ -1949,7 +2014,7 @@ export function redirectOAuthToMobile(opts?: Oazapfts.RequestOpts) { export function unlinkOAuthAccount(opts?: Oazapfts.RequestOpts) { return oazapfts.ok(oazapfts.fetchJson<{ status: 201; - data: UserResponseDto; + data: UserAdminResponseDto; }>("/oauth/unlink", { ...opts, method: "POST" @@ -2687,50 +2752,34 @@ export function restoreAssets({ bulkIdsDto }: { body: bulkIdsDto }))); } -export function getAllUsers({ isAll }: { - isAll: boolean; -}, opts?: Oazapfts.RequestOpts) { +export function searchUsers(opts?: Oazapfts.RequestOpts) { return oazapfts.ok(oazapfts.fetchJson<{ status: 200; data: UserResponseDto[]; - }>(`/users${QS.query(QS.explode({ - isAll - }))}`, { + }>("/users", { ...opts })); } -export function createUser({ createUserDto }: { - createUserDto: CreateUserDto; -}, opts?: Oazapfts.RequestOpts) { - return oazapfts.ok(oazapfts.fetchJson<{ - status: 201; - data: UserResponseDto; - }>("/users", oazapfts.json({ - ...opts, - method: "POST", - body: createUserDto - }))); -} -export function updateUser({ updateUserDto }: { - updateUserDto: UpdateUserDto; -}, opts?: Oazapfts.RequestOpts) { +export function getMyUser(opts?: Oazapfts.RequestOpts) { return oazapfts.ok(oazapfts.fetchJson<{ status: 200; - data: UserResponseDto; - }>("/users", oazapfts.json({ - ...opts, - method: "PUT", - body: updateUserDto - }))); -} -export function getMyUserInfo(opts?: Oazapfts.RequestOpts) { - return oazapfts.ok(oazapfts.fetchJson<{ - status: 200; - data: UserResponseDto; + data: UserAdminResponseDto; }>("/users/me", { ...opts })); } +export function updateMyUser({ userUpdateMeDto }: { + userUpdateMeDto: UserUpdateMeDto; +}, opts?: Oazapfts.RequestOpts) { + return oazapfts.ok(oazapfts.fetchJson<{ + status: 200; + data: UserAdminResponseDto; + }>("/users/me", oazapfts.json({ + ...opts, + method: "PUT", + body: userUpdateMeDto + }))); +} export function deleteProfileImage(opts?: Oazapfts.RequestOpts) { return oazapfts.ok(oazapfts.fetchText("/users/profile-image", { ...opts, @@ -2749,20 +2798,7 @@ export function createProfileImage({ createProfileImageDto }: { body: createProfileImageDto }))); } -export function deleteUser({ id, deleteUserDto }: { - id: string; - deleteUserDto: DeleteUserDto; -}, opts?: Oazapfts.RequestOpts) { - return oazapfts.ok(oazapfts.fetchJson<{ - status: 200; - data: UserResponseDto; - }>(`/users/${encodeURIComponent(id)}`, oazapfts.json({ - ...opts, - method: "DELETE", - body: deleteUserDto - }))); -} -export function getUserById({ id }: { +export function getUser({ id }: { id: string; }, opts?: Oazapfts.RequestOpts) { return oazapfts.ok(oazapfts.fetchJson<{ @@ -2782,17 +2818,6 @@ export function getProfileImage({ id }: { ...opts })); } -export function restoreUser({ id }: { - id: string; -}, opts?: Oazapfts.RequestOpts) { - return oazapfts.ok(oazapfts.fetchJson<{ - status: 201; - data: UserResponseDto; - }>(`/users/${encodeURIComponent(id)}/restore`, { - ...opts, - method: "POST" - })); -} export enum ReactionLevel { Album = "album", Asset = "asset" @@ -2817,15 +2842,15 @@ export enum UserAvatarColor { Gray = "gray", Amber = "amber" } -export enum AlbumUserRole { - Editor = "editor", - Viewer = "viewer" -} export enum UserStatus { Active = "active", Removing = "removing", Deleted = "deleted" } +export enum AlbumUserRole { + Editor = "editor", + Viewer = "viewer" +} export enum TagTypeEnum { Object = "OBJECT", Face = "FACE", diff --git a/server/src/commands/reset-admin-password.command.ts b/server/src/commands/reset-admin-password.command.ts index 32f77109b0..e5dee49837 100644 --- a/server/src/commands/reset-admin-password.command.ts +++ b/server/src/commands/reset-admin-password.command.ts @@ -1,9 +1,9 @@ import { Command, CommandRunner, InquirerService, Question, QuestionSet } from 'nest-commander'; -import { UserResponseDto } from 'src/dtos/user.dto'; +import { UserAdminResponseDto } from 'src/dtos/user.dto'; import { CliService } from 'src/services/cli.service'; const prompt = (inquirer: InquirerService) => { - return function ask(admin: UserResponseDto) { + return function ask(admin: UserAdminResponseDto) { const { id, oauthId, email, name } = admin; console.log(`Found Admin: - ID=${id} diff --git a/server/src/controllers/auth.controller.ts b/server/src/controllers/auth.controller.ts index 40fdf90916..7dcef9df5f 100644 --- a/server/src/controllers/auth.controller.ts +++ b/server/src/controllers/auth.controller.ts @@ -12,7 +12,7 @@ import { SignUpDto, ValidateAccessTokenResponseDto, } from 'src/dtos/auth.dto'; -import { UserResponseDto, mapUser } from 'src/dtos/user.dto'; +import { UserAdminResponseDto } from 'src/dtos/user.dto'; import { Auth, Authenticated, GetLoginDetails } from 'src/middleware/auth.guard'; import { AuthService, LoginDetails } from 'src/services/auth.service'; import { respondWithCookie, respondWithoutCookie } from 'src/utils/response'; @@ -40,7 +40,7 @@ export class AuthController { } @Post('admin-sign-up') - signUpAdmin(@Body() dto: SignUpDto): Promise { + signUpAdmin(@Body() dto: SignUpDto): Promise { return this.service.adminSignUp(dto); } @@ -54,8 +54,8 @@ export class AuthController { @Post('change-password') @HttpCode(HttpStatus.OK) @Authenticated() - changePassword(@Auth() auth: AuthDto, @Body() dto: ChangePasswordDto): Promise { - return this.service.changePassword(auth, dto).then(mapUser); + changePassword(@Auth() auth: AuthDto, @Body() dto: ChangePasswordDto): Promise { + return this.service.changePassword(auth, dto); } @Post('logout') diff --git a/server/src/controllers/index.ts b/server/src/controllers/index.ts index 187ba4b4db..ca454b6a1d 100644 --- a/server/src/controllers/index.ts +++ b/server/src/controllers/index.ts @@ -27,6 +27,7 @@ import { SystemMetadataController } from 'src/controllers/system-metadata.contro import { TagController } from 'src/controllers/tag.controller'; import { TimelineController } from 'src/controllers/timeline.controller'; import { TrashController } from 'src/controllers/trash.controller'; +import { UserAdminController } from 'src/controllers/user-admin.controller'; import { UserController } from 'src/controllers/user.controller'; export const controllers = [ @@ -59,5 +60,6 @@ export const controllers = [ TagController, TimelineController, TrashController, + UserAdminController, UserController, ]; diff --git a/server/src/controllers/oauth.controller.ts b/server/src/controllers/oauth.controller.ts index 3b498c7ddd..764e67d676 100644 --- a/server/src/controllers/oauth.controller.ts +++ b/server/src/controllers/oauth.controller.ts @@ -10,7 +10,7 @@ import { OAuthCallbackDto, OAuthConfigDto, } from 'src/dtos/auth.dto'; -import { UserResponseDto } from 'src/dtos/user.dto'; +import { UserAdminResponseDto } from 'src/dtos/user.dto'; import { Auth, Authenticated, GetLoginDetails } from 'src/middleware/auth.guard'; import { AuthService, LoginDetails } from 'src/services/auth.service'; import { respondWithCookie } from 'src/utils/response'; @@ -53,13 +53,13 @@ export class OAuthController { @Post('link') @Authenticated() - linkOAuthAccount(@Auth() auth: AuthDto, @Body() dto: OAuthCallbackDto): Promise { + linkOAuthAccount(@Auth() auth: AuthDto, @Body() dto: OAuthCallbackDto): Promise { return this.service.link(auth, dto); } @Post('unlink') @Authenticated() - unlinkOAuthAccount(@Auth() auth: AuthDto): Promise { + unlinkOAuthAccount(@Auth() auth: AuthDto): Promise { return this.service.unlink(auth); } } diff --git a/server/src/controllers/user-admin.controller.ts b/server/src/controllers/user-admin.controller.ts new file mode 100644 index 0000000000..4d0b781e81 --- /dev/null +++ b/server/src/controllers/user-admin.controller.ts @@ -0,0 +1,63 @@ +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 { + UserAdminCreateDto, + UserAdminDeleteDto, + UserAdminResponseDto, + UserAdminSearchDto, + UserAdminUpdateDto, +} from 'src/dtos/user.dto'; +import { Auth, Authenticated } from 'src/middleware/auth.guard'; +import { UserAdminService } from 'src/services/user-admin.service'; +import { UUIDParamDto } from 'src/validation'; + +@ApiTags('User') +@Controller('admin/users') +export class UserAdminController { + constructor(private service: UserAdminService) {} + + @Get() + @Authenticated({ admin: true }) + searchUsersAdmin(@Auth() auth: AuthDto, @Query() dto: UserAdminSearchDto): Promise { + return this.service.search(auth, dto); + } + + @Post() + @Authenticated({ admin: true }) + createUserAdmin(@Body() createUserDto: UserAdminCreateDto): Promise { + return this.service.create(createUserDto); + } + + @Get(':id') + @Authenticated({ admin: true }) + getUserAdmin(@Auth() auth: AuthDto, @Param() { id }: UUIDParamDto): Promise { + return this.service.get(auth, id); + } + + @Put(':id') + @Authenticated({ admin: true }) + updateUserAdmin( + @Auth() auth: AuthDto, + @Param() { id }: UUIDParamDto, + @Body() dto: UserAdminUpdateDto, + ): Promise { + return this.service.update(auth, id, dto); + } + + @Delete(':id') + @Authenticated({ admin: true }) + deleteUserAdmin( + @Auth() auth: AuthDto, + @Param() { id }: UUIDParamDto, + @Body() dto: UserAdminDeleteDto, + ): Promise { + return this.service.delete(auth, id, dto); + } + + @Post(':id/restore') + @Authenticated({ admin: true }) + restoreUserAdmin(@Auth() auth: AuthDto, @Param() { id }: UUIDParamDto): Promise { + return this.service.restore(auth, id); + } +} diff --git a/server/src/controllers/user.controller.ts b/server/src/controllers/user.controller.ts index 1b995c5944..f66807b92c 100644 --- a/server/src/controllers/user.controller.ts +++ b/server/src/controllers/user.controller.ts @@ -10,7 +10,6 @@ import { Param, Post, Put, - Query, Res, UploadedFile, UseInterceptors, @@ -19,7 +18,7 @@ import { ApiBody, ApiConsumes, ApiTags } from '@nestjs/swagger'; import { NextFunction, Response } from 'express'; import { AuthDto } from 'src/dtos/auth.dto'; import { CreateProfileImageDto, CreateProfileImageResponseDto } from 'src/dtos/user-profile.dto'; -import { CreateUserDto, DeleteUserDto, UpdateUserDto, UserResponseDto } from 'src/dtos/user.dto'; +import { UserAdminResponseDto, UserResponseDto, UserUpdateMeDto } from 'src/dtos/user.dto'; import { ILoggerRepository } from 'src/interfaces/logger.interface'; import { Auth, Authenticated, FileResponse } from 'src/middleware/auth.guard'; import { FileUploadInterceptor, Route } from 'src/middleware/file-upload.interceptor'; @@ -37,58 +36,28 @@ export class UserController { @Get() @Authenticated() - getAllUsers(@Auth() auth: AuthDto, @Query('isAll') isAll: boolean): Promise { - return this.service.getAll(auth, isAll); - } - - @Post() - @Authenticated({ admin: true }) - createUser(@Body() createUserDto: CreateUserDto): Promise { - return this.service.create(createUserDto); + searchUsers(): Promise { + return this.service.search(); } @Get('me') @Authenticated() - getMyUserInfo(@Auth() auth: AuthDto): Promise { + getMyUser(@Auth() auth: AuthDto): UserAdminResponseDto { return this.service.getMe(auth); } + @Put('me') + @Authenticated() + updateMyUser(@Auth() auth: AuthDto, @Body() dto: UserUpdateMeDto): Promise { + return this.service.updateMe(auth, dto); + } + @Get(':id') @Authenticated() - getUserById(@Param() { id }: UUIDParamDto): Promise { + getUser(@Param() { id }: UUIDParamDto): Promise { return this.service.get(id); } - @Delete('profile-image') - @HttpCode(HttpStatus.NO_CONTENT) - @Authenticated() - deleteProfileImage(@Auth() auth: AuthDto): Promise { - return this.service.deleteProfileImage(auth); - } - - @Delete(':id') - @Authenticated({ admin: true }) - deleteUser( - @Auth() auth: AuthDto, - @Param() { id }: UUIDParamDto, - @Body() dto: DeleteUserDto, - ): Promise { - return this.service.delete(auth, id, dto); - } - - @Post(':id/restore') - @Authenticated({ admin: true }) - restoreUser(@Auth() auth: AuthDto, @Param() { id }: UUIDParamDto): Promise { - return this.service.restore(auth, id); - } - - // TODO: replace with @Put(':id') - @Put() - @Authenticated() - updateUser(@Auth() auth: AuthDto, @Body() updateUserDto: UpdateUserDto): Promise { - return this.service.update(auth, updateUserDto); - } - @UseInterceptors(FileUploadInterceptor) @ApiConsumes('multipart/form-data') @ApiBody({ description: 'A new avatar for the user', type: CreateProfileImageDto }) @@ -101,6 +70,13 @@ export class UserController { return this.service.createProfileImage(auth, fileInfo); } + @Delete('profile-image') + @HttpCode(HttpStatus.NO_CONTENT) + @Authenticated() + deleteProfileImage(@Auth() auth: AuthDto): Promise { + return this.service.deleteProfileImage(auth); + } + @Get(':id/profile-image') @FileResponse() @Authenticated() diff --git a/server/src/cores/user.core.ts b/server/src/cores/user.core.ts index 504687fb18..153463a9cc 100644 --- a/server/src/cores/user.core.ts +++ b/server/src/cores/user.core.ts @@ -1,7 +1,6 @@ -import { BadRequestException, ForbiddenException } from '@nestjs/common'; +import { BadRequestException } from '@nestjs/common'; import sanitize from 'sanitize-filename'; import { SALT_ROUNDS } from 'src/constants'; -import { UserResponseDto } from 'src/dtos/user.dto'; import { UserEntity } from 'src/entities/user.entity'; import { ICryptoRepository } from 'src/interfaces/crypto.interface'; import { IUserRepository } from 'src/interfaces/user.interface'; @@ -26,46 +25,6 @@ export class UserCore { instance = null; } - // TODO: move auth related checks to the service layer - async updateUser(user: UserEntity | UserResponseDto, id: string, dto: Partial): Promise { - if (!user.isAdmin && user.id !== id) { - throw new ForbiddenException('You are not allowed to update this user'); - } - - if (!user.isAdmin) { - // Users can never update the isAdmin property. - delete dto.isAdmin; - delete dto.storageLabel; - } else if (dto.isAdmin && user.id !== id) { - // Admin cannot create another admin. - throw new BadRequestException('The server already has an admin'); - } - - if (dto.email) { - const duplicate = await this.userRepository.getByEmail(dto.email); - if (duplicate && duplicate.id !== id) { - throw new BadRequestException('Email already in use by another account'); - } - } - - if (dto.storageLabel) { - const duplicate = await this.userRepository.getByStorageLabel(dto.storageLabel); - if (duplicate && duplicate.id !== id) { - throw new BadRequestException('Storage label already in use by another account'); - } - } - - if (dto.password) { - dto.password = await this.cryptoRepository.hashBcrypt(dto.password, SALT_ROUNDS); - } - - if (dto.storageLabel === '') { - dto.storageLabel = null; - } - - return this.userRepository.update(id, { ...dto, updatedAt: new Date() }); - } - async createUser(dto: Partial & { email: string }): Promise { const user = await this.userRepository.getByEmail(dto.email); if (user) { diff --git a/server/src/dtos/activity.dto.ts b/server/src/dtos/activity.dto.ts index bd0d400951..4a3de208ff 100644 --- a/server/src/dtos/activity.dto.ts +++ b/server/src/dtos/activity.dto.ts @@ -1,6 +1,6 @@ import { ApiProperty } from '@nestjs/swagger'; import { IsEnum, IsNotEmpty, IsString, ValidateIf } from 'class-validator'; -import { UserDto, mapSimpleUser } from 'src/dtos/user.dto'; +import { UserResponseDto, mapUser } from 'src/dtos/user.dto'; import { ActivityEntity } from 'src/entities/activity.entity'; import { Optional, ValidateUUID } from 'src/validation'; @@ -20,7 +20,7 @@ export class ActivityResponseDto { id!: string; createdAt!: Date; type!: ReactionType; - user!: UserDto; + user!: UserResponseDto; assetId!: string | null; comment?: string | null; } @@ -73,6 +73,6 @@ export function mapActivity(activity: ActivityEntity): ActivityResponseDto { createdAt: activity.createdAt, comment: activity.comment, type: activity.isLiked ? ReactionType.LIKE : ReactionType.COMMENT, - user: mapSimpleUser(activity.user), + user: mapUser(activity.user), }; } diff --git a/server/src/dtos/user.dto.spec.ts b/server/src/dtos/user.dto.spec.ts index 95e625a1a8..683879a310 100644 --- a/server/src/dtos/user.dto.spec.ts +++ b/server/src/dtos/user.dto.spec.ts @@ -1,12 +1,12 @@ import { plainToInstance } from 'class-transformer'; import { validate } from 'class-validator'; -import { CreateUserDto, CreateUserOAuthDto, UpdateUserDto } from 'src/dtos/user.dto'; +import { UserAdminCreateDto, UserUpdateMeDto } from 'src/dtos/user.dto'; describe('update user DTO', () => { it('should allow emails without a tld', async () => { const someEmail = 'test@test'; - const dto = plainToInstance(UpdateUserDto, { + const dto = plainToInstance(UserUpdateMeDto, { email: someEmail, id: '3fe388e4-2078-44d7-b36c-39d9dee3a657', }); @@ -18,22 +18,22 @@ describe('update user DTO', () => { describe('create user DTO', () => { it('validates the email', async () => { - const params: Partial = { + const params: Partial = { email: undefined, password: 'password', name: 'name', }; - let dto: CreateUserDto = plainToInstance(CreateUserDto, params); + let dto: UserAdminCreateDto = plainToInstance(UserAdminCreateDto, params); let errors = await validate(dto); expect(errors).toHaveLength(1); params.email = 'invalid email'; - dto = plainToInstance(CreateUserDto, params); + dto = plainToInstance(UserAdminCreateDto, params); errors = await validate(dto); expect(errors).toHaveLength(1); params.email = 'valid@email.com'; - dto = plainToInstance(CreateUserDto, params); + dto = plainToInstance(UserAdminCreateDto, params); errors = await validate(dto); expect(errors).toHaveLength(0); }); @@ -41,7 +41,7 @@ describe('create user DTO', () => { it('should allow emails without a tld', async () => { const someEmail = 'test@test'; - const dto = plainToInstance(CreateUserDto, { + const dto = plainToInstance(UserAdminCreateDto, { email: someEmail, password: 'some password', name: 'some name', @@ -51,18 +51,3 @@ describe('create user DTO', () => { expect(dto.email).toEqual(someEmail); }); }); - -describe('create user oauth DTO', () => { - it('should allow emails without a tld', async () => { - const someEmail = 'test@test'; - - const dto = plainToInstance(CreateUserOAuthDto, { - email: someEmail, - oauthId: 'some oauth id', - name: 'some name', - }); - const errors = await validate(dto); - expect(errors).toHaveLength(0); - expect(dto.email).toEqual(someEmail); - }); -}); diff --git a/server/src/dtos/user.dto.ts b/server/src/dtos/user.dto.ts index 18b9d07b08..8290df6adb 100644 --- a/server/src/dtos/user.dto.ts +++ b/server/src/dtos/user.dto.ts @@ -1,12 +1,63 @@ import { ApiProperty } from '@nestjs/swagger'; import { Transform } from 'class-transformer'; -import { IsBoolean, IsEmail, IsEnum, IsNotEmpty, IsNumber, IsPositive, IsString, IsUUID } from 'class-validator'; +import { IsBoolean, IsEmail, IsEnum, 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'; import { Optional, ValidateBoolean, toEmail, toSanitized } from 'src/validation'; -export class CreateUserDto { +export class UserUpdateMeDto { + @Optional() + @IsEmail({ require_tld: false }) + @Transform(toEmail) + email?: string; + + // TODO: migrate to the other change password endpoint + @Optional() + @IsNotEmpty() + @IsString() + password?: string; + + @Optional() + @IsString() + @IsNotEmpty() + name?: string; + + @ValidateBoolean({ optional: true }) + memoriesEnabled?: boolean; + + @Optional() + @IsEnum(UserAvatarColor) + @ApiProperty({ enumName: 'UserAvatarColor', enum: UserAvatarColor }) + avatarColor?: UserAvatarColor; +} + +export class UserResponseDto { + id!: string; + name!: string; + email!: string; + profileImagePath!: string; + @IsEnum(UserAvatarColor) + @ApiProperty({ enumName: 'UserAvatarColor', enum: UserAvatarColor }) + avatarColor!: UserAvatarColor; +} + +export const mapUser = (entity: UserEntity): UserResponseDto => { + return { + id: entity.id, + email: entity.email, + name: entity.name, + profileImagePath: entity.profileImagePath, + avatarColor: getPreferences(entity).avatar.color, + }; +}; + +export class UserAdminSearchDto { + @ValidateBoolean({ optional: true }) + withDeleted?: boolean; +} + +export class UserAdminCreateDto { @IsEmail({ require_tld: false }) @Transform(toEmail) email!: string; @@ -41,23 +92,7 @@ export class CreateUserDto { notify?: boolean; } -export class CreateUserOAuthDto { - @IsEmail({ require_tld: false }) - @Transform(({ value }) => value?.toLowerCase()) - email!: string; - - @IsNotEmpty() - oauthId!: string; - - name?: string; -} - -export class DeleteUserDto { - @ValidateBoolean({ optional: true }) - force?: boolean; -} - -export class UpdateUserDto { +export class UserAdminUpdateDto { @Optional() @IsEmail({ require_tld: false }) @Transform(toEmail) @@ -73,18 +108,10 @@ export class UpdateUserDto { @IsNotEmpty() name?: string; - @Optional() + @Optional({ nullable: true }) @IsString() @Transform(toSanitized) - storageLabel?: string; - - @IsNotEmpty() - @IsUUID('4') - @ApiProperty({ format: 'uuid' }) - id!: string; - - @ValidateBoolean({ optional: true }) - isAdmin?: boolean; + storageLabel?: string | null; @ValidateBoolean({ optional: true }) shouldChangePassword?: boolean; @@ -104,17 +131,12 @@ export class UpdateUserDto { quotaSizeInBytes?: number | null; } -export class UserDto { - id!: string; - name!: string; - email!: string; - profileImagePath!: string; - @IsEnum(UserAvatarColor) - @ApiProperty({ enumName: 'UserAvatarColor', enum: UserAvatarColor }) - avatarColor!: UserAvatarColor; +export class UserAdminDeleteDto { + @ValidateBoolean({ optional: true }) + force?: boolean; } -export class UserResponseDto extends UserDto { +export class UserAdminResponseDto extends UserResponseDto { storageLabel!: string | null; shouldChangePassword!: boolean; isAdmin!: boolean; @@ -131,19 +153,9 @@ export class UserResponseDto extends UserDto { status!: string; } -export const mapSimpleUser = (entity: UserEntity): UserDto => { +export function mapUserAdmin(entity: UserEntity): UserAdminResponseDto { return { - id: entity.id, - email: entity.email, - name: entity.name, - profileImagePath: entity.profileImagePath, - avatarColor: getPreferences(entity).avatar.color, - }; -}; - -export function mapUser(entity: UserEntity): UserResponseDto { - return { - ...mapSimpleUser(entity), + ...mapUser(entity), storageLabel: entity.storageLabel, shouldChangePassword: entity.shouldChangePassword, isAdmin: entity.isAdmin, diff --git a/server/src/queries/api.key.repository.sql b/server/src/queries/api.key.repository.sql index fa0431fb0f..ba54a6e67c 100644 --- a/server/src/queries/api.key.repository.sql +++ b/server/src/queries/api.key.repository.sql @@ -22,13 +22,17 @@ FROM "APIKeyEntity__APIKeyEntity_user"."status" AS "APIKeyEntity__APIKeyEntity_user_status", "APIKeyEntity__APIKeyEntity_user"."updatedAt" AS "APIKeyEntity__APIKeyEntity_user_updatedAt", "APIKeyEntity__APIKeyEntity_user"."quotaSizeInBytes" AS "APIKeyEntity__APIKeyEntity_user_quotaSizeInBytes", - "APIKeyEntity__APIKeyEntity_user"."quotaUsageInBytes" AS "APIKeyEntity__APIKeyEntity_user_quotaUsageInBytes" + "APIKeyEntity__APIKeyEntity_user"."quotaUsageInBytes" AS "APIKeyEntity__APIKeyEntity_user_quotaUsageInBytes", + "7f5f7a38bf327bfbbf826778460704c9a50fe6f4"."userId" AS "7f5f7a38bf327bfbbf826778460704c9a50fe6f4_userId", + "7f5f7a38bf327bfbbf826778460704c9a50fe6f4"."key" AS "7f5f7a38bf327bfbbf826778460704c9a50fe6f4_key", + "7f5f7a38bf327bfbbf826778460704c9a50fe6f4"."value" AS "7f5f7a38bf327bfbbf826778460704c9a50fe6f4_value" FROM "api_keys" "APIKeyEntity" LEFT JOIN "users" "APIKeyEntity__APIKeyEntity_user" ON "APIKeyEntity__APIKeyEntity_user"."id" = "APIKeyEntity"."userId" AND ( "APIKeyEntity__APIKeyEntity_user"."deletedAt" IS NULL ) + LEFT JOIN "user_metadata" "7f5f7a38bf327bfbbf826778460704c9a50fe6f4" ON "7f5f7a38bf327bfbbf826778460704c9a50fe6f4"."userId" = "APIKeyEntity__APIKeyEntity_user"."id" WHERE (("APIKeyEntity"."key" = $1)) ) "distinctAlias" diff --git a/server/src/queries/session.repository.sql b/server/src/queries/session.repository.sql index b26b291e8b..17fff94f42 100644 --- a/server/src/queries/session.repository.sql +++ b/server/src/queries/session.repository.sql @@ -38,13 +38,17 @@ FROM "SessionEntity__SessionEntity_user"."status" AS "SessionEntity__SessionEntity_user_status", "SessionEntity__SessionEntity_user"."updatedAt" AS "SessionEntity__SessionEntity_user_updatedAt", "SessionEntity__SessionEntity_user"."quotaSizeInBytes" AS "SessionEntity__SessionEntity_user_quotaSizeInBytes", - "SessionEntity__SessionEntity_user"."quotaUsageInBytes" AS "SessionEntity__SessionEntity_user_quotaUsageInBytes" + "SessionEntity__SessionEntity_user"."quotaUsageInBytes" AS "SessionEntity__SessionEntity_user_quotaUsageInBytes", + "469e6aa7ff79eff78f8441f91ba15bb07d3634dd"."userId" AS "469e6aa7ff79eff78f8441f91ba15bb07d3634dd_userId", + "469e6aa7ff79eff78f8441f91ba15bb07d3634dd"."key" AS "469e6aa7ff79eff78f8441f91ba15bb07d3634dd_key", + "469e6aa7ff79eff78f8441f91ba15bb07d3634dd"."value" AS "469e6aa7ff79eff78f8441f91ba15bb07d3634dd_value" FROM "sessions" "SessionEntity" LEFT JOIN "users" "SessionEntity__SessionEntity_user" ON "SessionEntity__SessionEntity_user"."id" = "SessionEntity"."userId" AND ( "SessionEntity__SessionEntity_user"."deletedAt" IS NULL ) + LEFT JOIN "user_metadata" "469e6aa7ff79eff78f8441f91ba15bb07d3634dd" ON "469e6aa7ff79eff78f8441f91ba15bb07d3634dd"."userId" = "SessionEntity__SessionEntity_user"."id" WHERE (("SessionEntity"."token" = $1)) ) "distinctAlias" diff --git a/server/src/repositories/api-key.repository.ts b/server/src/repositories/api-key.repository.ts index d03d048063..c5cdb80551 100644 --- a/server/src/repositories/api-key.repository.ts +++ b/server/src/repositories/api-key.repository.ts @@ -34,7 +34,9 @@ export class ApiKeyRepository implements IKeyRepository { }, where: { key: hashedToken }, relations: { - user: true, + user: { + metadata: true, + }, }, }); } diff --git a/server/src/repositories/session.repository.ts b/server/src/repositories/session.repository.ts index 97b8750510..a4b55a19d7 100644 --- a/server/src/repositories/session.repository.ts +++ b/server/src/repositories/session.repository.ts @@ -18,7 +18,14 @@ export class SessionRepository implements ISessionRepository { @GenerateSql({ params: [DummyValue.STRING] }) getByToken(token: string): Promise { - return this.repository.findOne({ where: { token }, relations: { user: true } }); + return this.repository.findOne({ + where: { token }, + relations: { + user: { + metadata: true, + }, + }, + }); } getByUserId(userId: string): Promise { diff --git a/server/src/services/auth.service.spec.ts b/server/src/services/auth.service.spec.ts index aef0f04668..f9c3ed08cf 100644 --- a/server/src/services/auth.service.spec.ts +++ b/server/src/services/auth.service.spec.ts @@ -138,6 +138,7 @@ describe('AuthService', () => { email: 'test@immich.com', password: 'hash-password', } as UserEntity); + userMock.update.mockResolvedValue(userStub.user1); await sut.changePassword(auth, dto); diff --git a/server/src/services/auth.service.ts b/server/src/services/auth.service.ts index 5e61cad187..304be49f27 100644 --- a/server/src/services/auth.service.ts +++ b/server/src/services/auth.service.ts @@ -11,7 +11,7 @@ import { DateTime } from 'luxon'; import { IncomingHttpHeaders } from 'node:http'; import { ClientMetadata, Issuer, UserinfoResponse, custom, generators } from 'openid-client'; import { SystemConfig } from 'src/config'; -import { AuthType, LOGIN_URL, MOBILE_REDIRECT } from 'src/constants'; +import { AuthType, LOGIN_URL, MOBILE_REDIRECT, SALT_ROUNDS } from 'src/constants'; import { SystemConfigCore } from 'src/cores/system-config.core'; import { UserCore } from 'src/cores/user.core'; import { @@ -27,7 +27,7 @@ import { SignUpDto, mapLoginResponse, } from 'src/dtos/auth.dto'; -import { UserResponseDto, mapUser } from 'src/dtos/user.dto'; +import { UserAdminResponseDto, mapUserAdmin } from 'src/dtos/user.dto'; import { UserEntity } from 'src/entities/user.entity'; import { IKeyRepository } from 'src/interfaces/api-key.interface'; import { ICryptoRepository } from 'src/interfaces/crypto.interface'; @@ -109,7 +109,7 @@ export class AuthService { }; } - async changePassword(auth: AuthDto, dto: ChangePasswordDto) { + async changePassword(auth: AuthDto, dto: ChangePasswordDto): Promise { const { password, newPassword } = dto; const user = await this.userRepository.getByEmail(auth.user.email, true); if (!user) { @@ -121,10 +121,14 @@ export class AuthService { throw new BadRequestException('Wrong password'); } - return this.userCore.updateUser(auth.user, auth.user.id, { password: newPassword }); + const hashedPassword = await this.cryptoRepository.hashBcrypt(newPassword, SALT_ROUNDS); + + const updatedUser = await this.userRepository.update(user.id, { password: hashedPassword }); + + return mapUserAdmin(updatedUser); } - async adminSignUp(dto: SignUpDto): Promise { + async adminSignUp(dto: SignUpDto): Promise { const adminUser = await this.userRepository.getAdmin(); if (adminUser) { throw new BadRequestException('The server already has an admin'); @@ -138,7 +142,7 @@ export class AuthService { storageLabel: 'admin', }); - return mapUser(admin); + return mapUserAdmin(admin); } async validate(headers: IncomingHttpHeaders, params: Record): Promise { @@ -237,7 +241,7 @@ export class AuthService { return this.createLoginResponse(user, loginDetails); } - async link(auth: AuthDto, dto: OAuthCallbackDto): Promise { + async link(auth: AuthDto, dto: OAuthCallbackDto): Promise { const config = await this.configCore.getConfig(); const { sub: oauthId } = await this.getOAuthProfile(config, dto.url); const duplicate = await this.userRepository.getByOAuthId(oauthId); @@ -245,11 +249,14 @@ export class AuthService { this.logger.warn(`OAuth link account failed: sub is already linked to another user (${duplicate.email}).`); throw new BadRequestException('This OAuth account has already been linked to another user.'); } - return mapUser(await this.userRepository.update(auth.user.id, { oauthId })); + + const user = await this.userRepository.update(auth.user.id, { oauthId }); + return mapUserAdmin(user); } - async unlink(auth: AuthDto): Promise { - return mapUser(await this.userRepository.update(auth.user.id, { oauthId: '' })); + async unlink(auth: AuthDto): Promise { + const user = await this.userRepository.update(auth.user.id, { oauthId: '' }); + return mapUserAdmin(user); } private async getLogoutEndpoint(authType: AuthType): Promise { diff --git a/server/src/services/cli.service.ts b/server/src/services/cli.service.ts index 459dde1888..f676d43e89 100644 --- a/server/src/services/cli.service.ts +++ b/server/src/services/cli.service.ts @@ -1,7 +1,7 @@ import { Inject, Injectable } from '@nestjs/common'; +import { SALT_ROUNDS } from 'src/constants'; import { SystemConfigCore } from 'src/cores/system-config.core'; -import { UserCore } from 'src/cores/user.core'; -import { UserResponseDto, mapUser } from 'src/dtos/user.dto'; +import { UserAdminResponseDto, mapUserAdmin } from 'src/dtos/user.dto'; import { ICryptoRepository } from 'src/interfaces/crypto.interface'; import { ILoggerRepository } from 'src/interfaces/logger.interface'; import { ISystemMetadataRepository } from 'src/interfaces/system-metadata.interface'; @@ -10,7 +10,6 @@ import { IUserRepository } from 'src/interfaces/user.interface'; @Injectable() export class CliService { private configCore: SystemConfigCore; - private userCore: UserCore; constructor( @Inject(ICryptoRepository) private cryptoRepository: ICryptoRepository, @@ -18,26 +17,26 @@ export class CliService { @Inject(IUserRepository) private userRepository: IUserRepository, @Inject(ILoggerRepository) private logger: ILoggerRepository, ) { - this.userCore = UserCore.create(cryptoRepository, userRepository); this.logger.setContext(CliService.name); this.configCore = SystemConfigCore.create(systemMetadataRepository, this.logger); } - async listUsers(): Promise { + async listUsers(): Promise { const users = await this.userRepository.getList({ withDeleted: true }); - return users.map((user) => mapUser(user)); + return users.map((user) => mapUserAdmin(user)); } - async resetAdminPassword(ask: (admin: UserResponseDto) => Promise) { + async resetAdminPassword(ask: (admin: UserAdminResponseDto) => Promise) { const admin = await this.userRepository.getAdmin(); if (!admin) { throw new Error('Admin account does not exist'); } - const providedPassword = await ask(mapUser(admin)); + const providedPassword = await ask(mapUserAdmin(admin)); const password = providedPassword || this.cryptoRepository.newPassword(24); + const hashedPassword = await this.cryptoRepository.hashBcrypt(password, SALT_ROUNDS); - await this.userCore.updateUser(admin, admin.id, { password }); + await this.userRepository.update(admin.id, { password: hashedPassword }); return { admin, password, provided: !!providedPassword }; } diff --git a/server/src/services/index.ts b/server/src/services/index.ts index 5ea16d9e4b..eee0fac126 100644 --- a/server/src/services/index.ts +++ b/server/src/services/index.ts @@ -33,6 +33,7 @@ import { SystemMetadataService } from 'src/services/system-metadata.service'; import { TagService } from 'src/services/tag.service'; import { TimelineService } from 'src/services/timeline.service'; import { TrashService } from 'src/services/trash.service'; +import { UserAdminService } from 'src/services/user-admin.service'; import { UserService } from 'src/services/user.service'; import { VersionService } from 'src/services/version.service'; @@ -73,5 +74,6 @@ export const services = [ TimelineService, TrashService, UserService, + UserAdminService, VersionService, ]; diff --git a/server/src/services/partner.service.spec.ts b/server/src/services/partner.service.spec.ts index 8fe93e7961..043b8ae71a 100644 --- a/server/src/services/partner.service.spec.ts +++ b/server/src/services/partner.service.spec.ts @@ -1,6 +1,4 @@ import { BadRequestException } from '@nestjs/common'; -import { PartnerResponseDto } from 'src/dtos/partner.dto'; -import { UserAvatarColor } from 'src/entities/user-metadata.entity'; import { IAccessRepository } from 'src/interfaces/access.interface'; import { IPartnerRepository, PartnerDirection } from 'src/interfaces/partner.interface'; import { PartnerService } from 'src/services/partner.service'; @@ -9,45 +7,6 @@ import { partnerStub } from 'test/fixtures/partner.stub'; import { newPartnerRepositoryMock } from 'test/repositories/partner.repository.mock'; import { Mocked } from 'vitest'; -const responseDto = { - admin: { - email: 'admin@test.com', - name: 'admin_name', - id: 'admin_id', - isAdmin: true, - oauthId: '', - profileImagePath: '', - shouldChangePassword: false, - storageLabel: 'admin', - createdAt: new Date('2021-01-01'), - deletedAt: null, - updatedAt: new Date('2021-01-01'), - memoriesEnabled: true, - avatarColor: UserAvatarColor.GRAY, - quotaSizeInBytes: null, - inTimeline: true, - quotaUsageInBytes: 0, - }, - user1: { - email: 'immich@test.com', - name: 'immich_name', - id: 'user-id', - isAdmin: false, - oauthId: '', - profileImagePath: '', - shouldChangePassword: false, - storageLabel: null, - createdAt: new Date('2021-01-01'), - deletedAt: null, - updatedAt: new Date('2021-01-01'), - memoriesEnabled: true, - avatarColor: UserAvatarColor.PRIMARY, - inTimeline: true, - quotaSizeInBytes: null, - quotaUsageInBytes: 0, - }, -}; - describe(PartnerService.name, () => { let sut: PartnerService; let partnerMock: Mocked; @@ -65,13 +24,13 @@ describe(PartnerService.name, () => { describe('getAll', () => { it("should return a list of partners with whom I've shared my library", async () => { partnerMock.getAll.mockResolvedValue([partnerStub.adminToUser1, partnerStub.user1ToAdmin1]); - await expect(sut.getAll(authStub.user1, PartnerDirection.SharedBy)).resolves.toEqual([responseDto.admin]); + await expect(sut.getAll(authStub.user1, PartnerDirection.SharedBy)).resolves.toBeDefined(); expect(partnerMock.getAll).toHaveBeenCalledWith(authStub.user1.user.id); }); it('should return a list of partners who have shared their libraries with me', async () => { partnerMock.getAll.mockResolvedValue([partnerStub.adminToUser1, partnerStub.user1ToAdmin1]); - await expect(sut.getAll(authStub.user1, PartnerDirection.SharedWith)).resolves.toEqual([responseDto.admin]); + await expect(sut.getAll(authStub.user1, PartnerDirection.SharedWith)).resolves.toBeDefined(); expect(partnerMock.getAll).toHaveBeenCalledWith(authStub.user1.user.id); }); }); @@ -81,7 +40,7 @@ describe(PartnerService.name, () => { partnerMock.get.mockResolvedValue(null); partnerMock.create.mockResolvedValue(partnerStub.adminToUser1); - await expect(sut.create(authStub.admin, authStub.user1.user.id)).resolves.toEqual(responseDto.user1); + await expect(sut.create(authStub.admin, authStub.user1.user.id)).resolves.toBeDefined(); expect(partnerMock.create).toHaveBeenCalledWith({ sharedById: authStub.admin.user.id, diff --git a/server/src/services/partner.service.ts b/server/src/services/partner.service.ts index 14503cc7fa..e1d4e9738b 100644 --- a/server/src/services/partner.service.ts +++ b/server/src/services/partner.service.ts @@ -25,7 +25,7 @@ export class PartnerService { } const partner = await this.repository.create(partnerId); - return this.mapToPartnerEntity(partner, PartnerDirection.SharedBy); + return this.mapPartner(partner, PartnerDirection.SharedBy); } async remove(auth: AuthDto, sharedWithId: string): Promise { @@ -44,7 +44,7 @@ export class PartnerService { return partners .filter((partner) => partner.sharedBy && partner.sharedWith) // Filter out soft deleted users .filter((partner) => partner[key] === auth.user.id) - .map((partner) => this.mapToPartnerEntity(partner, direction)); + .map((partner) => this.mapPartner(partner, direction)); } async update(auth: AuthDto, sharedById: string, dto: UpdatePartnerDto): Promise { @@ -52,10 +52,10 @@ export class PartnerService { const partnerId: PartnerIds = { sharedById, sharedWithId: auth.user.id }; const entity = await this.repository.update({ ...partnerId, inTimeline: dto.inTimeline }); - return this.mapToPartnerEntity(entity, PartnerDirection.SharedWith); + return this.mapPartner(entity, PartnerDirection.SharedWith); } - private mapToPartnerEntity(partner: PartnerEntity, direction: PartnerDirection): PartnerResponseDto { + private mapPartner(partner: PartnerEntity, direction: PartnerDirection): PartnerResponseDto { // this is opposite to return the non-me user of the "partner" const user = mapUser( direction === PartnerDirection.SharedBy ? partner.sharedWith : partner.sharedBy, diff --git a/server/src/services/user-admin.service.spec.ts b/server/src/services/user-admin.service.spec.ts new file mode 100644 index 0000000000..b7060b1786 --- /dev/null +++ b/server/src/services/user-admin.service.spec.ts @@ -0,0 +1,197 @@ +import { BadRequestException, ForbiddenException } from '@nestjs/common'; +import { mapUserAdmin } from 'src/dtos/user.dto'; +import { UserStatus } from 'src/entities/user.entity'; +import { IAlbumRepository } from 'src/interfaces/album.interface'; +import { ICryptoRepository } from 'src/interfaces/crypto.interface'; +import { IJobRepository, JobName } from 'src/interfaces/job.interface'; +import { ILoggerRepository } from 'src/interfaces/logger.interface'; +import { IUserRepository } from 'src/interfaces/user.interface'; +import { UserAdminService } from 'src/services/user-admin.service'; +import { authStub } from 'test/fixtures/auth.stub'; +import { userStub } from 'test/fixtures/user.stub'; +import { newAlbumRepositoryMock } from 'test/repositories/album.repository.mock'; +import { newCryptoRepositoryMock } from 'test/repositories/crypto.repository.mock'; +import { newJobRepositoryMock } from 'test/repositories/job.repository.mock'; +import { newLoggerRepositoryMock } from 'test/repositories/logger.repository.mock'; +import { newUserRepositoryMock } from 'test/repositories/user.repository.mock'; +import { Mocked, describe } from 'vitest'; + +describe(UserAdminService.name, () => { + let sut: UserAdminService; + let userMock: Mocked; + let cryptoRepositoryMock: Mocked; + + let albumMock: Mocked; + let jobMock: Mocked; + let loggerMock: Mocked; + + beforeEach(() => { + albumMock = newAlbumRepositoryMock(); + cryptoRepositoryMock = newCryptoRepositoryMock(); + jobMock = newJobRepositoryMock(); + userMock = newUserRepositoryMock(); + loggerMock = newLoggerRepositoryMock(); + + sut = new UserAdminService(albumMock, cryptoRepositoryMock, jobMock, userMock, loggerMock); + + userMock.get.mockImplementation((userId) => + Promise.resolve([userStub.admin, userStub.user1].find((user) => user.id === userId) ?? null), + ); + }); + + describe('create', () => { + it('should not create a user if there is no local admin account', async () => { + userMock.getAdmin.mockResolvedValueOnce(null); + + await expect( + sut.create({ + email: 'john_smith@email.com', + name: 'John Smith', + password: 'password', + }), + ).rejects.toBeInstanceOf(BadRequestException); + }); + + it('should create user', async () => { + userMock.getAdmin.mockResolvedValue(userStub.admin); + userMock.create.mockResolvedValue(userStub.user1); + + await expect( + sut.create({ + email: userStub.user1.email, + name: userStub.user1.name, + password: 'password', + storageLabel: 'label', + }), + ).resolves.toEqual(mapUserAdmin(userStub.user1)); + + expect(userMock.getAdmin).toBeCalled(); + expect(userMock.create).toBeCalledWith({ + email: userStub.user1.email, + name: userStub.user1.name, + storageLabel: 'label', + password: expect.anything(), + }); + }); + }); + + describe('update', () => { + it('should update the user', async () => { + const update = { + shouldChangePassword: true, + email: 'immich@test.com', + storageLabel: 'storage_label', + }; + userMock.getByEmail.mockResolvedValue(null); + userMock.getByStorageLabel.mockResolvedValue(null); + userMock.update.mockResolvedValue(userStub.user1); + + await sut.update(authStub.user1, userStub.user1.id, update); + + expect(userMock.getByEmail).toHaveBeenCalledWith(update.email); + expect(userMock.getByStorageLabel).toHaveBeenCalledWith(update.storageLabel); + }); + + it('should not set an empty string for storage label', async () => { + userMock.update.mockResolvedValue(userStub.user1); + await sut.update(authStub.admin, userStub.user1.id, { storageLabel: '' }); + expect(userMock.update).toHaveBeenCalledWith(userStub.user1.id, { + storageLabel: null, + updatedAt: expect.any(Date), + }); + }); + + it('should not change an email to one already in use', async () => { + const dto = { id: userStub.user1.id, email: 'updated@test.com' }; + + userMock.get.mockResolvedValue(userStub.user1); + userMock.getByEmail.mockResolvedValue(userStub.admin); + + await expect(sut.update(authStub.admin, userStub.user1.id, dto)).rejects.toBeInstanceOf(BadRequestException); + + expect(userMock.update).not.toHaveBeenCalled(); + }); + + it('should not let the admin change the storage label to one already in use', async () => { + const dto = { id: userStub.user1.id, storageLabel: 'admin' }; + + userMock.get.mockResolvedValue(userStub.user1); + userMock.getByStorageLabel.mockResolvedValue(userStub.admin); + + await expect(sut.update(authStub.admin, userStub.user1.id, dto)).rejects.toBeInstanceOf(BadRequestException); + + expect(userMock.update).not.toHaveBeenCalled(); + }); + + it('update user information should throw error if user not found', async () => { + userMock.get.mockResolvedValueOnce(null); + + await expect( + sut.update(authStub.admin, userStub.user1.id, { shouldChangePassword: true }), + ).rejects.toBeInstanceOf(BadRequestException); + }); + }); + + describe('delete', () => { + 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); + expect(userMock.delete).not.toHaveBeenCalled(); + }); + + it('cannot delete admin user', async () => { + 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); + + expect(userMock.delete).not.toHaveBeenCalled(); + }); + + it('should delete user', async () => { + userMock.get.mockResolvedValue(userStub.user1); + userMock.update.mockResolvedValue(userStub.user1); + + await expect(sut.delete(authStub.admin, userStub.user1.id, {})).resolves.toEqual(mapUserAdmin(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( + mapUserAdmin(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 }, + }); + }); + }); + + describe('restore', () => { + it('should throw error if user could not be found', async () => { + userMock.get.mockResolvedValue(null); + await expect(sut.restore(authStub.admin, userStub.admin.id)).rejects.toThrowError(BadRequestException); + expect(userMock.update).not.toHaveBeenCalled(); + }); + + it('should restore an user', async () => { + userMock.get.mockResolvedValue(userStub.user1); + userMock.update.mockResolvedValue(userStub.user1); + await expect(sut.restore(authStub.admin, userStub.user1.id)).resolves.toEqual(mapUserAdmin(userStub.user1)); + expect(userMock.update).toHaveBeenCalledWith(userStub.user1.id, { status: UserStatus.ACTIVE, deletedAt: null }); + }); + }); +}); diff --git a/server/src/services/user-admin.service.ts b/server/src/services/user-admin.service.ts new file mode 100644 index 0000000000..1b93f96e71 --- /dev/null +++ b/server/src/services/user-admin.service.ts @@ -0,0 +1,154 @@ +import { BadRequestException, ForbiddenException, Inject, Injectable } from '@nestjs/common'; +import { SALT_ROUNDS } from 'src/constants'; +import { UserCore } from 'src/cores/user.core'; +import { AuthDto } from 'src/dtos/auth.dto'; +import { + UserAdminCreateDto, + UserAdminDeleteDto, + UserAdminResponseDto, + UserAdminSearchDto, + UserAdminUpdateDto, + mapUserAdmin, +} from 'src/dtos/user.dto'; +import { UserMetadataKey } from 'src/entities/user-metadata.entity'; +import { UserStatus } from 'src/entities/user.entity'; +import { IAlbumRepository } from 'src/interfaces/album.interface'; +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'; + +@Injectable() +export class UserAdminService { + private userCore: UserCore; + + constructor( + @Inject(IAlbumRepository) private albumRepository: IAlbumRepository, + @Inject(ICryptoRepository) private cryptoRepository: ICryptoRepository, + @Inject(IJobRepository) private jobRepository: IJobRepository, + @Inject(IUserRepository) private userRepository: IUserRepository, + @Inject(ILoggerRepository) private logger: ILoggerRepository, + ) { + this.userCore = UserCore.create(cryptoRepository, userRepository); + this.logger.setContext(UserAdminService.name); + } + + async search(auth: AuthDto, dto: UserAdminSearchDto): Promise { + const users = await this.userRepository.getList({ withDeleted: dto.withDeleted }); + return users.map((user) => mapUserAdmin(user)); + } + + 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 tempPassword = user.shouldChangePassword ? rest.password : undefined; + if (notify) { + await this.jobRepository.queue({ name: JobName.NOTIFY_SIGNUP, data: { id: user.id, tempPassword } }); + } + return mapUserAdmin(user); + } + + async get(auth: AuthDto, id: string): Promise { + const user = await this.findOrFail(id, { withDeleted: true }); + return mapUserAdmin(user); + } + + async update(auth: AuthDto, id: string, dto: UserAdminUpdateDto): Promise { + const user = await this.findOrFail(id, {}); + + if (dto.quotaSizeInBytes && user.quotaSizeInBytes !== dto.quotaSizeInBytes) { + 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) { + throw new BadRequestException('Email already in use by another account'); + } + } + + if (dto.storageLabel) { + const duplicate = await this.userRepository.getByStorageLabel(dto.storageLabel); + if (duplicate && duplicate.id !== id) { + throw new BadRequestException('Storage label already in use by another account'); + } + } + + if (dto.password) { + dto.password = await this.cryptoRepository.hashBcrypt(dto.password, SALT_ROUNDS); + } + + if (dto.storageLabel === '') { + dto.storageLabel = null; + } + + const updatedUser = await this.userRepository.update(id, { ...dto, updatedAt: new Date() }); + + return mapUserAdmin(updatedUser); + } + + async delete(auth: AuthDto, id: string, dto: UserAdminDeleteDto): Promise { + const { force } = dto; + const { isAdmin } = await this.findOrFail(id, {}); + if (isAdmin) { + throw new ForbiddenException('Cannot delete admin user'); + } + + await this.albumRepository.softDeleteAll(id); + + 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 mapUserAdmin(user); + } + + async restore(auth: AuthDto, id: string): Promise { + await this.findOrFail(id, { withDeleted: true }); + await this.albumRepository.restoreAll(id); + const user = await this.userRepository.update(id, { deletedAt: null, status: UserStatus.ACTIVE }); + return mapUserAdmin(user); + } + + private async findOrFail(id: string, options: UserFindOptions) { + const user = await this.userRepository.get(id, options); + if (!user) { + throw new BadRequestException('User not found'); + } + return user; + } +} diff --git a/server/src/services/user.service.spec.ts b/server/src/services/user.service.spec.ts index 0b0cdb5699..bc4a1e2874 100644 --- a/server/src/services/user.service.spec.ts +++ b/server/src/services/user.service.spec.ts @@ -1,11 +1,5 @@ -import { - BadRequestException, - ForbiddenException, - InternalServerErrorException, - NotFoundException, -} from '@nestjs/common'; -import { UpdateUserDto, mapUser } from 'src/dtos/user.dto'; -import { UserEntity, UserStatus } from 'src/entities/user.entity'; +import { BadRequestException, InternalServerErrorException, NotFoundException } from '@nestjs/common'; +import { UserEntity } from 'src/entities/user.entity'; import { IAlbumRepository } from 'src/interfaces/album.interface'; import { ICryptoRepository } from 'src/interfaces/crypto.interface'; import { IJobRepository, JobName } from 'src/interfaces/job.interface'; @@ -63,13 +57,13 @@ describe(UserService.name, () => { describe('getAll', () => { it('should get all users', async () => { userMock.getList.mockResolvedValue([userStub.admin]); - await expect(sut.getAll(authStub.admin, false)).resolves.toEqual([ + await expect(sut.search()).resolves.toEqual([ expect.objectContaining({ id: authStub.admin.user.id, email: authStub.admin.user.email, }), ]); - expect(userMock.getList).toHaveBeenCalledWith({ withDeleted: true }); + expect(userMock.getList).toHaveBeenCalledWith({ withDeleted: false }); }); }); @@ -82,255 +76,17 @@ describe(UserService.name, () => { it('should throw an error if a user is not found', async () => { userMock.get.mockResolvedValue(null); - await expect(sut.get(authStub.admin.user.id)).rejects.toBeInstanceOf(NotFoundException); + await expect(sut.get(authStub.admin.user.id)).rejects.toBeInstanceOf(BadRequestException); expect(userMock.get).toHaveBeenCalledWith(authStub.admin.user.id, { withDeleted: false }); }); }); describe('getMe', () => { - it("should get the auth user's info", async () => { - userMock.get.mockResolvedValue(userStub.admin); - await sut.getMe(authStub.admin); - expect(userMock.get).toHaveBeenCalledWith(authStub.admin.user.id, {}); - }); - - it('should throw an error if a user is not found', async () => { - userMock.get.mockResolvedValue(null); - await expect(sut.getMe(authStub.admin)).rejects.toBeInstanceOf(BadRequestException); - expect(userMock.get).toHaveBeenCalledWith(authStub.admin.user.id, {}); - }); - }); - - describe('update', () => { - it('should update user', async () => { - const update: UpdateUserDto = { - id: userStub.user1.id, - shouldChangePassword: true, - email: 'immich@test.com', - storageLabel: 'storage_label', - }; - userMock.getByEmail.mockResolvedValue(null); - userMock.getByStorageLabel.mockResolvedValue(null); - userMock.update.mockResolvedValue(userStub.user1); - - await sut.update({ user: { ...authStub.user1.user, isAdmin: true } }, update); - - expect(userMock.getByEmail).toHaveBeenCalledWith(update.email); - expect(userMock.getByStorageLabel).toHaveBeenCalledWith(update.storageLabel); - }); - - it('should not set an empty string for storage label', async () => { - userMock.update.mockResolvedValue(userStub.user1); - await sut.update(authStub.admin, { id: userStub.user1.id, storageLabel: '' }); - expect(userMock.update).toHaveBeenCalledWith(userStub.user1.id, { - id: userStub.user1.id, - storageLabel: null, - updatedAt: expect.any(Date), - }); - }); - - it('should omit a storage label set by non-admin users', async () => { - userMock.update.mockResolvedValue(userStub.user1); - await sut.update({ user: userStub.user1 }, { id: userStub.user1.id, storageLabel: 'admin' }); - expect(userMock.update).toHaveBeenCalledWith(userStub.user1.id, { - id: userStub.user1.id, - updatedAt: expect.any(Date), - }); - }); - - it('user can only update its information', async () => { - userMock.get.mockResolvedValueOnce({ - ...userStub.user1, - id: 'not_immich_auth_user_id', - }); - - const result = sut.update( - { user: userStub.user1 }, - { - id: 'not_immich_auth_user_id', - password: 'I take over your account now', - }, - ); - await expect(result).rejects.toBeInstanceOf(ForbiddenException); - }); - - it('should let a user change their email', async () => { - const dto = { id: userStub.user1.id, email: 'updated@test.com' }; - - userMock.get.mockResolvedValue(userStub.user1); - userMock.update.mockResolvedValue(userStub.user1); - - await sut.update({ user: userStub.user1 }, dto); - - expect(userMock.update).toHaveBeenCalledWith(userStub.user1.id, { - id: 'user-id', - email: 'updated@test.com', - updatedAt: expect.any(Date), - }); - }); - - it('should not let a user change their email to one already in use', async () => { - const dto = { id: userStub.user1.id, email: 'updated@test.com' }; - - userMock.get.mockResolvedValue(userStub.user1); - userMock.getByEmail.mockResolvedValue(userStub.admin); - - await expect(sut.update({ user: userStub.user1 }, dto)).rejects.toBeInstanceOf(BadRequestException); - - expect(userMock.update).not.toHaveBeenCalled(); - }); - - it('should not let the admin change the storage label to one already in use', async () => { - const dto = { id: userStub.user1.id, storageLabel: 'admin' }; - - userMock.get.mockResolvedValue(userStub.user1); - userMock.getByStorageLabel.mockResolvedValue(userStub.admin); - - await expect(sut.update(authStub.admin, dto)).rejects.toBeInstanceOf(BadRequestException); - - expect(userMock.update).not.toHaveBeenCalled(); - }); - - it('admin can update any user information', async () => { - const update: UpdateUserDto = { - id: userStub.user1.id, - shouldChangePassword: true, - }; - - userMock.update.mockResolvedValueOnce(userStub.user1); - await sut.update(authStub.admin, update); - expect(userMock.update).toHaveBeenCalledWith(userStub.user1.id, { - id: 'user-id', - shouldChangePassword: true, - updatedAt: expect.any(Date), - }); - }); - - it('update user information should throw error if user not found', async () => { - userMock.get.mockResolvedValueOnce(null); - - const result = sut.update(authStub.admin, { - id: userStub.user1.id, - shouldChangePassword: true, - }); - - await expect(result).rejects.toBeInstanceOf(BadRequestException); - }); - - it('should let the admin update himself', async () => { - const dto = { id: userStub.admin.id, shouldChangePassword: true, isAdmin: true }; - - userMock.update.mockResolvedValueOnce(userStub.admin); - - await sut.update(authStub.admin, dto); - - expect(userMock.update).toHaveBeenCalledWith(userStub.admin.id, { ...dto, updatedAt: expect.any(Date) }); - }); - - it('should not let the another user become an admin', async () => { - const dto = { id: userStub.user1.id, shouldChangePassword: true, isAdmin: true }; - - userMock.get.mockResolvedValueOnce(userStub.user1); - - await expect(sut.update(authStub.admin, dto)).rejects.toBeInstanceOf(BadRequestException); - }); - }); - - describe('restore', () => { - it('should throw error if user could not be found', async () => { - userMock.get.mockResolvedValue(null); - await expect(sut.restore(authStub.admin, userStub.admin.id)).rejects.toThrowError(BadRequestException); - expect(userMock.update).not.toHaveBeenCalled(); - }); - - it('should restore an user', async () => { - userMock.get.mockResolvedValue(userStub.user1); - userMock.update.mockResolvedValue(userStub.user1); - await expect(sut.restore(authStub.admin, userStub.user1.id)).resolves.toEqual(mapUser(userStub.user1)); - expect(userMock.update).toHaveBeenCalledWith(userStub.user1.id, { status: UserStatus.ACTIVE, deletedAt: null }); - }); - }); - - describe('delete', () => { - 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); - expect(userMock.delete).not.toHaveBeenCalled(); - }); - - it('cannot delete admin user', async () => { - 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); - - expect(userMock.delete).not.toHaveBeenCalled(); - }); - - it('should delete user', async () => { - userMock.get.mockResolvedValue(userStub.user1); - userMock.update.mockResolvedValue(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 }, - }); - }); - }); - - describe('create', () => { - it('should not create a user if there is no local admin account', async () => { - userMock.getAdmin.mockResolvedValueOnce(null); - - await expect( - sut.create({ - email: 'john_smith@email.com', - name: 'John Smith', - password: 'password', - }), - ).rejects.toBeInstanceOf(BadRequestException); - }); - - it('should create user', async () => { - userMock.getAdmin.mockResolvedValue(userStub.admin); - userMock.create.mockResolvedValue(userStub.user1); - - await expect( - sut.create({ - email: userStub.user1.email, - name: userStub.user1.name, - password: 'password', - storageLabel: 'label', - }), - ).resolves.toEqual(mapUser(userStub.user1)); - - expect(userMock.getAdmin).toBeCalled(); - expect(userMock.create).toBeCalledWith({ - email: userStub.user1.email, - name: userStub.user1.name, - storageLabel: 'label', - password: expect.anything(), + it("should get the auth user's info", () => { + const user = authStub.admin.user; + expect(sut.getMe(authStub.admin)).toMatchObject({ + id: user.id, + email: user.email, }); }); }); diff --git a/server/src/services/user.service.ts b/server/src/services/user.service.ts index bb3313e4a9..1f36501051 100644 --- a/server/src/services/user.service.ts +++ b/server/src/services/user.service.ts @@ -1,13 +1,13 @@ -import { BadRequestException, ForbiddenException, Inject, Injectable, NotFoundException } from '@nestjs/common'; +import { BadRequestException, Inject, Injectable, NotFoundException } from '@nestjs/common'; import { DateTime } from 'luxon'; +import { SALT_ROUNDS } from 'src/constants'; import { StorageCore, StorageFolder } from 'src/cores/storage.core'; import { SystemConfigCore } from 'src/cores/system-config.core'; -import { UserCore } from 'src/cores/user.core'; import { AuthDto } from 'src/dtos/auth.dto'; import { CreateProfileImageResponseDto, mapCreateProfileImageResponse } from 'src/dtos/user-profile.dto'; -import { CreateUserDto, DeleteUserDto, UpdateUserDto, UserResponseDto, mapUser } from 'src/dtos/user.dto'; +import { UserAdminResponseDto, UserResponseDto, UserUpdateMeDto, mapUser, mapUserAdmin } from 'src/dtos/user.dto'; import { UserMetadataKey } from 'src/entities/user-metadata.entity'; -import { UserEntity, UserStatus } from 'src/entities/user.entity'; +import { UserEntity } from 'src/entities/user.entity'; import { IAlbumRepository } from 'src/interfaces/album.interface'; import { ICryptoRepository } from 'src/interfaces/crypto.interface'; import { IEntityJob, IJobRepository, JobName, JobStatus } from 'src/interfaces/job.interface'; @@ -21,73 +21,30 @@ import { getPreferences, getPreferencesPartial } from 'src/utils/preferences'; @Injectable() export class UserService { private configCore: SystemConfigCore; - private userCore: UserCore; constructor( @Inject(IAlbumRepository) private albumRepository: IAlbumRepository, - @Inject(ICryptoRepository) cryptoRepository: ICryptoRepository, + @Inject(ICryptoRepository) private cryptoRepository: ICryptoRepository, @Inject(IJobRepository) private jobRepository: IJobRepository, @Inject(IStorageRepository) private storageRepository: IStorageRepository, @Inject(ISystemMetadataRepository) systemMetadataRepository: ISystemMetadataRepository, @Inject(IUserRepository) private userRepository: IUserRepository, @Inject(ILoggerRepository) private logger: ILoggerRepository, ) { - this.userCore = UserCore.create(cryptoRepository, userRepository); this.logger.setContext(UserService.name); this.configCore = SystemConfigCore.create(systemMetadataRepository, this.logger); } - async listUsers(): Promise { - const users = await this.userRepository.getList({ withDeleted: true }); + async search(): Promise { + const users = await this.userRepository.getList({ withDeleted: false }); return users.map((user) => mapUser(user)); } - async getAll(auth: AuthDto, isAll: boolean): Promise { - const users = await this.userRepository.getList({ withDeleted: !isAll }); - return users.map((user) => mapUser(user)); + getMe(auth: AuthDto): UserAdminResponseDto { + return mapUserAdmin(auth.user); } - async get(userId: string): Promise { - const user = await this.userRepository.get(userId, { withDeleted: false }); - if (!user) { - throw new NotFoundException('User not found'); - } - - return mapUser(user); - } - - getMe(auth: AuthDto): Promise { - return this.findOrFail(auth.user.id, {}).then(mapUser); - } - - async create(dto: CreateUserDto): 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 tempPassword = user.shouldChangePassword ? rest.password : undefined; - if (notify) { - await this.jobRepository.queue({ name: JobName.NOTIFY_SIGNUP, data: { id: user.id, tempPassword } }); - } - return mapUser(user); - } - - async update(auth: AuthDto, dto: UpdateUserDto): Promise { - const user = await this.findOrFail(dto.id, {}); - - if (dto.quotaSizeInBytes && user.quotaSizeInBytes !== dto.quotaSizeInBytes) { - await this.userRepository.syncUsage(dto.id); - } - + async updateMe({ user }: AuthDto, dto: UserUpdateMeDto): Promise { // TODO replace with entire preferences object if (dto.memoriesEnabled !== undefined || dto.avatarColor) { const newPreferences = getPreferences(user); @@ -101,42 +58,40 @@ export class UserService { delete dto.avatarColor; } - await this.userRepository.upsertMetadata(dto.id, { + await this.userRepository.upsertMetadata(user.id, { key: UserMetadataKey.PREFERENCES, value: getPreferencesPartial(user, newPreferences), }); } - const updatedUser = await this.userCore.updateUser(auth.user, dto.id, dto); + if (dto.email) { + const duplicate = await this.userRepository.getByEmail(dto.email); + if (duplicate && duplicate.id !== user.id) { + throw new BadRequestException('Email already in use by another account'); + } + } - return mapUser(updatedUser); + const update: Partial = { + email: dto.email, + name: dto.name, + }; + + if (dto.password) { + const hashedPassword = await this.cryptoRepository.hashBcrypt(dto.password, SALT_ROUNDS); + update.password = hashedPassword; + update.shouldChangePassword = false; + } + + const updatedUser = await this.userRepository.update(user.id, update); + + return mapUserAdmin(updatedUser); } - 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); - - 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 } }); - } - + async get(id: string): Promise { + const user = await this.findOrFail(id, { withDeleted: false }); return mapUser(user); } - async restore(auth: AuthDto, id: string): Promise { - await this.findOrFail(id, { withDeleted: true }); - await this.albumRepository.restoreAll(id); - return this.userRepository.update(id, { deletedAt: null, status: UserStatus.ACTIVE }).then(mapUser); - } - async createProfileImage(auth: AuthDto, fileInfo: Express.Multer.File): Promise { const { profileImagePath: oldpath } = await this.findOrFail(auth.user.id, { withDeleted: false }); const updatedUser = await this.userRepository.update(auth.user.id, { profileImagePath: fileInfo.path }); diff --git a/server/src/validation.ts b/server/src/validation.ts index bc1dbae819..6fb1684c06 100644 --- a/server/src/validation.ts +++ b/server/src/validation.ts @@ -154,7 +154,7 @@ export function validateCronExpression(expression: string) { type IValue = { value: string }; -export const toEmail = ({ value }: IValue) => value?.toLowerCase(); +export const toEmail = ({ value }: IValue) => (value ? value.toLowerCase() : value); export const toSanitized = ({ value }: IValue) => sanitize((value || '').replaceAll('.', '')); diff --git a/web/src/lib/components/admin-page/delete-confirm-dialogue.svelte b/web/src/lib/components/admin-page/delete-confirm-dialogue.svelte index cda78daa28..94112a70ac 100644 --- a/web/src/lib/components/admin-page/delete-confirm-dialogue.svelte +++ b/web/src/lib/components/admin-page/delete-confirm-dialogue.svelte @@ -1,7 +1,7 @@