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 dbbbdc2fee..273585c368 100644 --- a/mobile/openapi/README.md +++ b/mobile/openapi/README.md @@ -212,15 +212,18 @@ Class | Method | HTTP request | Description *TrashApi* | [**restoreAssets**](doc//TrashApi.md#restoreassets) | **POST** /trash/restore/assets | *TrashApi* | [**restoreTrash**](doc//TrashApi.md#restoretrash) | **POST** /trash/restore | *UserApi* | [**createProfileImage**](doc//UserApi.md#createprofileimage) | **POST** /users/profile-image | -*UserApi* | [**createUser**](doc//UserApi.md#createuser) | **POST** /users | +*UserApi* | [**createUserAdmin**](doc//UserApi.md#createuseradmin) | **POST** /admin/users | *UserApi* | [**deleteProfileImage**](doc//UserApi.md#deleteprofileimage) | **DELETE** /users/profile-image | -*UserApi* | [**deleteUser**](doc//UserApi.md#deleteuser) | **DELETE** /users/{id} | -*UserApi* | [**getAllUsers**](doc//UserApi.md#getallusers) | **GET** /users | -*UserApi* | [**getMyUserInfo**](doc//UserApi.md#getmyuserinfo) | **GET** /users/me | +*UserApi* | [**deleteUserAdmin**](doc//UserApi.md#deleteuseradmin) | **DELETE** /admin/users/{id} | +*UserApi* | [**getMyUser**](doc//UserApi.md#getmyuser) | **GET** /users/me | *UserApi* | [**getProfileImage**](doc//UserApi.md#getprofileimage) | **GET** /users/{id}/profile-image | -*UserApi* | [**getUserById**](doc//UserApi.md#getuserbyid) | **GET** /users/{id} | -*UserApi* | [**restoreUser**](doc//UserApi.md#restoreuser) | **POST** /users/{id}/restore | -*UserApi* | [**updateUser**](doc//UserApi.md#updateuser) | **PUT** /users | +*UserApi* | [**getUser**](doc//UserApi.md#getuser) | **GET** /users/{id} | +*UserApi* | [**getUserAdmin**](doc//UserApi.md#getuseradmin) | **GET** /admin/users/{id} | +*UserApi* | [**restoreUserAdmin**](doc//UserApi.md#restoreuseradmin) | **POST** /admin/users/{id}/restore | +*UserApi* | [**searchUsers**](doc//UserApi.md#searchusers) | **GET** /users | +*UserApi* | [**searchUsersAdmin**](doc//UserApi.md#searchusersadmin) | **GET** /admin/users | +*UserApi* | [**updateMyUser**](doc//UserApi.md#updatemyuser) | **PUT** /users/me | +*UserApi* | [**updateUserAdmin**](doc//UserApi.md#updateuseradmin) | **PUT** /admin/users/{id} | ## Documentation For Models @@ -280,8 +283,6 @@ Class | Method | HTTP request | Description - [CreateLibraryDto](doc//CreateLibraryDto.md) - [CreateProfileImageResponseDto](doc//CreateProfileImageResponseDto.md) - [CreateTagDto](doc//CreateTagDto.md) - - [CreateUserDto](doc//CreateUserDto.md) - - [DeleteUserDto](doc//DeleteUserDto.md) - [DownloadArchiveInfo](doc//DownloadArchiveInfo.md) - [DownloadInfoDto](doc//DownloadInfoDto.md) - [DownloadResponseDto](doc//DownloadResponseDto.md) @@ -402,12 +403,15 @@ Class | Method | HTTP request | Description - [UpdatePartnerDto](doc//UpdatePartnerDto.md) - [UpdateStackParentDto](doc//UpdateStackParentDto.md) - [UpdateTagDto](doc//UpdateTagDto.md) - - [UpdateUserDto](doc//UpdateUserDto.md) - [UsageByUserDto](doc//UsageByUserDto.md) + - [UserAdminCreateDto](doc//UserAdminCreateDto.md) + - [UserAdminDeleteDto](doc//UserAdminDeleteDto.md) + - [UserAdminResponseDto](doc//UserAdminResponseDto.md) + - [UserAdminUpdateDto](doc//UserAdminUpdateDto.md) - [UserAvatarColor](doc//UserAvatarColor.md) - - [UserDto](doc//UserDto.md) - [UserResponseDto](doc//UserResponseDto.md) - [UserStatus](doc//UserStatus.md) + - [UserUpdateMeDto](doc//UserUpdateMeDto.md) - [ValidateAccessTokenResponseDto](doc//ValidateAccessTokenResponseDto.md) - [ValidateLibraryDto](doc//ValidateLibraryDto.md) - [ValidateLibraryImportPathResponseDto](doc//ValidateLibraryImportPathResponseDto.md) diff --git a/mobile/openapi/lib/api.dart b/mobile/openapi/lib/api.dart index 7e71c9db3e..d7223a1ecf 100644 --- a/mobile/openapi/lib/api.dart +++ b/mobile/openapi/lib/api.dart @@ -112,8 +112,6 @@ part 'model/create_album_dto.dart'; part 'model/create_library_dto.dart'; part 'model/create_profile_image_response_dto.dart'; part 'model/create_tag_dto.dart'; -part 'model/create_user_dto.dart'; -part 'model/delete_user_dto.dart'; part 'model/download_archive_info.dart'; part 'model/download_info_dto.dart'; part 'model/download_response_dto.dart'; @@ -234,12 +232,15 @@ part 'model/update_library_dto.dart'; part 'model/update_partner_dto.dart'; part 'model/update_stack_parent_dto.dart'; part 'model/update_tag_dto.dart'; -part 'model/update_user_dto.dart'; part 'model/usage_by_user_dto.dart'; +part 'model/user_admin_create_dto.dart'; +part 'model/user_admin_delete_dto.dart'; +part 'model/user_admin_response_dto.dart'; +part 'model/user_admin_update_dto.dart'; part 'model/user_avatar_color.dart'; -part 'model/user_dto.dart'; part 'model/user_response_dto.dart'; part 'model/user_status.dart'; +part 'model/user_update_me_dto.dart'; part 'model/validate_access_token_response_dto.dart'; part 'model/validate_library_dto.dart'; part 'model/validate_library_import_path_response_dto.dart'; diff --git a/mobile/openapi/lib/api/authentication_api.dart b/mobile/openapi/lib/api/authentication_api.dart index c2aa50e7e7..cb81867425 100644 --- a/mobile/openapi/lib/api/authentication_api.dart +++ b/mobile/openapi/lib/api/authentication_api.dart @@ -48,7 +48,7 @@ class AuthenticationApi { /// Parameters: /// /// * [ChangePasswordDto] changePasswordDto (required): - Future changePassword(ChangePasswordDto changePasswordDto,) async { + Future changePassword(ChangePasswordDto changePasswordDto,) async { final response = await changePasswordWithHttpInfo(changePasswordDto,); if (response.statusCode >= HttpStatus.badRequest) { throw ApiException(response.statusCode, await _decodeBodyBytes(response)); @@ -57,7 +57,7 @@ class AuthenticationApi { // At the time of writing this, `dart:convert` will throw an "Unexpected end of input" // FormatException when trying to decode an empty string. if (response.body.isNotEmpty && response.statusCode != HttpStatus.noContent) { - return await apiClient.deserializeAsync(await _decodeBodyBytes(response), 'UserResponseDto',) as UserResponseDto; + return await apiClient.deserializeAsync(await _decodeBodyBytes(response), 'UserAdminResponseDto',) as UserAdminResponseDto; } return null; @@ -183,7 +183,7 @@ class AuthenticationApi { /// Parameters: /// /// * [SignUpDto] signUpDto (required): - Future signUpAdmin(SignUpDto signUpDto,) async { + Future signUpAdmin(SignUpDto signUpDto,) async { final response = await signUpAdminWithHttpInfo(signUpDto,); if (response.statusCode >= HttpStatus.badRequest) { throw ApiException(response.statusCode, await _decodeBodyBytes(response)); @@ -192,7 +192,7 @@ class AuthenticationApi { // At the time of writing this, `dart:convert` will throw an "Unexpected end of input" // FormatException when trying to decode an empty string. if (response.body.isNotEmpty && response.statusCode != HttpStatus.noContent) { - return await apiClient.deserializeAsync(await _decodeBodyBytes(response), 'UserResponseDto',) as UserResponseDto; + return await apiClient.deserializeAsync(await _decodeBodyBytes(response), 'UserAdminResponseDto',) as UserAdminResponseDto; } return null; diff --git a/mobile/openapi/lib/api/o_auth_api.dart b/mobile/openapi/lib/api/o_auth_api.dart index 9c238f01dc..aafcb28461 100644 --- a/mobile/openapi/lib/api/o_auth_api.dart +++ b/mobile/openapi/lib/api/o_auth_api.dart @@ -95,7 +95,7 @@ class OAuthApi { /// Parameters: /// /// * [OAuthCallbackDto] oAuthCallbackDto (required): - Future linkOAuthAccount(OAuthCallbackDto oAuthCallbackDto,) async { + Future linkOAuthAccount(OAuthCallbackDto oAuthCallbackDto,) async { final response = await linkOAuthAccountWithHttpInfo(oAuthCallbackDto,); if (response.statusCode >= HttpStatus.badRequest) { throw ApiException(response.statusCode, await _decodeBodyBytes(response)); @@ -104,7 +104,7 @@ class OAuthApi { // At the time of writing this, `dart:convert` will throw an "Unexpected end of input" // FormatException when trying to decode an empty string. if (response.body.isNotEmpty && response.statusCode != HttpStatus.noContent) { - return await apiClient.deserializeAsync(await _decodeBodyBytes(response), 'UserResponseDto',) as UserResponseDto; + return await apiClient.deserializeAsync(await _decodeBodyBytes(response), 'UserAdminResponseDto',) as UserAdminResponseDto; } return null; @@ -216,7 +216,7 @@ class OAuthApi { ); } - Future unlinkOAuthAccount() async { + Future unlinkOAuthAccount() async { final response = await unlinkOAuthAccountWithHttpInfo(); if (response.statusCode >= HttpStatus.badRequest) { throw ApiException(response.statusCode, await _decodeBodyBytes(response)); @@ -225,7 +225,7 @@ class OAuthApi { // At the time of writing this, `dart:convert` will throw an "Unexpected end of input" // FormatException when trying to decode an empty string. if (response.body.isNotEmpty && response.statusCode != HttpStatus.noContent) { - return await apiClient.deserializeAsync(await _decodeBodyBytes(response), 'UserResponseDto',) as UserResponseDto; + return await apiClient.deserializeAsync(await _decodeBodyBytes(response), 'UserAdminResponseDto',) as UserAdminResponseDto; } return null; diff --git a/mobile/openapi/lib/api/user_api.dart b/mobile/openapi/lib/api/user_api.dart index 301169cb9a..3c1a3ff4e7 100644 --- a/mobile/openapi/lib/api/user_api.dart +++ b/mobile/openapi/lib/api/user_api.dart @@ -73,16 +73,16 @@ class UserApi { return null; } - /// Performs an HTTP 'POST /users' operation and returns the [Response]. + /// Performs an HTTP 'POST /admin/users' operation and returns the [Response]. /// Parameters: /// - /// * [CreateUserDto] createUserDto (required): - Future createUserWithHttpInfo(CreateUserDto createUserDto,) async { + /// * [UserAdminCreateDto] userAdminCreateDto (required): + Future createUserAdminWithHttpInfo(UserAdminCreateDto userAdminCreateDto,) async { // ignore: prefer_const_declarations - final path = r'/users'; + final path = r'/admin/users'; // ignore: prefer_final_locals - Object? postBody = createUserDto; + Object? postBody = userAdminCreateDto; final queryParams = []; final headerParams = {}; @@ -104,9 +104,9 @@ class UserApi { /// Parameters: /// - /// * [CreateUserDto] createUserDto (required): - Future createUser(CreateUserDto createUserDto,) async { - final response = await createUserWithHttpInfo(createUserDto,); + /// * [UserAdminCreateDto] userAdminCreateDto (required): + Future createUserAdmin(UserAdminCreateDto userAdminCreateDto,) async { + final response = await createUserAdminWithHttpInfo(userAdminCreateDto,); if (response.statusCode >= HttpStatus.badRequest) { throw ApiException(response.statusCode, await _decodeBodyBytes(response)); } @@ -114,7 +114,7 @@ class UserApi { // At the time of writing this, `dart:convert` will throw an "Unexpected end of input" // FormatException when trying to decode an empty string. if (response.body.isNotEmpty && response.statusCode != HttpStatus.noContent) { - return await apiClient.deserializeAsync(await _decodeBodyBytes(response), 'UserResponseDto',) as UserResponseDto; + return await apiClient.deserializeAsync(await _decodeBodyBytes(response), 'UserAdminResponseDto',) as UserAdminResponseDto; } return null; @@ -153,19 +153,19 @@ class UserApi { } } - /// Performs an HTTP 'DELETE /users/{id}' operation and returns the [Response]. + /// Performs an HTTP 'DELETE /admin/users/{id}' operation and returns the [Response]. /// Parameters: /// /// * [String] id (required): /// - /// * [DeleteUserDto] deleteUserDto (required): - Future deleteUserWithHttpInfo(String id, DeleteUserDto deleteUserDto,) async { + /// * [UserAdminDeleteDto] userAdminDeleteDto (required): + Future deleteUserAdminWithHttpInfo(String id, UserAdminDeleteDto userAdminDeleteDto,) async { // ignore: prefer_const_declarations - final path = r'/users/{id}' + final path = r'/admin/users/{id}' .replaceAll('{id}', id); // ignore: prefer_final_locals - Object? postBody = deleteUserDto; + Object? postBody = userAdminDeleteDto; final queryParams = []; final headerParams = {}; @@ -189,9 +189,9 @@ class UserApi { /// /// * [String] id (required): /// - /// * [DeleteUserDto] deleteUserDto (required): - Future deleteUser(String id, DeleteUserDto deleteUserDto,) async { - final response = await deleteUserWithHttpInfo(id, deleteUserDto,); + /// * [UserAdminDeleteDto] userAdminDeleteDto (required): + Future deleteUserAdmin(String id, UserAdminDeleteDto userAdminDeleteDto,) async { + final response = await deleteUserAdminWithHttpInfo(id, userAdminDeleteDto,); if (response.statusCode >= HttpStatus.badRequest) { throw ApiException(response.statusCode, await _decodeBodyBytes(response)); } @@ -199,66 +199,14 @@ class UserApi { // At the time of writing this, `dart:convert` will throw an "Unexpected end of input" // FormatException when trying to decode an empty string. if (response.body.isNotEmpty && response.statusCode != HttpStatus.noContent) { - return await apiClient.deserializeAsync(await _decodeBodyBytes(response), 'UserResponseDto',) as UserResponseDto; + return await apiClient.deserializeAsync(await _decodeBodyBytes(response), 'UserAdminResponseDto',) as UserAdminResponseDto; } return null; } - /// Performs an HTTP 'GET /users' operation and returns the [Response]. - /// Parameters: - /// - /// * [bool] isAll (required): - Future getAllUsersWithHttpInfo(bool isAll,) async { - // ignore: prefer_const_declarations - final path = r'/users'; - - // ignore: prefer_final_locals - Object? postBody; - - final queryParams = []; - final headerParams = {}; - final formParams = {}; - - queryParams.addAll(_queryParams('', 'isAll', isAll)); - - const contentTypes = []; - - - return apiClient.invokeAPI( - path, - 'GET', - queryParams, - postBody, - headerParams, - formParams, - contentTypes.isEmpty ? null : contentTypes.first, - ); - } - - /// Parameters: - /// - /// * [bool] isAll (required): - Future?> getAllUsers(bool isAll,) async { - final response = await getAllUsersWithHttpInfo(isAll,); - if (response.statusCode >= HttpStatus.badRequest) { - throw ApiException(response.statusCode, await _decodeBodyBytes(response)); - } - // When a remote server returns no body with a status of 204, we shall not decode it. - // At the time of writing this, `dart:convert` will throw an "Unexpected end of input" - // FormatException when trying to decode an empty string. - if (response.body.isNotEmpty && response.statusCode != HttpStatus.noContent) { - final responseBody = await _decodeBodyBytes(response); - return (await apiClient.deserializeAsync(responseBody, 'List') as List) - .cast() - .toList(growable: false); - - } - return null; - } - /// Performs an HTTP 'GET /users/me' operation and returns the [Response]. - Future getMyUserInfoWithHttpInfo() async { + Future getMyUserWithHttpInfo() async { // ignore: prefer_const_declarations final path = r'/users/me'; @@ -283,8 +231,8 @@ class UserApi { ); } - Future getMyUserInfo() async { - final response = await getMyUserInfoWithHttpInfo(); + Future getMyUser() async { + final response = await getMyUserWithHttpInfo(); if (response.statusCode >= HttpStatus.badRequest) { throw ApiException(response.statusCode, await _decodeBodyBytes(response)); } @@ -292,7 +240,7 @@ class UserApi { // At the time of writing this, `dart:convert` will throw an "Unexpected end of input" // FormatException when trying to decode an empty string. if (response.body.isNotEmpty && response.statusCode != HttpStatus.noContent) { - return await apiClient.deserializeAsync(await _decodeBodyBytes(response), 'UserResponseDto',) as UserResponseDto; + return await apiClient.deserializeAsync(await _decodeBodyBytes(response), 'UserAdminResponseDto',) as UserAdminResponseDto; } return null; @@ -350,7 +298,7 @@ class UserApi { /// Parameters: /// /// * [String] id (required): - Future getUserByIdWithHttpInfo(String id,) async { + Future getUserWithHttpInfo(String id,) async { // ignore: prefer_const_declarations final path = r'/users/{id}' .replaceAll('{id}', id); @@ -379,8 +327,8 @@ class UserApi { /// Parameters: /// /// * [String] id (required): - Future getUserById(String id,) async { - final response = await getUserByIdWithHttpInfo(id,); + Future getUser(String id,) async { + final response = await getUserWithHttpInfo(id,); if (response.statusCode >= HttpStatus.badRequest) { throw ApiException(response.statusCode, await _decodeBodyBytes(response)); } @@ -394,13 +342,61 @@ class UserApi { return null; } - /// Performs an HTTP 'POST /users/{id}/restore' operation and returns the [Response]. + /// Performs an HTTP 'GET /admin/users/{id}' operation and returns the [Response]. /// Parameters: /// /// * [String] id (required): - Future restoreUserWithHttpInfo(String id,) async { + Future getUserAdminWithHttpInfo(String id,) async { // ignore: prefer_const_declarations - final path = r'/users/{id}/restore' + final path = r'/admin/users/{id}' + .replaceAll('{id}', id); + + // ignore: prefer_final_locals + Object? postBody; + + final queryParams = []; + final headerParams = {}; + final formParams = {}; + + const contentTypes = []; + + + return apiClient.invokeAPI( + path, + 'GET', + queryParams, + postBody, + headerParams, + formParams, + contentTypes.isEmpty ? null : contentTypes.first, + ); + } + + /// Parameters: + /// + /// * [String] id (required): + Future getUserAdmin(String id,) async { + final response = await getUserAdminWithHttpInfo(id,); + if (response.statusCode >= HttpStatus.badRequest) { + throw ApiException(response.statusCode, await _decodeBodyBytes(response)); + } + // When a remote server returns no body with a status of 204, we shall not decode it. + // At the time of writing this, `dart:convert` will throw an "Unexpected end of input" + // FormatException when trying to decode an empty string. + if (response.body.isNotEmpty && response.statusCode != HttpStatus.noContent) { + return await apiClient.deserializeAsync(await _decodeBodyBytes(response), 'UserAdminResponseDto',) as UserAdminResponseDto; + + } + return null; + } + + /// Performs an HTTP 'POST /admin/users/{id}/restore' operation and returns the [Response]. + /// Parameters: + /// + /// * [String] id (required): + Future restoreUserAdminWithHttpInfo(String id,) async { + // ignore: prefer_const_declarations + final path = r'/admin/users/{id}/restore' .replaceAll('{id}', id); // ignore: prefer_final_locals @@ -427,8 +423,8 @@ class UserApi { /// Parameters: /// /// * [String] id (required): - Future restoreUser(String id,) async { - final response = await restoreUserWithHttpInfo(id,); + Future restoreUserAdmin(String id,) async { + final response = await restoreUserAdminWithHttpInfo(id,); if (response.statusCode >= HttpStatus.badRequest) { throw ApiException(response.statusCode, await _decodeBodyBytes(response)); } @@ -436,22 +432,120 @@ class UserApi { // At the time of writing this, `dart:convert` will throw an "Unexpected end of input" // FormatException when trying to decode an empty string. if (response.body.isNotEmpty && response.statusCode != HttpStatus.noContent) { - return await apiClient.deserializeAsync(await _decodeBodyBytes(response), 'UserResponseDto',) as UserResponseDto; + return await apiClient.deserializeAsync(await _decodeBodyBytes(response), 'UserAdminResponseDto',) as UserAdminResponseDto; } return null; } - /// Performs an HTTP 'PUT /users' operation and returns the [Response]. - /// Parameters: - /// - /// * [UpdateUserDto] updateUserDto (required): - Future updateUserWithHttpInfo(UpdateUserDto updateUserDto,) async { + /// Performs an HTTP 'GET /users' operation and returns the [Response]. + Future searchUsersWithHttpInfo() async { // ignore: prefer_const_declarations final path = r'/users'; // ignore: prefer_final_locals - Object? postBody = updateUserDto; + Object? postBody; + + final queryParams = []; + final headerParams = {}; + final formParams = {}; + + const contentTypes = []; + + + return apiClient.invokeAPI( + path, + 'GET', + queryParams, + postBody, + headerParams, + formParams, + contentTypes.isEmpty ? null : contentTypes.first, + ); + } + + Future?> searchUsers() async { + final response = await searchUsersWithHttpInfo(); + if (response.statusCode >= HttpStatus.badRequest) { + throw ApiException(response.statusCode, await _decodeBodyBytes(response)); + } + // When a remote server returns no body with a status of 204, we shall not decode it. + // At the time of writing this, `dart:convert` will throw an "Unexpected end of input" + // FormatException when trying to decode an empty string. + if (response.body.isNotEmpty && response.statusCode != HttpStatus.noContent) { + final responseBody = await _decodeBodyBytes(response); + return (await apiClient.deserializeAsync(responseBody, 'List') as List) + .cast() + .toList(growable: false); + + } + return null; + } + + /// Performs an HTTP 'GET /admin/users' operation and returns the [Response]. + /// Parameters: + /// + /// * [bool] withDeleted: + Future searchUsersAdminWithHttpInfo({ bool? withDeleted, }) async { + // ignore: prefer_const_declarations + final path = r'/admin/users'; + + // ignore: prefer_final_locals + Object? postBody; + + final queryParams = []; + final headerParams = {}; + final formParams = {}; + + if (withDeleted != null) { + queryParams.addAll(_queryParams('', 'withDeleted', withDeleted)); + } + + const contentTypes = []; + + + return apiClient.invokeAPI( + path, + 'GET', + queryParams, + postBody, + headerParams, + formParams, + contentTypes.isEmpty ? null : contentTypes.first, + ); + } + + /// Parameters: + /// + /// * [bool] withDeleted: + Future?> searchUsersAdmin({ bool? withDeleted, }) async { + final response = await searchUsersAdminWithHttpInfo( withDeleted: withDeleted, ); + if (response.statusCode >= HttpStatus.badRequest) { + throw ApiException(response.statusCode, await _decodeBodyBytes(response)); + } + // When a remote server returns no body with a status of 204, we shall not decode it. + // At the time of writing this, `dart:convert` will throw an "Unexpected end of input" + // FormatException when trying to decode an empty string. + if (response.body.isNotEmpty && response.statusCode != HttpStatus.noContent) { + final responseBody = await _decodeBodyBytes(response); + return (await apiClient.deserializeAsync(responseBody, 'List') as List) + .cast() + .toList(growable: false); + + } + return null; + } + + /// Performs an HTTP 'PUT /users/me' operation and returns the [Response]. + /// Parameters: + /// + /// * [UserUpdateMeDto] userUpdateMeDto (required): + Future updateMyUserWithHttpInfo(UserUpdateMeDto userUpdateMeDto,) async { + // ignore: prefer_const_declarations + final path = r'/users/me'; + + // ignore: prefer_final_locals + Object? postBody = userUpdateMeDto; final queryParams = []; final headerParams = {}; @@ -473,9 +567,9 @@ class UserApi { /// Parameters: /// - /// * [UpdateUserDto] updateUserDto (required): - Future updateUser(UpdateUserDto updateUserDto,) async { - final response = await updateUserWithHttpInfo(updateUserDto,); + /// * [UserUpdateMeDto] userUpdateMeDto (required): + Future updateMyUser(UserUpdateMeDto userUpdateMeDto,) async { + final response = await updateMyUserWithHttpInfo(userUpdateMeDto,); if (response.statusCode >= HttpStatus.badRequest) { throw ApiException(response.statusCode, await _decodeBodyBytes(response)); } @@ -483,7 +577,59 @@ class UserApi { // At the time of writing this, `dart:convert` will throw an "Unexpected end of input" // FormatException when trying to decode an empty string. if (response.body.isNotEmpty && response.statusCode != HttpStatus.noContent) { - return await apiClient.deserializeAsync(await _decodeBodyBytes(response), 'UserResponseDto',) as UserResponseDto; + return await apiClient.deserializeAsync(await _decodeBodyBytes(response), 'UserAdminResponseDto',) as UserAdminResponseDto; + + } + return null; + } + + /// Performs an HTTP 'PUT /admin/users/{id}' operation and returns the [Response]. + /// Parameters: + /// + /// * [String] id (required): + /// + /// * [UserAdminUpdateDto] userAdminUpdateDto (required): + Future updateUserAdminWithHttpInfo(String id, UserAdminUpdateDto userAdminUpdateDto,) async { + // ignore: prefer_const_declarations + final path = r'/admin/users/{id}' + .replaceAll('{id}', id); + + // ignore: prefer_final_locals + Object? postBody = userAdminUpdateDto; + + final queryParams = []; + final headerParams = {}; + final formParams = {}; + + const contentTypes = ['application/json']; + + + return apiClient.invokeAPI( + path, + 'PUT', + queryParams, + postBody, + headerParams, + formParams, + contentTypes.isEmpty ? null : contentTypes.first, + ); + } + + /// Parameters: + /// + /// * [String] id (required): + /// + /// * [UserAdminUpdateDto] userAdminUpdateDto (required): + Future updateUserAdmin(String id, UserAdminUpdateDto userAdminUpdateDto,) async { + final response = await updateUserAdminWithHttpInfo(id, userAdminUpdateDto,); + if (response.statusCode >= HttpStatus.badRequest) { + throw ApiException(response.statusCode, await _decodeBodyBytes(response)); + } + // When a remote server returns no body with a status of 204, we shall not decode it. + // At the time of writing this, `dart:convert` will throw an "Unexpected end of input" + // FormatException when trying to decode an empty string. + if (response.body.isNotEmpty && response.statusCode != HttpStatus.noContent) { + return await apiClient.deserializeAsync(await _decodeBodyBytes(response), 'UserAdminResponseDto',) as UserAdminResponseDto; } return null; diff --git a/mobile/openapi/lib/api_client.dart b/mobile/openapi/lib/api_client.dart index 1f959757da..bd3433872a 100644 --- a/mobile/openapi/lib/api_client.dart +++ b/mobile/openapi/lib/api_client.dart @@ -292,10 +292,6 @@ class ApiClient { return CreateProfileImageResponseDto.fromJson(value); case 'CreateTagDto': return CreateTagDto.fromJson(value); - case 'CreateUserDto': - return CreateUserDto.fromJson(value); - case 'DeleteUserDto': - return DeleteUserDto.fromJson(value); case 'DownloadArchiveInfo': return DownloadArchiveInfo.fromJson(value); case 'DownloadInfoDto': @@ -536,18 +532,24 @@ class ApiClient { return UpdateStackParentDto.fromJson(value); case 'UpdateTagDto': return UpdateTagDto.fromJson(value); - case 'UpdateUserDto': - return UpdateUserDto.fromJson(value); case 'UsageByUserDto': return UsageByUserDto.fromJson(value); + case 'UserAdminCreateDto': + return UserAdminCreateDto.fromJson(value); + case 'UserAdminDeleteDto': + return UserAdminDeleteDto.fromJson(value); + case 'UserAdminResponseDto': + return UserAdminResponseDto.fromJson(value); + case 'UserAdminUpdateDto': + return UserAdminUpdateDto.fromJson(value); case 'UserAvatarColor': return UserAvatarColorTypeTransformer().decode(value); - case 'UserDto': - return UserDto.fromJson(value); case 'UserResponseDto': return UserResponseDto.fromJson(value); case 'UserStatus': return UserStatusTypeTransformer().decode(value); + case 'UserUpdateMeDto': + return UserUpdateMeDto.fromJson(value); case 'ValidateAccessTokenResponseDto': return ValidateAccessTokenResponseDto.fromJson(value); case 'ValidateLibraryDto': diff --git a/mobile/openapi/lib/model/activity_response_dto.dart b/mobile/openapi/lib/model/activity_response_dto.dart index d276d19e6c..cd7a4f482f 100644 --- a/mobile/openapi/lib/model/activity_response_dto.dart +++ b/mobile/openapi/lib/model/activity_response_dto.dart @@ -31,7 +31,7 @@ class ActivityResponseDto { ActivityResponseDtoTypeEnum type; - UserDto user; + UserResponseDto user; @override bool operator ==(Object other) => identical(this, other) || other is ActivityResponseDto && @@ -87,7 +87,7 @@ class ActivityResponseDto { createdAt: mapDateTime(json, r'createdAt', r'')!, id: mapValueOfType(json, r'id')!, type: ActivityResponseDtoTypeEnum.fromJson(json[r'type'])!, - user: UserDto.fromJson(json[r'user'])!, + user: UserResponseDto.fromJson(json[r'user'])!, ); } return null; diff --git a/mobile/openapi/lib/model/partner_response_dto.dart b/mobile/openapi/lib/model/partner_response_dto.dart index 1efd91c346..7c3cf03bd9 100644 --- a/mobile/openapi/lib/model/partner_response_dto.dart +++ b/mobile/openapi/lib/model/partner_response_dto.dart @@ -14,30 +14,15 @@ class PartnerResponseDto { /// Returns a new [PartnerResponseDto] instance. PartnerResponseDto({ required this.avatarColor, - required this.createdAt, - required this.deletedAt, required this.email, required this.id, this.inTimeline, - required this.isAdmin, - this.memoriesEnabled, required this.name, - required this.oauthId, required this.profileImagePath, - required this.quotaSizeInBytes, - required this.quotaUsageInBytes, - required this.shouldChangePassword, - required this.status, - required this.storageLabel, - required this.updatedAt, }); UserAvatarColor avatarColor; - DateTime createdAt; - - DateTime? deletedAt; - String email; String id; @@ -50,121 +35,44 @@ class PartnerResponseDto { /// bool? inTimeline; - bool isAdmin; - - /// - /// Please note: This property should have been non-nullable! Since the specification file - /// does not include a default value (using the "default:" property), however, the generated - /// source code must fall back to having a nullable type. - /// Consider adding a "default:" property in the specification file to hide this note. - /// - bool? memoriesEnabled; - String name; - String oauthId; - String profileImagePath; - int? quotaSizeInBytes; - - int? quotaUsageInBytes; - - bool shouldChangePassword; - - UserStatus status; - - String? storageLabel; - - DateTime updatedAt; - @override bool operator ==(Object other) => identical(this, other) || other is PartnerResponseDto && other.avatarColor == avatarColor && - other.createdAt == createdAt && - other.deletedAt == deletedAt && other.email == email && other.id == id && other.inTimeline == inTimeline && - other.isAdmin == isAdmin && - other.memoriesEnabled == memoriesEnabled && other.name == name && - other.oauthId == oauthId && - other.profileImagePath == profileImagePath && - other.quotaSizeInBytes == quotaSizeInBytes && - other.quotaUsageInBytes == quotaUsageInBytes && - other.shouldChangePassword == shouldChangePassword && - other.status == status && - other.storageLabel == storageLabel && - other.updatedAt == updatedAt; + other.profileImagePath == profileImagePath; @override int get hashCode => // ignore: unnecessary_parenthesis (avatarColor.hashCode) + - (createdAt.hashCode) + - (deletedAt == null ? 0 : deletedAt!.hashCode) + (email.hashCode) + (id.hashCode) + (inTimeline == null ? 0 : inTimeline!.hashCode) + - (isAdmin.hashCode) + - (memoriesEnabled == null ? 0 : memoriesEnabled!.hashCode) + (name.hashCode) + - (oauthId.hashCode) + - (profileImagePath.hashCode) + - (quotaSizeInBytes == null ? 0 : quotaSizeInBytes!.hashCode) + - (quotaUsageInBytes == null ? 0 : quotaUsageInBytes!.hashCode) + - (shouldChangePassword.hashCode) + - (status.hashCode) + - (storageLabel == null ? 0 : storageLabel!.hashCode) + - (updatedAt.hashCode); + (profileImagePath.hashCode); @override - String toString() => 'PartnerResponseDto[avatarColor=$avatarColor, createdAt=$createdAt, deletedAt=$deletedAt, email=$email, id=$id, inTimeline=$inTimeline, isAdmin=$isAdmin, memoriesEnabled=$memoriesEnabled, name=$name, oauthId=$oauthId, profileImagePath=$profileImagePath, quotaSizeInBytes=$quotaSizeInBytes, quotaUsageInBytes=$quotaUsageInBytes, shouldChangePassword=$shouldChangePassword, status=$status, storageLabel=$storageLabel, updatedAt=$updatedAt]'; + String toString() => 'PartnerResponseDto[avatarColor=$avatarColor, email=$email, id=$id, inTimeline=$inTimeline, name=$name, profileImagePath=$profileImagePath]'; Map toJson() { final json = {}; json[r'avatarColor'] = this.avatarColor; - json[r'createdAt'] = this.createdAt.toUtc().toIso8601String(); - if (this.deletedAt != null) { - json[r'deletedAt'] = this.deletedAt!.toUtc().toIso8601String(); - } else { - // json[r'deletedAt'] = null; - } json[r'email'] = this.email; json[r'id'] = this.id; if (this.inTimeline != null) { json[r'inTimeline'] = this.inTimeline; } else { // json[r'inTimeline'] = null; - } - json[r'isAdmin'] = this.isAdmin; - if (this.memoriesEnabled != null) { - json[r'memoriesEnabled'] = this.memoriesEnabled; - } else { - // json[r'memoriesEnabled'] = null; } json[r'name'] = this.name; - json[r'oauthId'] = this.oauthId; json[r'profileImagePath'] = this.profileImagePath; - if (this.quotaSizeInBytes != null) { - json[r'quotaSizeInBytes'] = this.quotaSizeInBytes; - } else { - // json[r'quotaSizeInBytes'] = null; - } - if (this.quotaUsageInBytes != null) { - json[r'quotaUsageInBytes'] = this.quotaUsageInBytes; - } else { - // json[r'quotaUsageInBytes'] = null; - } - json[r'shouldChangePassword'] = this.shouldChangePassword; - json[r'status'] = this.status; - if (this.storageLabel != null) { - json[r'storageLabel'] = this.storageLabel; - } else { - // json[r'storageLabel'] = null; - } - json[r'updatedAt'] = this.updatedAt.toUtc().toIso8601String(); return json; } @@ -177,22 +85,11 @@ class PartnerResponseDto { return PartnerResponseDto( avatarColor: UserAvatarColor.fromJson(json[r'avatarColor'])!, - createdAt: mapDateTime(json, r'createdAt', r'')!, - deletedAt: mapDateTime(json, r'deletedAt', r''), email: mapValueOfType(json, r'email')!, id: mapValueOfType(json, r'id')!, inTimeline: mapValueOfType(json, r'inTimeline'), - isAdmin: mapValueOfType(json, r'isAdmin')!, - memoriesEnabled: mapValueOfType(json, r'memoriesEnabled'), name: mapValueOfType(json, r'name')!, - oauthId: mapValueOfType(json, r'oauthId')!, profileImagePath: mapValueOfType(json, r'profileImagePath')!, - quotaSizeInBytes: mapValueOfType(json, r'quotaSizeInBytes'), - quotaUsageInBytes: mapValueOfType(json, r'quotaUsageInBytes'), - shouldChangePassword: mapValueOfType(json, r'shouldChangePassword')!, - status: UserStatus.fromJson(json[r'status'])!, - storageLabel: mapValueOfType(json, r'storageLabel'), - updatedAt: mapDateTime(json, r'updatedAt', r'')!, ); } return null; @@ -241,20 +138,10 @@ class PartnerResponseDto { /// The list of required keys that must be present in a JSON. static const requiredKeys = { 'avatarColor', - 'createdAt', - 'deletedAt', 'email', 'id', - 'isAdmin', 'name', - 'oauthId', 'profileImagePath', - 'quotaSizeInBytes', - 'quotaUsageInBytes', - 'shouldChangePassword', - 'status', - 'storageLabel', - 'updatedAt', }; } 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 4b0bdd55da..daf8854e01 100644 --- a/mobile/openapi/lib/model/create_user_dto.dart +++ b/mobile/openapi/lib/model/user_admin_create_dto.dart @@ -10,9 +10,9 @@ part of openapi.api; -class CreateUserDto { - /// Returns a new [CreateUserDto] instance. - CreateUserDto({ +class UserAdminCreateDto { + /// Returns a new [UserAdminCreateDto] instance. + UserAdminCreateDto({ required this.email, this.memoriesEnabled, required this.name, @@ -59,7 +59,7 @@ class CreateUserDto { String? storageLabel; @override - bool operator ==(Object other) => identical(this, other) || other is CreateUserDto && + bool operator ==(Object other) => identical(this, other) || other is UserAdminCreateDto && other.email == email && other.memoriesEnabled == memoriesEnabled && other.name == name && @@ -82,7 +82,7 @@ class CreateUserDto { (storageLabel == null ? 0 : storageLabel!.hashCode); @override - String toString() => 'CreateUserDto[email=$email, memoriesEnabled=$memoriesEnabled, name=$name, notify=$notify, password=$password, quotaSizeInBytes=$quotaSizeInBytes, shouldChangePassword=$shouldChangePassword, storageLabel=$storageLabel]'; + String toString() => 'UserAdminCreateDto[email=$email, memoriesEnabled=$memoriesEnabled, name=$name, notify=$notify, password=$password, quotaSizeInBytes=$quotaSizeInBytes, shouldChangePassword=$shouldChangePassword, storageLabel=$storageLabel]'; Map toJson() { final json = {}; @@ -117,14 +117,14 @@ class CreateUserDto { return json; } - /// Returns a new [CreateUserDto] instance and imports its values from + /// Returns a new [UserAdminCreateDto] instance and imports its values from /// [value] if it's a [Map], null otherwise. // ignore: prefer_constructors_over_static_methods - static CreateUserDto? fromJson(dynamic value) { + static UserAdminCreateDto? fromJson(dynamic value) { if (value is Map) { final json = value.cast(); - return CreateUserDto( + return UserAdminCreateDto( email: mapValueOfType(json, r'email')!, memoriesEnabled: mapValueOfType(json, r'memoriesEnabled'), name: mapValueOfType(json, r'name')!, @@ -138,11 +138,11 @@ class CreateUserDto { return null; } - static List listFromJson(dynamic json, {bool growable = false,}) { - final result = []; + static List listFromJson(dynamic json, {bool growable = false,}) { + final result = []; if (json is List && json.isNotEmpty) { for (final row in json) { - final value = CreateUserDto.fromJson(row); + final value = UserAdminCreateDto.fromJson(row); if (value != null) { result.add(value); } @@ -151,12 +151,12 @@ class CreateUserDto { return result.toList(growable: growable); } - static Map mapFromJson(dynamic json) { - final map = {}; + static Map mapFromJson(dynamic json) { + final map = {}; if (json is Map && json.isNotEmpty) { json = json.cast(); // ignore: parameter_assignments for (final entry in json.entries) { - final value = CreateUserDto.fromJson(entry.value); + final value = UserAdminCreateDto.fromJson(entry.value); if (value != null) { map[entry.key] = value; } @@ -165,14 +165,14 @@ class CreateUserDto { return map; } - // maps a json object with a list of CreateUserDto-objects as value to a dart map - static Map> mapListFromJson(dynamic json, {bool growable = false,}) { - final map = >{}; + // maps a json object with a list of UserAdminCreateDto-objects as value to a dart map + static Map> mapListFromJson(dynamic json, {bool growable = false,}) { + final map = >{}; if (json is Map && json.isNotEmpty) { // ignore: parameter_assignments json = json.cast(); for (final entry in json.entries) { - map[entry.key] = CreateUserDto.listFromJson(entry.value, growable: growable,); + map[entry.key] = UserAdminCreateDto.listFromJson(entry.value, growable: growable,); } } return map; 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 a758991fa9..7778b15775 100644 --- a/mobile/openapi/lib/model/delete_user_dto.dart +++ b/mobile/openapi/lib/model/user_admin_delete_dto.dart @@ -10,9 +10,9 @@ part of openapi.api; -class DeleteUserDto { - /// Returns a new [DeleteUserDto] instance. - DeleteUserDto({ +class UserAdminDeleteDto { + /// Returns a new [UserAdminDeleteDto] instance. + UserAdminDeleteDto({ this.force, }); @@ -25,7 +25,7 @@ class DeleteUserDto { bool? force; @override - bool operator ==(Object other) => identical(this, other) || other is DeleteUserDto && + bool operator ==(Object other) => identical(this, other) || other is UserAdminDeleteDto && other.force == force; @override @@ -34,7 +34,7 @@ class DeleteUserDto { (force == null ? 0 : force!.hashCode); @override - String toString() => 'DeleteUserDto[force=$force]'; + String toString() => 'UserAdminDeleteDto[force=$force]'; Map toJson() { final json = {}; @@ -46,25 +46,25 @@ class DeleteUserDto { return json; } - /// Returns a new [DeleteUserDto] instance and imports its values from + /// Returns a new [UserAdminDeleteDto] instance and imports its values from /// [value] if it's a [Map], null otherwise. // ignore: prefer_constructors_over_static_methods - static DeleteUserDto? fromJson(dynamic value) { + static UserAdminDeleteDto? fromJson(dynamic value) { if (value is Map) { final json = value.cast(); - return DeleteUserDto( + return UserAdminDeleteDto( force: mapValueOfType(json, r'force'), ); } return null; } - static List listFromJson(dynamic json, {bool growable = false,}) { - final result = []; + static List listFromJson(dynamic json, {bool growable = false,}) { + final result = []; if (json is List && json.isNotEmpty) { for (final row in json) { - final value = DeleteUserDto.fromJson(row); + final value = UserAdminDeleteDto.fromJson(row); if (value != null) { result.add(value); } @@ -73,12 +73,12 @@ class DeleteUserDto { return result.toList(growable: growable); } - static Map mapFromJson(dynamic json) { - final map = {}; + static Map mapFromJson(dynamic json) { + final map = {}; if (json is Map && json.isNotEmpty) { json = json.cast(); // ignore: parameter_assignments for (final entry in json.entries) { - final value = DeleteUserDto.fromJson(entry.value); + final value = UserAdminDeleteDto.fromJson(entry.value); if (value != null) { map[entry.key] = value; } @@ -87,14 +87,14 @@ class DeleteUserDto { return map; } - // maps a json object with a list of DeleteUserDto-objects as value to a dart map - static Map> mapListFromJson(dynamic json, {bool growable = false,}) { - final map = >{}; + // maps a json object with a list of UserAdminDeleteDto-objects as value to a dart map + static Map> mapListFromJson(dynamic json, {bool growable = false,}) { + final map = >{}; if (json is Map && json.isNotEmpty) { // ignore: parameter_assignments json = json.cast(); for (final entry in json.entries) { - map[entry.key] = DeleteUserDto.listFromJson(entry.value, growable: growable,); + map[entry.key] = UserAdminDeleteDto.listFromJson(entry.value, growable: growable,); } } return map; 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 0000000000..3fc8c2e274 --- /dev/null +++ b/mobile/openapi/lib/model/user_admin_response_dto.dart @@ -0,0 +1,243 @@ +// +// AUTO-GENERATED FILE, DO NOT MODIFY! +// +// @dart=2.18 + +// ignore_for_file: unused_element, unused_import +// ignore_for_file: always_put_required_named_parameters_first +// ignore_for_file: constant_identifier_names +// ignore_for_file: lines_longer_than_80_chars + +part of openapi.api; + +class UserAdminResponseDto { + /// Returns a new [UserAdminResponseDto] instance. + UserAdminResponseDto({ + required this.avatarColor, + required this.createdAt, + required this.deletedAt, + required this.email, + required this.id, + required this.isAdmin, + this.memoriesEnabled, + required this.name, + required this.oauthId, + required this.profileImagePath, + required this.quotaSizeInBytes, + required this.quotaUsageInBytes, + required this.shouldChangePassword, + required this.status, + required this.storageLabel, + required this.updatedAt, + }); + + UserAvatarColor avatarColor; + + DateTime createdAt; + + DateTime? deletedAt; + + String email; + + String id; + + bool isAdmin; + + /// + /// Please note: This property should have been non-nullable! Since the specification file + /// does not include a default value (using the "default:" property), however, the generated + /// source code must fall back to having a nullable type. + /// Consider adding a "default:" property in the specification file to hide this note. + /// + bool? memoriesEnabled; + + String name; + + String oauthId; + + String profileImagePath; + + int? quotaSizeInBytes; + + int? quotaUsageInBytes; + + bool shouldChangePassword; + + UserStatus status; + + String? storageLabel; + + DateTime updatedAt; + + @override + bool operator ==(Object other) => identical(this, other) || other is UserAdminResponseDto && + other.avatarColor == avatarColor && + other.createdAt == createdAt && + other.deletedAt == deletedAt && + other.email == email && + other.id == id && + other.isAdmin == isAdmin && + other.memoriesEnabled == memoriesEnabled && + other.name == name && + other.oauthId == oauthId && + other.profileImagePath == profileImagePath && + other.quotaSizeInBytes == quotaSizeInBytes && + other.quotaUsageInBytes == quotaUsageInBytes && + other.shouldChangePassword == shouldChangePassword && + other.status == status && + other.storageLabel == storageLabel && + other.updatedAt == updatedAt; + + @override + int get hashCode => + // ignore: unnecessary_parenthesis + (avatarColor.hashCode) + + (createdAt.hashCode) + + (deletedAt == null ? 0 : deletedAt!.hashCode) + + (email.hashCode) + + (id.hashCode) + + (isAdmin.hashCode) + + (memoriesEnabled == null ? 0 : memoriesEnabled!.hashCode) + + (name.hashCode) + + (oauthId.hashCode) + + (profileImagePath.hashCode) + + (quotaSizeInBytes == null ? 0 : quotaSizeInBytes!.hashCode) + + (quotaUsageInBytes == null ? 0 : quotaUsageInBytes!.hashCode) + + (shouldChangePassword.hashCode) + + (status.hashCode) + + (storageLabel == null ? 0 : storageLabel!.hashCode) + + (updatedAt.hashCode); + + @override + String toString() => 'UserAdminResponseDto[avatarColor=$avatarColor, createdAt=$createdAt, deletedAt=$deletedAt, email=$email, id=$id, isAdmin=$isAdmin, memoriesEnabled=$memoriesEnabled, name=$name, oauthId=$oauthId, profileImagePath=$profileImagePath, quotaSizeInBytes=$quotaSizeInBytes, quotaUsageInBytes=$quotaUsageInBytes, shouldChangePassword=$shouldChangePassword, status=$status, storageLabel=$storageLabel, updatedAt=$updatedAt]'; + + Map toJson() { + final json = {}; + json[r'avatarColor'] = this.avatarColor; + json[r'createdAt'] = this.createdAt.toUtc().toIso8601String(); + if (this.deletedAt != null) { + json[r'deletedAt'] = this.deletedAt!.toUtc().toIso8601String(); + } else { + // json[r'deletedAt'] = null; + } + json[r'email'] = this.email; + json[r'id'] = this.id; + json[r'isAdmin'] = this.isAdmin; + if (this.memoriesEnabled != null) { + json[r'memoriesEnabled'] = this.memoriesEnabled; + } else { + // json[r'memoriesEnabled'] = null; + } + json[r'name'] = this.name; + json[r'oauthId'] = this.oauthId; + json[r'profileImagePath'] = this.profileImagePath; + if (this.quotaSizeInBytes != null) { + json[r'quotaSizeInBytes'] = this.quotaSizeInBytes; + } else { + // json[r'quotaSizeInBytes'] = null; + } + if (this.quotaUsageInBytes != null) { + json[r'quotaUsageInBytes'] = this.quotaUsageInBytes; + } else { + // json[r'quotaUsageInBytes'] = null; + } + json[r'shouldChangePassword'] = this.shouldChangePassword; + json[r'status'] = this.status; + if (this.storageLabel != null) { + json[r'storageLabel'] = this.storageLabel; + } else { + // json[r'storageLabel'] = null; + } + json[r'updatedAt'] = this.updatedAt.toUtc().toIso8601String(); + return json; + } + + /// Returns a new [UserAdminResponseDto] instance and imports its values from + /// [value] if it's a [Map], null otherwise. + // ignore: prefer_constructors_over_static_methods + static UserAdminResponseDto? fromJson(dynamic value) { + if (value is Map) { + final json = value.cast(); + + return UserAdminResponseDto( + avatarColor: UserAvatarColor.fromJson(json[r'avatarColor'])!, + createdAt: mapDateTime(json, r'createdAt', r'')!, + deletedAt: mapDateTime(json, r'deletedAt', r''), + email: mapValueOfType(json, r'email')!, + id: mapValueOfType(json, r'id')!, + isAdmin: mapValueOfType(json, r'isAdmin')!, + memoriesEnabled: mapValueOfType(json, r'memoriesEnabled'), + name: mapValueOfType(json, r'name')!, + oauthId: mapValueOfType(json, r'oauthId')!, + profileImagePath: mapValueOfType(json, r'profileImagePath')!, + quotaSizeInBytes: mapValueOfType(json, r'quotaSizeInBytes'), + quotaUsageInBytes: mapValueOfType(json, r'quotaUsageInBytes'), + shouldChangePassword: mapValueOfType(json, r'shouldChangePassword')!, + status: UserStatus.fromJson(json[r'status'])!, + storageLabel: mapValueOfType(json, r'storageLabel'), + updatedAt: mapDateTime(json, r'updatedAt', r'')!, + ); + } + return null; + } + + static List listFromJson(dynamic json, {bool growable = false,}) { + final result = []; + if (json is List && json.isNotEmpty) { + for (final row in json) { + final value = UserAdminResponseDto.fromJson(row); + if (value != null) { + result.add(value); + } + } + } + return result.toList(growable: growable); + } + + static Map mapFromJson(dynamic json) { + final map = {}; + if (json is Map && json.isNotEmpty) { + json = json.cast(); // ignore: parameter_assignments + for (final entry in json.entries) { + final value = UserAdminResponseDto.fromJson(entry.value); + if (value != null) { + map[entry.key] = value; + } + } + } + return map; + } + + // maps a json object with a list of UserAdminResponseDto-objects as value to a dart map + static Map> mapListFromJson(dynamic json, {bool growable = false,}) { + final map = >{}; + if (json is Map && json.isNotEmpty) { + // ignore: parameter_assignments + json = json.cast(); + for (final entry in json.entries) { + map[entry.key] = UserAdminResponseDto.listFromJson(entry.value, growable: growable,); + } + } + return map; + } + + /// The list of required keys that must be present in a JSON. + static const requiredKeys = { + 'avatarColor', + 'createdAt', + 'deletedAt', + 'email', + 'id', + 'isAdmin', + 'name', + 'oauthId', + 'profileImagePath', + 'quotaSizeInBytes', + 'quotaUsageInBytes', + 'shouldChangePassword', + 'status', + 'storageLabel', + 'updatedAt', + }; +} + 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 caa0600793..ecd145248f 100644 --- a/mobile/openapi/lib/model/update_user_dto.dart +++ b/mobile/openapi/lib/model/user_admin_update_dto.dart @@ -10,13 +10,11 @@ part of openapi.api; -class UpdateUserDto { - /// Returns a new [UpdateUserDto] instance. - UpdateUserDto({ +class UserAdminUpdateDto { + /// Returns a new [UserAdminUpdateDto] instance. + UserAdminUpdateDto({ this.avatarColor, this.email, - required this.id, - this.isAdmin, this.memoriesEnabled, this.name, this.password, @@ -41,16 +39,6 @@ class UpdateUserDto { /// String? email; - String id; - - /// - /// Please note: This property should have been non-nullable! Since the specification file - /// does not include a default value (using the "default:" property), however, the generated - /// source code must fall back to having a nullable type. - /// Consider adding a "default:" property in the specification file to hide this note. - /// - bool? isAdmin; - /// /// Please note: This property should have been non-nullable! Since the specification file /// does not include a default value (using the "default:" property), however, the generated @@ -86,20 +74,12 @@ class UpdateUserDto { /// bool? shouldChangePassword; - /// - /// Please note: This property should have been non-nullable! Since the specification file - /// does not include a default value (using the "default:" property), however, the generated - /// source code must fall back to having a nullable type. - /// Consider adding a "default:" property in the specification file to hide this note. - /// String? storageLabel; @override - bool operator ==(Object other) => identical(this, other) || other is UpdateUserDto && + bool operator ==(Object other) => identical(this, other) || other is UserAdminUpdateDto && other.avatarColor == avatarColor && other.email == email && - other.id == id && - other.isAdmin == isAdmin && other.memoriesEnabled == memoriesEnabled && other.name == name && other.password == password && @@ -112,8 +92,6 @@ class UpdateUserDto { // ignore: unnecessary_parenthesis (avatarColor == null ? 0 : avatarColor!.hashCode) + (email == null ? 0 : email!.hashCode) + - (id.hashCode) + - (isAdmin == null ? 0 : isAdmin!.hashCode) + (memoriesEnabled == null ? 0 : memoriesEnabled!.hashCode) + (name == null ? 0 : name!.hashCode) + (password == null ? 0 : password!.hashCode) + @@ -122,7 +100,7 @@ class UpdateUserDto { (storageLabel == null ? 0 : storageLabel!.hashCode); @override - String toString() => 'UpdateUserDto[avatarColor=$avatarColor, email=$email, id=$id, isAdmin=$isAdmin, memoriesEnabled=$memoriesEnabled, name=$name, password=$password, quotaSizeInBytes=$quotaSizeInBytes, shouldChangePassword=$shouldChangePassword, storageLabel=$storageLabel]'; + String toString() => 'UserAdminUpdateDto[avatarColor=$avatarColor, email=$email, memoriesEnabled=$memoriesEnabled, name=$name, password=$password, quotaSizeInBytes=$quotaSizeInBytes, shouldChangePassword=$shouldChangePassword, storageLabel=$storageLabel]'; Map toJson() { final json = {}; @@ -135,12 +113,6 @@ class UpdateUserDto { json[r'email'] = this.email; } else { // json[r'email'] = null; - } - json[r'id'] = this.id; - if (this.isAdmin != null) { - json[r'isAdmin'] = this.isAdmin; - } else { - // json[r'isAdmin'] = null; } if (this.memoriesEnabled != null) { json[r'memoriesEnabled'] = this.memoriesEnabled; @@ -175,18 +147,16 @@ class UpdateUserDto { return json; } - /// Returns a new [UpdateUserDto] instance and imports its values from + /// Returns a new [UserAdminUpdateDto] instance and imports its values from /// [value] if it's a [Map], null otherwise. // ignore: prefer_constructors_over_static_methods - static UpdateUserDto? fromJson(dynamic value) { + static UserAdminUpdateDto? fromJson(dynamic value) { if (value is Map) { final json = value.cast(); - return UpdateUserDto( + return UserAdminUpdateDto( avatarColor: UserAvatarColor.fromJson(json[r'avatarColor']), email: mapValueOfType(json, r'email'), - id: mapValueOfType(json, r'id')!, - isAdmin: mapValueOfType(json, r'isAdmin'), memoriesEnabled: mapValueOfType(json, r'memoriesEnabled'), name: mapValueOfType(json, r'name'), password: mapValueOfType(json, r'password'), @@ -198,11 +168,11 @@ class UpdateUserDto { return null; } - static List listFromJson(dynamic json, {bool growable = false,}) { - final result = []; + static List listFromJson(dynamic json, {bool growable = false,}) { + final result = []; if (json is List && json.isNotEmpty) { for (final row in json) { - final value = UpdateUserDto.fromJson(row); + final value = UserAdminUpdateDto.fromJson(row); if (value != null) { result.add(value); } @@ -211,12 +181,12 @@ class UpdateUserDto { return result.toList(growable: growable); } - static Map mapFromJson(dynamic json) { - final map = {}; + static Map mapFromJson(dynamic json) { + final map = {}; if (json is Map && json.isNotEmpty) { json = json.cast(); // ignore: parameter_assignments for (final entry in json.entries) { - final value = UpdateUserDto.fromJson(entry.value); + final value = UserAdminUpdateDto.fromJson(entry.value); if (value != null) { map[entry.key] = value; } @@ -225,14 +195,14 @@ class UpdateUserDto { return map; } - // maps a json object with a list of UpdateUserDto-objects as value to a dart map - static Map> mapListFromJson(dynamic json, {bool growable = false,}) { - final map = >{}; + // maps a json object with a list of UserAdminUpdateDto-objects as value to a dart map + static Map> mapListFromJson(dynamic json, {bool growable = false,}) { + final map = >{}; if (json is Map && json.isNotEmpty) { // ignore: parameter_assignments json = json.cast(); for (final entry in json.entries) { - map[entry.key] = UpdateUserDto.listFromJson(entry.value, growable: growable,); + map[entry.key] = UserAdminUpdateDto.listFromJson(entry.value, growable: growable,); } } return map; @@ -240,7 +210,6 @@ class UpdateUserDto { /// The list of required keys that must be present in a JSON. static const requiredKeys = { - 'id', }; } diff --git a/mobile/openapi/lib/model/user_dto.dart b/mobile/openapi/lib/model/user_dto.dart deleted file mode 100644 index 1c4c4eb0b4..0000000000 --- a/mobile/openapi/lib/model/user_dto.dart +++ /dev/null @@ -1,130 +0,0 @@ -// -// AUTO-GENERATED FILE, DO NOT MODIFY! -// -// @dart=2.18 - -// ignore_for_file: unused_element, unused_import -// ignore_for_file: always_put_required_named_parameters_first -// ignore_for_file: constant_identifier_names -// ignore_for_file: lines_longer_than_80_chars - -part of openapi.api; - -class UserDto { - /// Returns a new [UserDto] instance. - UserDto({ - required this.avatarColor, - required this.email, - required this.id, - required this.name, - required this.profileImagePath, - }); - - UserAvatarColor avatarColor; - - String email; - - String id; - - String name; - - String profileImagePath; - - @override - bool operator ==(Object other) => identical(this, other) || other is UserDto && - other.avatarColor == avatarColor && - other.email == email && - other.id == id && - other.name == name && - other.profileImagePath == profileImagePath; - - @override - int get hashCode => - // ignore: unnecessary_parenthesis - (avatarColor.hashCode) + - (email.hashCode) + - (id.hashCode) + - (name.hashCode) + - (profileImagePath.hashCode); - - @override - String toString() => 'UserDto[avatarColor=$avatarColor, email=$email, id=$id, name=$name, profileImagePath=$profileImagePath]'; - - Map toJson() { - final json = {}; - json[r'avatarColor'] = this.avatarColor; - json[r'email'] = this.email; - json[r'id'] = this.id; - json[r'name'] = this.name; - json[r'profileImagePath'] = this.profileImagePath; - return json; - } - - /// Returns a new [UserDto] instance and imports its values from - /// [value] if it's a [Map], null otherwise. - // ignore: prefer_constructors_over_static_methods - static UserDto? fromJson(dynamic value) { - if (value is Map) { - final json = value.cast(); - - return UserDto( - avatarColor: UserAvatarColor.fromJson(json[r'avatarColor'])!, - email: mapValueOfType(json, r'email')!, - id: mapValueOfType(json, r'id')!, - name: mapValueOfType(json, r'name')!, - profileImagePath: mapValueOfType(json, r'profileImagePath')!, - ); - } - return null; - } - - static List listFromJson(dynamic json, {bool growable = false,}) { - final result = []; - if (json is List && json.isNotEmpty) { - for (final row in json) { - final value = UserDto.fromJson(row); - if (value != null) { - result.add(value); - } - } - } - return result.toList(growable: growable); - } - - static Map mapFromJson(dynamic json) { - final map = {}; - if (json is Map && json.isNotEmpty) { - json = json.cast(); // ignore: parameter_assignments - for (final entry in json.entries) { - final value = UserDto.fromJson(entry.value); - if (value != null) { - map[entry.key] = value; - } - } - } - return map; - } - - // maps a json object with a list of UserDto-objects as value to a dart map - static Map> mapListFromJson(dynamic json, {bool growable = false,}) { - final map = >{}; - if (json is Map && json.isNotEmpty) { - // ignore: parameter_assignments - json = json.cast(); - for (final entry in json.entries) { - map[entry.key] = UserDto.listFromJson(entry.value, growable: growable,); - } - } - return map; - } - - /// The list of required keys that must be present in a JSON. - static const requiredKeys = { - 'avatarColor', - 'email', - 'id', - 'name', - 'profileImagePath', - }; -} - diff --git a/mobile/openapi/lib/model/user_response_dto.dart b/mobile/openapi/lib/model/user_response_dto.dart index 063b3d33b6..41c1899848 100644 --- a/mobile/openapi/lib/model/user_response_dto.dart +++ b/mobile/openapi/lib/model/user_response_dto.dart @@ -14,141 +14,49 @@ class UserResponseDto { /// Returns a new [UserResponseDto] instance. UserResponseDto({ required this.avatarColor, - required this.createdAt, - required this.deletedAt, required this.email, required this.id, - required this.isAdmin, - this.memoriesEnabled, required this.name, - required this.oauthId, required this.profileImagePath, - required this.quotaSizeInBytes, - required this.quotaUsageInBytes, - required this.shouldChangePassword, - required this.status, - required this.storageLabel, - required this.updatedAt, }); UserAvatarColor avatarColor; - DateTime createdAt; - - DateTime? deletedAt; - String email; String id; - bool isAdmin; - - /// - /// Please note: This property should have been non-nullable! Since the specification file - /// does not include a default value (using the "default:" property), however, the generated - /// source code must fall back to having a nullable type. - /// Consider adding a "default:" property in the specification file to hide this note. - /// - bool? memoriesEnabled; - String name; - String oauthId; - String profileImagePath; - int? quotaSizeInBytes; - - int? quotaUsageInBytes; - - bool shouldChangePassword; - - UserStatus status; - - String? storageLabel; - - DateTime updatedAt; - @override bool operator ==(Object other) => identical(this, other) || other is UserResponseDto && other.avatarColor == avatarColor && - other.createdAt == createdAt && - other.deletedAt == deletedAt && other.email == email && other.id == id && - other.isAdmin == isAdmin && - other.memoriesEnabled == memoriesEnabled && other.name == name && - other.oauthId == oauthId && - other.profileImagePath == profileImagePath && - other.quotaSizeInBytes == quotaSizeInBytes && - other.quotaUsageInBytes == quotaUsageInBytes && - other.shouldChangePassword == shouldChangePassword && - other.status == status && - other.storageLabel == storageLabel && - other.updatedAt == updatedAt; + other.profileImagePath == profileImagePath; @override int get hashCode => // ignore: unnecessary_parenthesis (avatarColor.hashCode) + - (createdAt.hashCode) + - (deletedAt == null ? 0 : deletedAt!.hashCode) + (email.hashCode) + (id.hashCode) + - (isAdmin.hashCode) + - (memoriesEnabled == null ? 0 : memoriesEnabled!.hashCode) + (name.hashCode) + - (oauthId.hashCode) + - (profileImagePath.hashCode) + - (quotaSizeInBytes == null ? 0 : quotaSizeInBytes!.hashCode) + - (quotaUsageInBytes == null ? 0 : quotaUsageInBytes!.hashCode) + - (shouldChangePassword.hashCode) + - (status.hashCode) + - (storageLabel == null ? 0 : storageLabel!.hashCode) + - (updatedAt.hashCode); + (profileImagePath.hashCode); @override - String toString() => 'UserResponseDto[avatarColor=$avatarColor, createdAt=$createdAt, deletedAt=$deletedAt, email=$email, id=$id, isAdmin=$isAdmin, memoriesEnabled=$memoriesEnabled, name=$name, oauthId=$oauthId, profileImagePath=$profileImagePath, quotaSizeInBytes=$quotaSizeInBytes, quotaUsageInBytes=$quotaUsageInBytes, shouldChangePassword=$shouldChangePassword, status=$status, storageLabel=$storageLabel, updatedAt=$updatedAt]'; + String toString() => 'UserResponseDto[avatarColor=$avatarColor, email=$email, id=$id, name=$name, profileImagePath=$profileImagePath]'; Map toJson() { final json = {}; json[r'avatarColor'] = this.avatarColor; - json[r'createdAt'] = this.createdAt.toUtc().toIso8601String(); - if (this.deletedAt != null) { - json[r'deletedAt'] = this.deletedAt!.toUtc().toIso8601String(); - } else { - // json[r'deletedAt'] = null; - } json[r'email'] = this.email; json[r'id'] = this.id; - json[r'isAdmin'] = this.isAdmin; - if (this.memoriesEnabled != null) { - json[r'memoriesEnabled'] = this.memoriesEnabled; - } else { - // json[r'memoriesEnabled'] = null; - } json[r'name'] = this.name; - json[r'oauthId'] = this.oauthId; json[r'profileImagePath'] = this.profileImagePath; - if (this.quotaSizeInBytes != null) { - json[r'quotaSizeInBytes'] = this.quotaSizeInBytes; - } else { - // json[r'quotaSizeInBytes'] = null; - } - if (this.quotaUsageInBytes != null) { - json[r'quotaUsageInBytes'] = this.quotaUsageInBytes; - } else { - // json[r'quotaUsageInBytes'] = null; - } - json[r'shouldChangePassword'] = this.shouldChangePassword; - json[r'status'] = this.status; - if (this.storageLabel != null) { - json[r'storageLabel'] = this.storageLabel; - } else { - // json[r'storageLabel'] = null; - } - json[r'updatedAt'] = this.updatedAt.toUtc().toIso8601String(); return json; } @@ -161,21 +69,10 @@ class UserResponseDto { return UserResponseDto( avatarColor: UserAvatarColor.fromJson(json[r'avatarColor'])!, - createdAt: mapDateTime(json, r'createdAt', r'')!, - deletedAt: mapDateTime(json, r'deletedAt', r''), email: mapValueOfType(json, r'email')!, id: mapValueOfType(json, r'id')!, - isAdmin: mapValueOfType(json, r'isAdmin')!, - memoriesEnabled: mapValueOfType(json, r'memoriesEnabled'), name: mapValueOfType(json, r'name')!, - oauthId: mapValueOfType(json, r'oauthId')!, profileImagePath: mapValueOfType(json, r'profileImagePath')!, - quotaSizeInBytes: mapValueOfType(json, r'quotaSizeInBytes'), - quotaUsageInBytes: mapValueOfType(json, r'quotaUsageInBytes'), - shouldChangePassword: mapValueOfType(json, r'shouldChangePassword')!, - status: UserStatus.fromJson(json[r'status'])!, - storageLabel: mapValueOfType(json, r'storageLabel'), - updatedAt: mapDateTime(json, r'updatedAt', r'')!, ); } return null; @@ -224,20 +121,10 @@ class UserResponseDto { /// The list of required keys that must be present in a JSON. static const requiredKeys = { 'avatarColor', - 'createdAt', - 'deletedAt', 'email', 'id', - 'isAdmin', 'name', - 'oauthId', 'profileImagePath', - 'quotaSizeInBytes', - 'quotaUsageInBytes', - 'shouldChangePassword', - 'status', - 'storageLabel', - 'updatedAt', }; } 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 0000000000..1b54d4a383 --- /dev/null +++ b/mobile/openapi/lib/model/user_update_me_dto.dart @@ -0,0 +1,175 @@ +// +// AUTO-GENERATED FILE, DO NOT MODIFY! +// +// @dart=2.18 + +// ignore_for_file: unused_element, unused_import +// ignore_for_file: always_put_required_named_parameters_first +// ignore_for_file: constant_identifier_names +// ignore_for_file: lines_longer_than_80_chars + +part of openapi.api; + +class UserUpdateMeDto { + /// Returns a new [UserUpdateMeDto] instance. + UserUpdateMeDto({ + this.avatarColor, + this.email, + this.memoriesEnabled, + this.name, + this.password, + }); + + /// + /// Please note: This property should have been non-nullable! Since the specification file + /// does not include a default value (using the "default:" property), however, the generated + /// source code must fall back to having a nullable type. + /// Consider adding a "default:" property in the specification file to hide this note. + /// + UserAvatarColor? avatarColor; + + /// + /// Please note: This property should have been non-nullable! Since the specification file + /// does not include a default value (using the "default:" property), however, the generated + /// source code must fall back to having a nullable type. + /// Consider adding a "default:" property in the specification file to hide this note. + /// + String? email; + + /// + /// Please note: This property should have been non-nullable! Since the specification file + /// does not include a default value (using the "default:" property), however, the generated + /// source code must fall back to having a nullable type. + /// Consider adding a "default:" property in the specification file to hide this note. + /// + bool? memoriesEnabled; + + /// + /// Please note: This property should have been non-nullable! Since the specification file + /// does not include a default value (using the "default:" property), however, the generated + /// source code must fall back to having a nullable type. + /// Consider adding a "default:" property in the specification file to hide this note. + /// + String? name; + + /// + /// Please note: This property should have been non-nullable! Since the specification file + /// does not include a default value (using the "default:" property), however, the generated + /// source code must fall back to having a nullable type. + /// Consider adding a "default:" property in the specification file to hide this note. + /// + String? password; + + @override + bool operator ==(Object other) => identical(this, other) || other is UserUpdateMeDto && + other.avatarColor == avatarColor && + other.email == email && + other.memoriesEnabled == memoriesEnabled && + other.name == name && + other.password == password; + + @override + int get hashCode => + // ignore: unnecessary_parenthesis + (avatarColor == null ? 0 : avatarColor!.hashCode) + + (email == null ? 0 : email!.hashCode) + + (memoriesEnabled == null ? 0 : memoriesEnabled!.hashCode) + + (name == null ? 0 : name!.hashCode) + + (password == null ? 0 : password!.hashCode); + + @override + String toString() => 'UserUpdateMeDto[avatarColor=$avatarColor, email=$email, memoriesEnabled=$memoriesEnabled, name=$name, password=$password]'; + + Map toJson() { + final json = {}; + if (this.avatarColor != null) { + json[r'avatarColor'] = this.avatarColor; + } else { + // json[r'avatarColor'] = null; + } + if (this.email != null) { + json[r'email'] = this.email; + } else { + // json[r'email'] = null; + } + if (this.memoriesEnabled != null) { + json[r'memoriesEnabled'] = this.memoriesEnabled; + } else { + // json[r'memoriesEnabled'] = null; + } + if (this.name != null) { + json[r'name'] = this.name; + } else { + // json[r'name'] = null; + } + if (this.password != null) { + json[r'password'] = this.password; + } else { + // json[r'password'] = null; + } + return json; + } + + /// Returns a new [UserUpdateMeDto] instance and imports its values from + /// [value] if it's a [Map], null otherwise. + // ignore: prefer_constructors_over_static_methods + static UserUpdateMeDto? fromJson(dynamic value) { + if (value is Map) { + final json = value.cast(); + + return UserUpdateMeDto( + avatarColor: UserAvatarColor.fromJson(json[r'avatarColor']), + email: mapValueOfType(json, r'email'), + memoriesEnabled: mapValueOfType(json, r'memoriesEnabled'), + name: mapValueOfType(json, r'name'), + password: mapValueOfType(json, r'password'), + ); + } + return null; + } + + static List listFromJson(dynamic json, {bool growable = false,}) { + final result = []; + if (json is List && json.isNotEmpty) { + for (final row in json) { + final value = UserUpdateMeDto.fromJson(row); + if (value != null) { + result.add(value); + } + } + } + return result.toList(growable: growable); + } + + static Map mapFromJson(dynamic json) { + final map = {}; + if (json is Map && json.isNotEmpty) { + json = json.cast(); // ignore: parameter_assignments + for (final entry in json.entries) { + final value = UserUpdateMeDto.fromJson(entry.value); + if (value != null) { + map[entry.key] = value; + } + } + } + return map; + } + + // maps a json object with a list of UserUpdateMeDto-objects as value to a dart map + static Map> mapListFromJson(dynamic json, {bool growable = false,}) { + final map = >{}; + if (json is Map && json.isNotEmpty) { + // ignore: parameter_assignments + json = json.cast(); + for (final entry in json.entries) { + map[entry.key] = UserUpdateMeDto.listFromJson(entry.value, growable: growable,); + } + } + return map; + } + + /// The list of required keys that must be present in a JSON. + static const requiredKeys = { + }; +} + diff --git a/open-api/immich-openapi-specs.json b/open-api/immich-openapi-specs.json index 82e328bc47..558823e62b 100644 --- a/open-api/immich-openapi-specs.json +++ b/open-api/immich-openapi-specs.json @@ -206,6 +206,274 @@ ] } }, + "/admin/users": { + "get": { + "operationId": "searchUsersAdmin", + "parameters": [ + { + "name": "withDeleted", + "required": false, + "in": "query", + "schema": { + "type": "boolean" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "items": { + "$ref": "#/components/schemas/UserAdminResponseDto" + }, + "type": "array" + } + } + }, + "description": "" + } + }, + "security": [ + { + "bearer": [] + }, + { + "cookie": [] + }, + { + "api_key": [] + } + ], + "tags": [ + "User" + ] + }, + "post": { + "operationId": "createUserAdmin", + "parameters": [], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UserAdminCreateDto" + } + } + }, + "required": true + }, + "responses": { + "201": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UserAdminResponseDto" + } + } + }, + "description": "" + } + }, + "security": [ + { + "bearer": [] + }, + { + "cookie": [] + }, + { + "api_key": [] + } + ], + "tags": [ + "User" + ] + } + }, + "/admin/users/{id}": { + "delete": { + "operationId": "deleteUserAdmin", + "parameters": [ + { + "name": "id", + "required": true, + "in": "path", + "schema": { + "format": "uuid", + "type": "string" + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UserAdminDeleteDto" + } + } + }, + "required": true + }, + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UserAdminResponseDto" + } + } + }, + "description": "" + } + }, + "security": [ + { + "bearer": [] + }, + { + "cookie": [] + }, + { + "api_key": [] + } + ], + "tags": [ + "User" + ] + }, + "get": { + "operationId": "getUserAdmin", + "parameters": [ + { + "name": "id", + "required": true, + "in": "path", + "schema": { + "format": "uuid", + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UserAdminResponseDto" + } + } + }, + "description": "" + } + }, + "security": [ + { + "bearer": [] + }, + { + "cookie": [] + }, + { + "api_key": [] + } + ], + "tags": [ + "User" + ] + }, + "put": { + "operationId": "updateUserAdmin", + "parameters": [ + { + "name": "id", + "required": true, + "in": "path", + "schema": { + "format": "uuid", + "type": "string" + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UserAdminUpdateDto" + } + } + }, + "required": true + }, + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UserAdminResponseDto" + } + } + }, + "description": "" + } + }, + "security": [ + { + "bearer": [] + }, + { + "cookie": [] + }, + { + "api_key": [] + } + ], + "tags": [ + "User" + ] + } + }, + "/admin/users/{id}/restore": { + "post": { + "operationId": "restoreUserAdmin", + "parameters": [ + { + "name": "id", + "required": true, + "in": "path", + "schema": { + "format": "uuid", + "type": "string" + } + } + ], + "responses": { + "201": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UserAdminResponseDto" + } + } + }, + "description": "" + } + }, + "security": [ + { + "bearer": [] + }, + { + "cookie": [] + }, + { + "api_key": [] + } + ], + "tags": [ + "User" + ] + } + }, "/albums": { "get": { "operationId": "getAllAlbums", @@ -1879,7 +2147,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/UserResponseDto" + "$ref": "#/components/schemas/UserAdminResponseDto" } } }, @@ -1910,7 +2178,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/UserResponseDto" + "$ref": "#/components/schemas/UserAdminResponseDto" } } }, @@ -3160,7 +3428,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/UserResponseDto" + "$ref": "#/components/schemas/UserAdminResponseDto" } } }, @@ -3206,7 +3474,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/UserResponseDto" + "$ref": "#/components/schemas/UserAdminResponseDto" } } }, @@ -6030,17 +6298,8 @@ }, "/users": { "get": { - "operationId": "getAllUsers", - "parameters": [ - { - "name": "isAll", - "required": true, - "in": "query", - "schema": { - "type": "boolean" - } - } - ], + "operationId": "searchUsers", + "parameters": [], "responses": { "200": { "content": { @@ -6070,26 +6329,18 @@ "tags": [ "User" ] - }, - "post": { - "operationId": "createUser", + } + }, + "/users/me": { + "get": { + "operationId": "getMyUser", "parameters": [], - "requestBody": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/CreateUserDto" - } - } - }, - "required": true - }, "responses": { - "201": { + "200": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/UserResponseDto" + "$ref": "#/components/schemas/UserAdminResponseDto" } } }, @@ -6112,13 +6363,13 @@ ] }, "put": { - "operationId": "updateUser", + "operationId": "updateMyUser", "parameters": [], "requestBody": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/UpdateUserDto" + "$ref": "#/components/schemas/UserUpdateMeDto" } } }, @@ -6129,39 +6380,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/UserResponseDto" - } - } - }, - "description": "" - } - }, - "security": [ - { - "bearer": [] - }, - { - "cookie": [] - }, - { - "api_key": [] - } - ], - "tags": [ - "User" - ] - } - }, - "/users/me": { - "get": { - "operationId": "getMyUserInfo", - "parameters": [], - "responses": { - "200": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/UserResponseDto" + "$ref": "#/components/schemas/UserAdminResponseDto" } } }, @@ -6251,58 +6470,8 @@ } }, "/users/{id}": { - "delete": { - "operationId": "deleteUser", - "parameters": [ - { - "name": "id", - "required": true, - "in": "path", - "schema": { - "format": "uuid", - "type": "string" - } - } - ], - "requestBody": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/DeleteUserDto" - } - } - }, - "required": true - }, - "responses": { - "200": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/UserResponseDto" - } - } - }, - "description": "" - } - }, - "security": [ - { - "bearer": [] - }, - { - "cookie": [] - }, - { - "api_key": [] - } - ], - "tags": [ - "User" - ] - }, "get": { - "operationId": "getUserById", + "operationId": "getUser", "parameters": [ { "name": "id", @@ -6384,48 +6553,6 @@ "User" ] } - }, - "/users/{id}/restore": { - "post": { - "operationId": "restoreUser", - "parameters": [ - { - "name": "id", - "required": true, - "in": "path", - "schema": { - "format": "uuid", - "type": "string" - } - } - ], - "responses": { - "201": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/UserResponseDto" - } - } - }, - "description": "" - } - }, - "security": [ - { - "bearer": [] - }, - { - "cookie": [] - }, - { - "api_key": [] - } - ], - "tags": [ - "User" - ] - } } }, "info": { @@ -6567,7 +6694,7 @@ "type": "string" }, "user": { - "$ref": "#/components/schemas/UserDto" + "$ref": "#/components/schemas/UserResponseDto" } }, "required": [ @@ -7775,52 +7902,6 @@ ], "type": "object" }, - "CreateUserDto": { - "properties": { - "email": { - "type": "string" - }, - "memoriesEnabled": { - "type": "boolean" - }, - "name": { - "type": "string" - }, - "notify": { - "type": "boolean" - }, - "password": { - "type": "string" - }, - "quotaSizeInBytes": { - "format": "int64", - "minimum": 1, - "nullable": true, - "type": "integer" - }, - "shouldChangePassword": { - "type": "boolean" - }, - "storageLabel": { - "nullable": true, - "type": "string" - } - }, - "required": [ - "email", - "name", - "password" - ], - "type": "object" - }, - "DeleteUserDto": { - "properties": { - "force": { - "type": "boolean" - } - }, - "type": "object" - }, "DownloadArchiveInfo": { "properties": { "assetIds": { @@ -8803,15 +8884,6 @@ "avatarColor": { "$ref": "#/components/schemas/UserAvatarColor" }, - "createdAt": { - "format": "date-time", - "type": "string" - }, - "deletedAt": { - "format": "date-time", - "nullable": true, - "type": "string" - }, "email": { "type": "string" }, @@ -8821,62 +8893,19 @@ "inTimeline": { "type": "boolean" }, - "isAdmin": { - "type": "boolean" - }, - "memoriesEnabled": { - "type": "boolean" - }, "name": { "type": "string" }, - "oauthId": { - "type": "string" - }, "profileImagePath": { "type": "string" - }, - "quotaSizeInBytes": { - "format": "int64", - "nullable": true, - "type": "integer" - }, - "quotaUsageInBytes": { - "format": "int64", - "nullable": true, - "type": "integer" - }, - "shouldChangePassword": { - "type": "boolean" - }, - "status": { - "$ref": "#/components/schemas/UserStatus" - }, - "storageLabel": { - "nullable": true, - "type": "string" - }, - "updatedAt": { - "format": "date-time", - "type": "string" } }, "required": [ "avatarColor", - "createdAt", - "deletedAt", "email", "id", - "isAdmin", "name", - "oauthId", - "profileImagePath", - "quotaSizeInBytes", - "quotaUsageInBytes", - "shouldChangePassword", - "status", - "storageLabel", - "updatedAt" + "profileImagePath" ], "type": "object" }, @@ -10810,48 +10839,6 @@ }, "type": "object" }, - "UpdateUserDto": { - "properties": { - "avatarColor": { - "$ref": "#/components/schemas/UserAvatarColor" - }, - "email": { - "type": "string" - }, - "id": { - "format": "uuid", - "type": "string" - }, - "isAdmin": { - "type": "boolean" - }, - "memoriesEnabled": { - "type": "boolean" - }, - "name": { - "type": "string" - }, - "password": { - "type": "string" - }, - "quotaSizeInBytes": { - "format": "int64", - "minimum": 1, - "nullable": true, - "type": "integer" - }, - "shouldChangePassword": { - "type": "boolean" - }, - "storageLabel": { - "type": "string" - } - }, - "required": [ - "id" - ], - "type": "object" - }, "UsageByUserDto": { "properties": { "photos": { @@ -10886,49 +10873,53 @@ ], "type": "object" }, - "UserAvatarColor": { - "enum": [ - "primary", - "pink", - "red", - "yellow", - "blue", - "green", - "purple", - "orange", - "gray", - "amber" - ], - "type": "string" - }, - "UserDto": { + "UserAdminCreateDto": { "properties": { - "avatarColor": { - "$ref": "#/components/schemas/UserAvatarColor" - }, "email": { "type": "string" }, - "id": { - "type": "string" + "memoriesEnabled": { + "type": "boolean" }, "name": { "type": "string" }, - "profileImagePath": { + "notify": { + "type": "boolean" + }, + "password": { + "type": "string" + }, + "quotaSizeInBytes": { + "format": "int64", + "minimum": 1, + "nullable": true, + "type": "integer" + }, + "shouldChangePassword": { + "type": "boolean" + }, + "storageLabel": { + "nullable": true, "type": "string" } }, "required": [ - "avatarColor", "email", - "id", "name", - "profileImagePath" + "password" ], "type": "object" }, - "UserResponseDto": { + "UserAdminDeleteDto": { + "properties": { + "force": { + "type": "boolean" + } + }, + "type": "object" + }, + "UserAdminResponseDto": { "properties": { "avatarColor": { "$ref": "#/components/schemas/UserAvatarColor" @@ -11007,6 +10998,81 @@ ], "type": "object" }, + "UserAdminUpdateDto": { + "properties": { + "avatarColor": { + "$ref": "#/components/schemas/UserAvatarColor" + }, + "email": { + "type": "string" + }, + "memoriesEnabled": { + "type": "boolean" + }, + "name": { + "type": "string" + }, + "password": { + "type": "string" + }, + "quotaSizeInBytes": { + "format": "int64", + "minimum": 1, + "nullable": true, + "type": "integer" + }, + "shouldChangePassword": { + "type": "boolean" + }, + "storageLabel": { + "nullable": true, + "type": "string" + } + }, + "type": "object" + }, + "UserAvatarColor": { + "enum": [ + "primary", + "pink", + "red", + "yellow", + "blue", + "green", + "purple", + "orange", + "gray", + "amber" + ], + "type": "string" + }, + "UserResponseDto": { + "properties": { + "avatarColor": { + "$ref": "#/components/schemas/UserAvatarColor" + }, + "email": { + "type": "string" + }, + "id": { + "type": "string" + }, + "name": { + "type": "string" + }, + "profileImagePath": { + "type": "string" + } + }, + "required": [ + "avatarColor", + "email", + "id", + "name", + "profileImagePath" + ], + "type": "object" + }, "UserStatus": { "enum": [ "active", @@ -11015,6 +11081,26 @@ ], "type": "string" }, + "UserUpdateMeDto": { + "properties": { + "avatarColor": { + "$ref": "#/components/schemas/UserAvatarColor" + }, + "email": { + "type": "string" + }, + "memoriesEnabled": { + "type": "boolean" + }, + "name": { + "type": "string" + }, + "password": { + "type": "string" + } + }, + "type": "object" + }, "ValidateAccessTokenResponseDto": { "properties": { "authStatus": { diff --git a/open-api/typescript-sdk/README.md b/open-api/typescript-sdk/README.md index 91b702d43e..53a83a4237 100644 --- a/open-api/typescript-sdk/README.md +++ b/open-api/typescript-sdk/README.md @@ -13,13 +13,22 @@ npm i --save @immich/sdk For a more detailed example, check out the [`@immich/cli`](https://github.com/immich-app/immich/tree/main/cli). ```typescript +<<<<<<< HEAD +import { getAllAlbums, getAllAssets, getMyUser, init } from "@immich/sdk"; +======= import { getAllAlbums, getMyUserInfo, init } from "@immich/sdk"; +>>>>>>> e7c8501930a988dfb6c23ce1c48b0beb076a58c2 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 @@