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/docker/docker-compose.dev.yml b/docker/docker-compose.dev.yml index 29cea1a873..1375ad7667 100644 --- a/docker/docker-compose.dev.yml +++ b/docker/docker-compose.dev.yml @@ -9,6 +9,9 @@ services: container_name: immich_server command: ['/usr/src/app/bin/immich-dev'] image: immich-server-dev:latest + # extends: + # file: hwaccel.transcoding.yml + # service: cpu # set to one of [nvenc, quicksync, rkmpp, vaapi, vaapi-wsl] for accelerated transcoding build: context: ../ dockerfile: server/Dockerfile diff --git a/docker/docker-compose.prod.yml b/docker/docker-compose.prod.yml index 6168ea66dd..c19509eb26 100644 --- a/docker/docker-compose.prod.yml +++ b/docker/docker-compose.prod.yml @@ -4,6 +4,9 @@ services: immich-server: container_name: immich_server image: immich-server:latest + # extends: + # file: hwaccel.transcoding.yml + # service: cpu # set to one of [nvenc, quicksync, rkmpp, vaapi, vaapi-wsl] for accelerated transcoding build: context: ../ dockerfile: server/Dockerfile diff --git a/docker/docker-compose.yml b/docker/docker-compose.yml index 2669db5080..62833d323c 100644 --- a/docker/docker-compose.yml +++ b/docker/docker-compose.yml @@ -12,6 +12,9 @@ services: immich-server: container_name: immich_server image: ghcr.io/immich-app/immich-server:${IMMICH_VERSION:-release} + # extends: + # file: hwaccel.transcoding.yml + # service: cpu # set to one of [nvenc, quicksync, rkmpp, vaapi, vaapi-wsl] for accelerated transcoding volumes: - ${UPLOAD_LOCATION}:/usr/src/app/upload - /etc/localtime:/etc/localtime:ro diff --git a/docs/docs/administration/img/google-example.webp b/docs/docs/administration/img/google-example.webp new file mode 100644 index 0000000000..742e77cd37 Binary files /dev/null and b/docs/docs/administration/img/google-example.webp differ diff --git a/docs/docs/administration/img/immich-google-example.webp b/docs/docs/administration/img/immich-google-example.webp new file mode 100644 index 0000000000..ed6c31432d Binary files /dev/null and b/docs/docs/administration/img/immich-google-example.webp differ diff --git a/docs/docs/administration/oauth.md b/docs/docs/administration/oauth.md index 6fcc47d6a4..2ee84970df 100644 --- a/docs/docs/administration/oauth.md +++ b/docs/docs/administration/oauth.md @@ -110,8 +110,44 @@ Immich has a route (`/api/oauth/mobile-redirect`) that is already configured to ## Example Configuration +
+Authentik Example + +### Authentik Example + Here's an example of OAuth configured for Authentik: -![OAuth Settings](./img/oauth-settings.png) + + +
+ +
+Google Example + +### Google Example + +Configuration of Authorised redirect URIs (Google Console) + + + +Configuration of OAuth in System Settings + +| Setting | Value | +| ---------------------------- | ------------------------------------------------------------------------------------------------------ | +| Issuer URL | [https://accounts.google.com](https://accounts.google.com) | +| Client ID | 7\***\*\*\*\*\*\*\***\*\*\***\*\*\*\*\*\*\***vuls.apps.googleusercontent.com | +| Client Secret | G\***\*\*\*\*\*\*\***\*\*\***\*\*\*\*\*\*\***OO | +| Scope | openid email profile | +| Signing Algorithm | RS256 | +| Storage Label Claim | preferred_username | +| Storage Quota Claim | immich_quota | +| Default Storage Quota (GiB) | 0 (0 for unlimited quota) | +| Button Text | Sign in with Google (optional) | +| Auto Register | Enabled (optional) | +| Auto Launch | Enabled | +| Mobile Redirect URI Override | Enabled (required) | +| Mobile Redirect URI | [https://demo.immich.app/api/oauth/mobile-redirect](https://demo.immich.app/api/oauth/mobile-redirect) | + +
[oidc]: https://openid.net/connect/ 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 e3dedf84e0..d96b9d67d2 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'; @@ -1168,7 +1168,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/pages/common/album_viewer.page.dart b/mobile/lib/pages/common/album_viewer.page.dart index a7cb8c218a..e1e0419d52 100644 --- a/mobile/lib/pages/common/album_viewer.page.dart +++ b/mobile/lib/pages/common/album_viewer.page.dart @@ -133,7 +133,7 @@ class AlbumViewerPage extends HookConsumerWidget { Widget buildTitle(Album album) { return Padding( - padding: const EdgeInsets.only(left: 8, right: 8, top: 24), + padding: const EdgeInsets.only(left: 8, right: 8), child: userId == album.ownerId && album.isRemote ? AlbumViewerEditableTitle( album: album, @@ -228,9 +228,30 @@ class AlbumViewerPage extends HookConsumerWidget { } return Scaffold( - appBar: ref.watch(multiselectProvider) - ? null - : album.when( + body: Stack( + children: [ + album.widgetWhen( + onData: (data) => MultiselectGrid( + renderListProvider: albumRenderlistProvider(albumId), + topWidget: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + buildHeader(data), + if (data.isRemote) buildControlButton(data), + ], + ), + onRemoveFromAlbum: onRemoveFromAlbumPressed, + editEnabled: data.ownerId == userId, + ), + ), + AnimatedPositioned( + duration: const Duration(milliseconds: 300), + top: ref.watch(multiselectProvider) + ? -(kToolbarHeight + MediaQuery.of(context).padding.top) + : 0, + left: 0, + right: 0, + child: album.when( data: (data) => AlbumViewerAppbar( titleFocusNode: titleFocusNode, album: data, @@ -242,19 +263,8 @@ class AlbumViewerPage extends HookConsumerWidget { error: (error, stackTrace) => AppBar(title: const Text("Error")), loading: () => AppBar(), ), - body: album.widgetWhen( - onData: (data) => MultiselectGrid( - renderListProvider: albumRenderlistProvider(albumId), - topWidget: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - buildHeader(data), - if (data.isRemote) buildControlButton(data), - ], ), - onRemoveFromAlbum: onRemoveFromAlbumPressed, - editEnabled: data.ownerId == userId, - ), + ], ), ); } 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/lib/widgets/album/album_viewer_editable_title.dart b/mobile/lib/widgets/album/album_viewer_editable_title.dart index 5114d7ba7f..788c61d8a4 100644 --- a/mobile/lib/widgets/album/album_viewer_editable_title.dart +++ b/mobile/lib/widgets/album/album_viewer_editable_title.dart @@ -36,58 +36,62 @@ class AlbumViewerEditableTitle extends HookConsumerWidget { [], ); - return TextField( - onChanged: (value) { - if (value.isEmpty) { - } else { - ref.watch(albumViewerProvider.notifier).setEditTitleText(value); - } - }, - focusNode: titleFocusNode, - style: context.textTheme.headlineMedium, - controller: titleTextEditController, - onTap: () { - FocusScope.of(context).requestFocus(titleFocusNode); + return Material( + color: Colors.transparent, + child: TextField( + onChanged: (value) { + if (value.isEmpty) { + } else { + ref.watch(albumViewerProvider.notifier).setEditTitleText(value); + } + }, + focusNode: titleFocusNode, + style: context.textTheme.headlineMedium, + controller: titleTextEditController, + onTap: () { + FocusScope.of(context).requestFocus(titleFocusNode); - ref.watch(albumViewerProvider.notifier).setEditTitleText(album.name); - ref.watch(albumViewerProvider.notifier).enableEditAlbum(); + ref.watch(albumViewerProvider.notifier).setEditTitleText(album.name); + ref.watch(albumViewerProvider.notifier).enableEditAlbum(); - if (titleTextEditController.text == 'Untitled') { - titleTextEditController.clear(); - } - }, - decoration: InputDecoration( - contentPadding: const EdgeInsets.symmetric(horizontal: 8, vertical: 8), - suffixIcon: titleFocusNode.hasFocus - ? IconButton( - onPressed: () { - titleTextEditController.clear(); - }, - icon: Icon( - Icons.cancel_rounded, - color: context.primaryColor, - ), - splashRadius: 10, - ) - : null, - enabledBorder: OutlineInputBorder( - borderSide: const BorderSide(color: Colors.transparent), - borderRadius: BorderRadius.circular(10), - ), - focusedBorder: OutlineInputBorder( - borderSide: const BorderSide(color: Colors.transparent), - borderRadius: BorderRadius.circular(10), - ), - focusColor: Colors.grey[300], - fillColor: context.isDarkTheme - ? const Color.fromARGB(255, 32, 33, 35) - : Colors.grey[200], - filled: titleFocusNode.hasFocus, - hintText: 'share_add_title'.tr(), - hintStyle: TextStyle( - fontSize: 28, - color: context.isDarkTheme ? Colors.grey[300] : Colors.grey[700], - fontWeight: FontWeight.bold, + if (titleTextEditController.text == 'Untitled') { + titleTextEditController.clear(); + } + }, + decoration: InputDecoration( + contentPadding: + const EdgeInsets.symmetric(horizontal: 8, vertical: 8), + suffixIcon: titleFocusNode.hasFocus + ? IconButton( + onPressed: () { + titleTextEditController.clear(); + }, + icon: Icon( + Icons.cancel_rounded, + color: context.primaryColor, + ), + splashRadius: 10, + ) + : null, + enabledBorder: OutlineInputBorder( + borderSide: const BorderSide(color: Colors.transparent), + borderRadius: BorderRadius.circular(10), + ), + focusedBorder: OutlineInputBorder( + borderSide: const BorderSide(color: Colors.transparent), + borderRadius: BorderRadius.circular(10), + ), + focusColor: Colors.grey[300], + fillColor: context.isDarkTheme + ? const Color.fromARGB(255, 32, 33, 35) + : Colors.grey[200], + filled: titleFocusNode.hasFocus, + hintText: 'share_add_title'.tr(), + hintStyle: TextStyle( + fontSize: 28, + color: context.isDarkTheme ? Colors.grey[300] : Colors.grey[700], + fontWeight: FontWeight.bold, + ), ), ), ); diff --git a/mobile/lib/widgets/asset_grid/immich_asset_grid_view.dart b/mobile/lib/widgets/asset_grid/immich_asset_grid_view.dart index 40c6c52914..61104d282d 100644 --- a/mobile/lib/widgets/asset_grid/immich_asset_grid_view.dart +++ b/mobile/lib/widgets/asset_grid/immich_asset_grid_view.dart @@ -238,8 +238,10 @@ class ImmichAssetGridViewState extends ConsumerState { } bool appBarOffset() { - return ref.watch(tabProvider).index == 0 && - ModalRoute.of(context)?.settings.name == TabControllerRoute.name; + return (ref.watch(tabProvider).index == 0 && + ModalRoute.of(context)?.settings.name == + TabControllerRoute.name) || + (ModalRoute.of(context)?.settings.name == AlbumViewerRoute.name); } final listWidget = ScrollablePositionedList.builder( 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/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 ab4f606b09..84a82e8644 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" } } }, @@ -3200,7 +3468,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/UserResponseDto" + "$ref": "#/components/schemas/UserAdminResponseDto" } } }, @@ -3246,7 +3514,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/UserResponseDto" + "$ref": "#/components/schemas/UserAdminResponseDto" } } }, @@ -6102,17 +6370,8 @@ }, "/users": { "get": { - "operationId": "getAllUsers", - "parameters": [ - { - "name": "isAll", - "required": true, - "in": "query", - "schema": { - "type": "boolean" - } - } - ], + "operationId": "searchUsers", + "parameters": [], "responses": { "200": { "content": { @@ -6142,26 +6401,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" } } }, @@ -6184,13 +6435,13 @@ ] }, "put": { - "operationId": "updateUser", + "operationId": "updateMyUser", "parameters": [], "requestBody": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/UpdateUserDto" + "$ref": "#/components/schemas/UserUpdateMeDto" } } }, @@ -6201,39 +6452,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" } } }, @@ -6323,58 +6542,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", @@ -6456,48 +6625,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": { @@ -6639,7 +6766,7 @@ "type": "string" }, "user": { - "$ref": "#/components/schemas/UserDto" + "$ref": "#/components/schemas/UserResponseDto" } }, "required": [ @@ -7844,52 +7971,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": { @@ -8872,15 +8953,6 @@ "avatarColor": { "$ref": "#/components/schemas/UserAvatarColor" }, - "createdAt": { - "format": "date-time", - "type": "string" - }, - "deletedAt": { - "format": "date-time", - "nullable": true, - "type": "string" - }, "email": { "type": "string" }, @@ -8890,62 +8962,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" }, @@ -10897,48 +10926,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": { @@ -10973,49 +10960,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" @@ -11094,6 +11085,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", @@ -11102,6 +11168,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 19c7df34a3..9d04758366 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; @@ -521,22 +544,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; @@ -1064,27 +1076,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; @@ -1093,9 +1090,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; @@ -1150,6 +1144,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; @@ -1593,7 +1658,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", @@ -1605,7 +1670,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", @@ -1949,7 +2014,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", @@ -1964,7 +2029,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" @@ -2714,50 +2779,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, @@ -2776,20 +2825,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<{ @@ -2809,17 +2845,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" @@ -2844,15 +2869,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/readme_i18n/README_fr_FR.md b/readme_i18n/README_fr_FR.md index 08be3cc02f..47f0dab740 100644 --- a/readme_i18n/README_fr_FR.md +++ b/readme_i18n/README_fr_FR.md @@ -11,7 +11,7 @@

-

Immich - Solution de sauvegarde performante et auto-hébergée des photos et des vidéos

+

Immich - Solution de sauvegarde performante et auto-hébergée de photos et de vidéos


@@ -36,16 +36,16 @@ ## Clause de non-responsabilité - ⚠️ Le projet est en **très fort** développement. -- ⚠️ Attendez-vous à rencontrer des bugs et des changements importants. -- ⚠️ **N'utilisez pas cette application comme seule façon de sauvegarder vos photos et vos vidéos.** +- ⚠️ Attendez-vous à rencontrer des bogues et des changements importants. +- ⚠️ **N'utilisez pas cette application comme seul support de sauvegarde de vos photos et vos vidéos.** - ⚠️ Ayez toujours un plan de sauvegarde en [3-2-1](https://www.seagate.com/fr/fr/blog/what-is-a-3-2-1-backup-strategy/) pour vos précieuses photos et vidéos ! ## Sommaire - [Documentation officielle](https://immich.app/docs) - [Feuille de route](https://github.com/orgs/immich-app/projects/1) -- [Démo](#demo) -- [Fonctionnalités](#features) +- [Démo](#démo) +- [Fonctionnalités](#fonctionnalités) - [Introduction](https://immich.app/docs/overview/introduction) - [Installation](https://immich.app/docs/install/requirements) - [Contribution](https://immich.app/docs/overview/support-the-project) @@ -56,26 +56,31 @@ Vous pouvez trouver la documentation principale ainsi que les guides d'installat ## Démo -Vous pouvez accéder à la démo Web sur https://demo.immich.app +Vous pouvez accéder à la démo en ligne sur https://demo.immich.app -Pour l'application mobile, vous pouvez utiliser `https://demo.immich.app/api` dans le champ 'URL du point d'accès au serveur' +Pour l'application mobile, vous pouvez utiliser `https://demo.immich.app/api` dans le champ `URL du point d'accès au serveur` -```bash title="Demo Credential" +```bash title="Identifiants pour la démo" Les identifiants email: demo@immich.app mot de passe: demo ``` ``` -Caractéristiques: Plan gratuit Oracle VM - Amsterdam - 2.4Ghz quatre-cœurs ARM64 CPU, 24GB RAM +Caractéristiques : Plan gratuit Oracle VM - Amsterdam - 2.4Ghz quatre-cœurs ARM64 CPU, 24GB RAM ``` -# Fonctionnalités +## Activités + +![Activités](https://repobeats.axiom.co/api/embed/9e86d9dc3ddd137161f2f6d2e758d7863b1789cb.svg "Image des statistiques Repobeats") + +## Fonctionnalités | Fonctionnalités | Mobile | Web | | ---------------------------------------------------------------- | ------ | --- | | Téléverser et voir les vidéos et photos | Oui | Oui | | Sauvegarde automatique quand l'application est ouverte | Oui | N/A | +| Prévention contre la duplication des photos et des vidéos | Oui | Oui | | Sélection des albums à sauvegarder | Oui | N/A | | Télécharger les photos et les vidéos sur l'appareil | Oui | Oui | | Support multi-utilisateur | Oui | Oui | @@ -89,13 +94,32 @@ Caractéristiques: Plan gratuit Oracle VM - Amsterdam - 2.4Ghz quatre-cœurs ARM | Défilement virtuel | Oui | Oui | | Support de l'OAuth | Oui | Oui | | Clés d'API | N/A | Oui | -| Sauvegarde et lecture des LivePhotos | iOS | Oui | +| Sauvegarde et lecture des LivePhoto/MotionPhoto | Oui | Oui | +| Support de l'affichage des images à 360° | Non | Oui | | Structure de stockage définissable | Oui | Oui | | Partage public | Non | Oui | | Archives et favoris | Oui | Oui | -| Carte globale | Non | Oui | +| Carte globale | Oui | Oui | | Partage entre utilisateurs | Oui | Oui | | Reconnaissance et regroupement facial | Oui | Oui | | Souvenirs (il y a x années) | Oui | Oui | | Support hors-ligne | Oui | Non | | Gallerie en lecture seule | Oui | Oui | +| Empilage de photos | Oui | Oui | + + +## Contributeurs + + + + + +## Historique des favoris + + + + + + Star History Chart + + 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/config.ts b/server/src/config.ts index 0f8a645005..4d1704c47f 100644 --- a/server/src/config.ts +++ b/server/src/config.ts @@ -256,8 +256,8 @@ export const defaults = Object.freeze({ modelName: 'ViT-B-32__openai', }, duplicateDetection: { - enabled: false, - maxDistance: 0.03, + enabled: true, + maxDistance: 0.0155, }, facialRecognition: { enabled: true, 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/interfaces/media.interface.ts b/server/src/interfaces/media.interface.ts index 80beb8c436..11ee525f8a 100644 --- a/server/src/interfaces/media.interface.ts +++ b/server/src/interfaces/media.interface.ts @@ -53,7 +53,7 @@ export interface VideoInfo { audioStreams: AudioStreamInfo[]; } -export interface TranscodeOptions { +export interface TranscodeCommand { inputOptions: string[]; outputOptions: string[]; twoPass: boolean; @@ -67,7 +67,7 @@ export interface BitrateDistribution { } export interface VideoCodecSWConfig { - getOptions(target: TranscodeTarget, videoStream: VideoStreamInfo, audioStream: AudioStreamInfo): TranscodeOptions; + getCommand(target: TranscodeTarget, videoStream: VideoStreamInfo, audioStream: AudioStreamInfo): TranscodeCommand; } export interface VideoCodecHWConfig extends VideoCodecSWConfig { @@ -83,5 +83,5 @@ export interface IMediaRepository { // video probe(input: string): Promise; - transcode(input: string, output: string | Writable, options: TranscodeOptions): Promise; + transcode(input: string, output: string | Writable, command: TranscodeCommand): Promise; } diff --git a/server/src/interfaces/search.interface.ts b/server/src/interfaces/search.interface.ts index 57523aa940..ce9e2a1940 100644 --- a/server/src/interfaces/search.interface.ts +++ b/server/src/interfaces/search.interface.ts @@ -155,8 +155,9 @@ export interface FaceEmbeddingSearch extends SearchEmbeddingOptions { export interface AssetDuplicateSearch { assetId: string; embedding: Embedding; - userIds: string[]; maxDistance?: number; + type: AssetType; + userIds: string[]; } export interface FaceSearchResult { 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/search.repository.sql b/server/src/queries/search.repository.sql index 7ee74618fc..4f907877ea 100644 --- a/server/src/queries/search.repository.sql +++ b/server/src/queries/search.repository.sql @@ -204,6 +204,7 @@ WITH "asset"."ownerId" IN ($2) AND "asset"."id" != $3 AND "asset"."isVisible" = $4 + AND "asset"."type" = $5 ) AND ("asset"."deletedAt" IS NULL) ORDER BY @@ -216,7 +217,7 @@ SELECT FROM "cte" "res" WHERE - res.distance <= $5 + res.distance <= $6 -- SearchRepository.searchFaces START TRANSACTION 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/media.repository.ts b/server/src/repositories/media.repository.ts index 04a9751223..73920820cf 100644 --- a/server/src/repositories/media.repository.ts +++ b/server/src/repositories/media.repository.ts @@ -11,7 +11,7 @@ import { IMediaRepository, ImageDimensions, ThumbnailOptions, - TranscodeOptions, + TranscodeCommand, VideoInfo, } from 'src/interfaces/media.interface'; import { Instrumentation } from 'src/utils/instrumentation'; @@ -97,7 +97,7 @@ export class MediaRepository implements IMediaRepository { }; } - transcode(input: string, output: string | Writable, options: TranscodeOptions): Promise { + transcode(input: string, output: string | Writable, options: TranscodeCommand): Promise { if (!options.twoPass) { return new Promise((resolve, reject) => { this.configureFfmpegCall(input, output, options).on('error', reject).on('end', resolve).run(); @@ -150,7 +150,7 @@ export class MediaRepository implements IMediaRepository { return { width, height }; } - private configureFfmpegCall(input: string, output: string | Writable, options: TranscodeOptions) { + private configureFfmpegCall(input: string, output: string | Writable, options: TranscodeCommand) { return ffmpeg(input, { niceness: 10 }) .inputOptions(options.inputOptions) .outputOptions(options.outputOptions) diff --git a/server/src/repositories/search.repository.ts b/server/src/repositories/search.repository.ts index 072d452777..f0c5dcb364 100644 --- a/server/src/repositories/search.repository.ts +++ b/server/src/repositories/search.repository.ts @@ -160,6 +160,7 @@ export class SearchRepository implements ISearchRepository { assetId, embedding, maxDistance, + type, userIds, }: AssetDuplicateSearch): Promise { const cte = this.assetRepository.createQueryBuilder('asset'); @@ -171,18 +172,22 @@ export class SearchRepository implements ISearchRepository { .where('asset.ownerId IN (:...userIds )') .andWhere('asset.id != :assetId') .andWhere('asset.isVisible = :isVisible') + .andWhere('asset.type = :type') .orderBy('search.embedding <=> :embedding') .limit(64) - .setParameters({ assetId, embedding: asVector(embedding), isVisible: true, userIds }); + .setParameters({ assetId, embedding: asVector(embedding), isVisible: true, type, userIds }); const builder = this.assetRepository.manager .createQueryBuilder() .addCommonTableExpression(cte, 'cte') .from('cte', 'res') - .select('res.*') - .where('res.distance <= :maxDistance', { maxDistance }); + .select('res.*'); - return builder.getRawMany() as any as Promise; + if (maxDistance) { + builder.where('res.distance <= :maxDistance', { maxDistance }); + } + + return builder.getRawMany() as Promise; } @GenerateSql({ 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/duplicate.service.spec.ts b/server/src/services/duplicate.service.spec.ts index 4560d9024c..cbde4ea777 100644 --- a/server/src/services/duplicate.service.spec.ts +++ b/server/src/services/duplicate.service.spec.ts @@ -214,7 +214,8 @@ describe(SearchService.name, () => { expect(searchMock.searchDuplicates).toHaveBeenCalledWith({ assetId: assetStub.hasEmbedding.id, embedding: assetStub.hasEmbedding.smartSearch!.embedding, - maxDistance: 0.03, + maxDistance: 0.0155, + type: assetStub.hasEmbedding.type, userIds: [assetStub.hasEmbedding.ownerId], }); expect(assetMock.updateDuplicates).toHaveBeenCalledWith({ @@ -239,7 +240,8 @@ describe(SearchService.name, () => { expect(searchMock.searchDuplicates).toHaveBeenCalledWith({ assetId: assetStub.hasEmbedding.id, embedding: assetStub.hasEmbedding.smartSearch!.embedding, - maxDistance: 0.03, + maxDistance: 0.0155, + type: assetStub.hasEmbedding.type, userIds: [assetStub.hasEmbedding.ownerId], }); expect(assetMock.updateDuplicates).toHaveBeenCalledWith({ diff --git a/server/src/services/duplicate.service.ts b/server/src/services/duplicate.service.ts index 95a12bd18e..6313ffa21f 100644 --- a/server/src/services/duplicate.service.ts +++ b/server/src/services/duplicate.service.ts @@ -94,6 +94,7 @@ export class DuplicateService { assetId: asset.id, embedding: asset.smartSearch.embedding, maxDistance: machineLearning.duplicateDetection.maxDistance, + type: asset.type, userIds: [asset.ownerId], }); 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/media.service.spec.ts b/server/src/services/media.service.spec.ts index 9fe0038232..5ff1b135dd 100644 --- a/server/src/services/media.service.spec.ts +++ b/server/src/services/media.service.spec.ts @@ -294,11 +294,13 @@ describe(MediaService.name, () => { '/original/path.ext', 'upload/thumbs/user-id/as/se/asset-id-preview.jpeg', { - inputOptions: ['-ss 00:00:00', '-sws_flags accurate_rnd+bitexact+full_chroma_int'], + inputOptions: ['-skip_frame nokey', '-sws_flags accurate_rnd+full_chroma_int'], outputOptions: [ + '-fps_mode vfr', '-frames:v 1', + '-update 1', '-v verbose', - '-vf scale=-2:1440:flags=lanczos+accurate_rnd+bitexact+full_chroma_int:out_color_matrix=601:out_range=pc,format=yuv420p', + `-vf fps=12,thumbnail=12,select=gt(scene\\,0.1)+gt(n\\,20),scale=-2:1440:flags=lanczos+accurate_rnd+full_chroma_int:out_color_matrix=601:out_range=pc,format=yuv420p`, ], twoPass: false, }, @@ -319,11 +321,13 @@ describe(MediaService.name, () => { '/original/path.ext', 'upload/thumbs/user-id/as/se/asset-id-preview.jpeg', { - inputOptions: ['-ss 00:00:00', '-sws_flags accurate_rnd+bitexact+full_chroma_int'], + inputOptions: ['-skip_frame nokey', '-sws_flags accurate_rnd+full_chroma_int'], outputOptions: [ + '-fps_mode vfr', '-frames:v 1', + '-update 1', '-v verbose', - '-vf zscale=t=linear:npl=100,tonemap=hable:desat=0,zscale=p=bt709:t=601:m=bt470bg:range=pc,format=yuv420p', + `-vf fps=12,thumbnail=12,select=gt(scene\\,0.1)+gt(n\\,20),zscale=t=linear:npl=100,tonemap=hable:desat=0,zscale=p=bt709:t=601:m=bt470bg:range=pc,format=yuv420p`, ], twoPass: false, }, @@ -346,11 +350,13 @@ describe(MediaService.name, () => { '/original/path.ext', 'upload/thumbs/user-id/as/se/asset-id-preview.jpeg', { - inputOptions: ['-ss 00:00:00', '-sws_flags accurate_rnd+bitexact+full_chroma_int'], + inputOptions: ['-skip_frame nokey', '-sws_flags accurate_rnd+full_chroma_int'], outputOptions: [ + '-fps_mode vfr', '-frames:v 1', + '-update 1', '-v verbose', - '-vf zscale=t=linear:npl=100,tonemap=hable:desat=0,zscale=p=bt709:t=601:m=bt470bg:range=pc,format=yuv420p', + `-vf fps=12,thumbnail=12,select=gt(scene\\,0.1)+gt(n\\,20),zscale=t=linear:npl=100,tonemap=hable:desat=0,zscale=p=bt709:t=601:m=bt470bg:range=pc,format=yuv420p`, ], twoPass: false, }, diff --git a/server/src/services/media.service.ts b/server/src/services/media.service.ts index 7e52fe384c..2ba4b34935 100644 --- a/server/src/services/media.service.ts +++ b/server/src/services/media.service.ts @@ -27,25 +27,12 @@ import { QueueName, } from 'src/interfaces/job.interface'; import { ILoggerRepository } from 'src/interfaces/logger.interface'; -import { AudioStreamInfo, IMediaRepository, VideoCodecHWConfig, VideoStreamInfo } from 'src/interfaces/media.interface'; +import { AudioStreamInfo, IMediaRepository, VideoStreamInfo } from 'src/interfaces/media.interface'; import { IMoveRepository } from 'src/interfaces/move.interface'; import { IPersonRepository } from 'src/interfaces/person.interface'; import { IStorageRepository } from 'src/interfaces/storage.interface'; import { ISystemMetadataRepository } from 'src/interfaces/system-metadata.interface'; -import { - AV1Config, - H264Config, - HEVCConfig, - NvencHwDecodeConfig, - NvencSwDecodeConfig, - QsvHwDecodeConfig, - QsvSwDecodeConfig, - RkmppHwDecodeConfig, - RkmppSwDecodeConfig, - ThumbnailConfig, - VAAPIConfig, - VP9Config, -} from 'src/utils/media'; +import { BaseConfig, ThumbnailConfig } from 'src/utils/media'; import { mimeTypes } from 'src/utils/mime-types'; import { usePagination } from 'src/utils/pagination'; @@ -53,8 +40,8 @@ import { usePagination } from 'src/utils/pagination'; export class MediaService { private configCore: SystemConfigCore; private storageCore: StorageCore; - private openCL: boolean | null = null; - private devices: string[] | null = null; + private maliOpenCL?: boolean; + private devices?: string[]; constructor( @Inject(IAssetRepository) private assetRepository: IAssetRepository, @@ -232,8 +219,8 @@ export class MediaService { return; } const mainAudioStream = this.getMainStream(audioStreams); - const config = { ...ffmpeg, targetResolution: size.toString() }; - const options = new ThumbnailConfig(config).getOptions(TranscodeTarget.VIDEO, mainVideoStream, mainAudioStream); + const config = ThumbnailConfig.create({ ...ffmpeg, targetResolution: size.toString() }); + const options = config.getCommand(TranscodeTarget.VIDEO, mainVideoStream, mainAudioStream); await this.mediaRepository.transcode(asset.originalPath, path, options); break; } @@ -331,8 +318,8 @@ export class MediaService { return JobStatus.FAILED; } - const { ffmpeg: config } = await this.configCore.getConfig(); - const target = this.getTranscodeTarget(config, mainVideoStream, mainAudioStream); + const { ffmpeg } = await this.configCore.getConfig(); + const target = this.getTranscodeTarget(ffmpeg, mainVideoStream, mainAudioStream); if (target === TranscodeTarget.NONE) { if (asset.encodedVideoPath) { this.logger.log(`Transcoded video exists for asset ${asset.id}, but is no longer required. Deleting...`); @@ -343,30 +330,28 @@ export class MediaService { return JobStatus.SKIPPED; } - let transcodeOptions; + let command; try { - transcodeOptions = await this.getCodecConfig(config).then((c) => - c.getOptions(target, mainVideoStream, mainAudioStream), - ); + const config = BaseConfig.create(ffmpeg, await this.getDevices(), await this.hasMaliOpenCL()); + command = config.getCommand(target, mainVideoStream, mainAudioStream); } catch (error) { this.logger.error(`An error occurred while configuring transcoding options: ${error}`); return JobStatus.FAILED; } - this.logger.log(`Started encoding video ${asset.id} ${JSON.stringify(transcodeOptions)}`); + this.logger.log(`Started encoding video ${asset.id} ${JSON.stringify(command)}`); try { - await this.mediaRepository.transcode(input, output, transcodeOptions); + await this.mediaRepository.transcode(input, output, command); } catch (error) { this.logger.error(error); - if (config.accel !== TranscodeHWAccel.DISABLED) { + if (ffmpeg.accel !== TranscodeHWAccel.DISABLED) { this.logger.error( - `Error occurred during transcoding. Retrying with ${config.accel.toUpperCase()} acceleration disabled.`, + `Error occurred during transcoding. Retrying with ${ffmpeg.accel.toUpperCase()} acceleration disabled.`, ); } - transcodeOptions = await this.getCodecConfig({ ...config, accel: TranscodeHWAccel.DISABLED }).then((c) => - c.getOptions(target, mainVideoStream, mainAudioStream), - ); - await this.mediaRepository.transcode(input, output, transcodeOptions); + const config = BaseConfig.create({ ...ffmpeg, accel: TranscodeHWAccel.DISABLED }); + command = config.getCommand(target, mainVideoStream, mainAudioStream); + await this.mediaRepository.transcode(input, output, command); } this.logger.log(`Successfully encoded ${asset.id}`); @@ -382,10 +367,10 @@ export class MediaService { private getTranscodeTarget( config: SystemConfigFFmpegDto, - videoStream: VideoStreamInfo | null, - audioStream: AudioStreamInfo | null, + videoStream?: VideoStreamInfo, + audioStream?: AudioStreamInfo, ): TranscodeTarget { - if (videoStream == null && audioStream == null) { + if (!videoStream && !audioStream) { return TranscodeTarget.NONE; } @@ -407,8 +392,8 @@ export class MediaService { return TranscodeTarget.NONE; } - private isAudioTranscodeRequired(ffmpegConfig: SystemConfigFFmpegDto, stream: AudioStreamInfo | null): boolean { - if (stream == null) { + private isAudioTranscodeRequired(ffmpegConfig: SystemConfigFFmpegDto, stream?: AudioStreamInfo): boolean { + if (!stream) { return false; } @@ -430,8 +415,8 @@ export class MediaService { } } - private isVideoTranscodeRequired(ffmpegConfig: SystemConfigFFmpegDto, stream: VideoStreamInfo | null): boolean { - if (stream == null) { + private isVideoTranscodeRequired(ffmpegConfig: SystemConfigFFmpegDto, stream?: VideoStreamInfo): boolean { + if (!stream) { return false; } @@ -465,70 +450,6 @@ export class MediaService { } } - async getCodecConfig(config: SystemConfigFFmpegDto) { - if (config.accel === TranscodeHWAccel.DISABLED) { - return this.getSWCodecConfig(config); - } - return this.getHWCodecConfig(config); - } - - private getSWCodecConfig(config: SystemConfigFFmpegDto) { - switch (config.targetVideoCodec) { - case VideoCodec.H264: { - return new H264Config(config); - } - case VideoCodec.HEVC: { - return new HEVCConfig(config); - } - case VideoCodec.VP9: { - return new VP9Config(config); - } - case VideoCodec.AV1: { - return new AV1Config(config); - } - default: { - throw new UnsupportedMediaTypeException(`Codec '${config.targetVideoCodec}' is unsupported`); - } - } - } - - private async getHWCodecConfig(config: SystemConfigFFmpegDto) { - let handler: VideoCodecHWConfig; - switch (config.accel) { - case TranscodeHWAccel.NVENC: { - handler = config.accelDecode ? new NvencHwDecodeConfig(config) : new NvencSwDecodeConfig(config); - break; - } - case TranscodeHWAccel.QSV: { - handler = config.accelDecode - ? new QsvHwDecodeConfig(config, await this.getDevices()) - : new QsvSwDecodeConfig(config, await this.getDevices()); - break; - } - case TranscodeHWAccel.VAAPI: { - handler = new VAAPIConfig(config, await this.getDevices()); - break; - } - case TranscodeHWAccel.RKMPP: { - handler = - config.accelDecode && (await this.hasOpenCL()) - ? new RkmppHwDecodeConfig(config, await this.getDevices()) - : new RkmppSwDecodeConfig(config, await this.getDevices()); - break; - } - default: { - throw new UnsupportedMediaTypeException(`${config.accel.toUpperCase()} acceleration is unsupported`); - } - } - if (!handler.getSupportedCodecs().includes(config.targetVideoCodec)) { - throw new UnsupportedMediaTypeException( - `${config.accel.toUpperCase()} acceleration does not support codec '${config.targetVideoCodec.toUpperCase()}'. Supported codecs: ${handler.getSupportedCodecs()}`, - ); - } - - return handler; - } - isSRGB(asset: AssetEntity): boolean { const { colorspace, profileDescription, bitsPerSample } = asset.exifInfo ?? {}; if (colorspace || profileDescription) { @@ -567,24 +488,29 @@ export class MediaService { private async getDevices() { if (!this.devices) { - this.devices = await this.storageRepository.readdir('/dev/dri'); + try { + this.devices = await this.storageRepository.readdir('/dev/dri'); + } catch { + this.logger.debug('No devices found in /dev/dri.'); + this.devices = []; + } } return this.devices; } - private async hasOpenCL() { - if (this.openCL === null) { + private async hasMaliOpenCL() { + if (this.maliOpenCL === undefined) { try { const maliIcdStat = await this.storageRepository.stat('/etc/OpenCL/vendors/mali.icd'); const maliDeviceStat = await this.storageRepository.stat('/dev/mali0'); - this.openCL = maliIcdStat.isFile() && maliDeviceStat.isCharacterDevice(); + this.maliOpenCL = maliIcdStat.isFile() && maliDeviceStat.isCharacterDevice(); } catch { - this.logger.warn('OpenCL not available for transcoding, using CPU instead.'); - this.openCL = false; + this.logger.debug('OpenCL not available for transcoding, using CPU decoding instead.'); + this.maliOpenCL = false; } } - return this.openCL; + return this.maliOpenCL; } } 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/server-info.service.spec.ts b/server/src/services/server-info.service.spec.ts index 57beb165db..90d70b21ff 100644 --- a/server/src/services/server-info.service.spec.ts +++ b/server/src/services/server-info.service.spec.ts @@ -149,7 +149,7 @@ describe(ServerInfoService.name, () => { it('should respond the server features', async () => { await expect(sut.getFeatures()).resolves.toEqual({ smartSearch: true, - duplicateDetection: false, + duplicateDetection: true, facialRecognition: true, map: true, reverseGeocoding: true, diff --git a/server/src/services/system-config.service.spec.ts b/server/src/services/system-config.service.spec.ts index 878916b0d2..281090cb3f 100644 --- a/server/src/services/system-config.service.spec.ts +++ b/server/src/services/system-config.service.spec.ts @@ -81,8 +81,8 @@ const updatedConfig = Object.freeze({ modelName: 'ViT-B-32__openai', }, duplicateDetection: { - enabled: false, - maxDistance: 0.03, + enabled: true, + maxDistance: 0.0155, }, facialRecognition: { enabled: true, 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/utils/media.ts b/server/src/utils/media.ts index 5a57f0f0cf..29c5a9c14b 100644 --- a/server/src/utils/media.ts +++ b/server/src/utils/media.ts @@ -3,22 +3,84 @@ import { SystemConfigFFmpegDto } from 'src/dtos/system-config.dto'; import { AudioStreamInfo, BitrateDistribution, - TranscodeOptions, + TranscodeCommand, VideoCodecHWConfig, VideoCodecSWConfig, VideoStreamInfo, } from 'src/interfaces/media.interface'; -class BaseConfig implements VideoCodecSWConfig { - presets = ['veryslow', 'slower', 'slow', 'medium', 'fast', 'faster', 'veryfast', 'superfast', 'ultrafast']; - constructor(protected config: SystemConfigFFmpegDto) {} +export class BaseConfig implements VideoCodecSWConfig { + readonly presets = ['veryslow', 'slower', 'slow', 'medium', 'fast', 'faster', 'veryfast', 'superfast', 'ultrafast']; + protected constructor(protected config: SystemConfigFFmpegDto) {} - getOptions(target: TranscodeTarget, videoStream: VideoStreamInfo, audioStream?: AudioStreamInfo) { + static create(config: SystemConfigFFmpegDto, devices: string[] = [], hasMaliOpenCL = false): VideoCodecSWConfig { + if (config.accel === TranscodeHWAccel.DISABLED) { + return this.getSWCodecConfig(config); + } + return this.getHWCodecConfig(config, devices, hasMaliOpenCL); + } + + private static getSWCodecConfig(config: SystemConfigFFmpegDto) { + switch (config.targetVideoCodec) { + case VideoCodec.H264: { + return new H264Config(config); + } + case VideoCodec.HEVC: { + return new HEVCConfig(config); + } + case VideoCodec.VP9: { + return new VP9Config(config); + } + case VideoCodec.AV1: { + return new AV1Config(config); + } + default: { + throw new Error(`Codec '${config.targetVideoCodec}' is unsupported`); + } + } + } + + private static getHWCodecConfig(config: SystemConfigFFmpegDto, devices: string[] = [], hasMaliOpenCL = false) { + let handler: VideoCodecHWConfig; + switch (config.accel) { + case TranscodeHWAccel.NVENC: { + handler = config.accelDecode ? new NvencHwDecodeConfig(config) : new NvencSwDecodeConfig(config); + break; + } + case TranscodeHWAccel.QSV: { + handler = config.accelDecode ? new QsvHwDecodeConfig(config, devices) : new QsvSwDecodeConfig(config, devices); + break; + } + case TranscodeHWAccel.VAAPI: { + handler = new VAAPIConfig(config, devices); + break; + } + case TranscodeHWAccel.RKMPP: { + handler = + config.accelDecode && hasMaliOpenCL + ? new RkmppHwDecodeConfig(config, devices) + : new RkmppSwDecodeConfig(config, devices); + break; + } + default: { + throw new Error(`${config.accel.toUpperCase()} acceleration is unsupported`); + } + } + if (!handler.getSupportedCodecs().includes(config.targetVideoCodec)) { + throw new Error( + `${config.accel.toUpperCase()} acceleration does not support codec '${config.targetVideoCodec.toUpperCase()}'. Supported codecs: ${handler.getSupportedCodecs()}`, + ); + } + + return handler; + } + + getCommand(target: TranscodeTarget, videoStream: VideoStreamInfo, audioStream?: AudioStreamInfo) { const options = { inputOptions: this.getBaseInputOptions(videoStream), outputOptions: [...this.getBaseOutputOptions(target, videoStream, audioStream), '-v verbose'], twoPass: this.eligibleForTwoPass(), - } as TranscodeOptions; + } as TranscodeCommand; if ([TranscodeTarget.ALL, TranscodeTarget.VIDEO].includes(target)) { const filters = this.getFilterOptions(videoStream); if (filters.length > 0) { @@ -318,11 +380,20 @@ export class BaseHWConfig extends BaseConfig implements VideoCodecHWConfig { } export class ThumbnailConfig extends BaseConfig { - getBaseInputOptions(): string[] { - return ['-ss 00:00:00', '-sws_flags accurate_rnd+bitexact+full_chroma_int']; + static create(config: SystemConfigFFmpegDto): VideoCodecSWConfig { + return new ThumbnailConfig(config); } + + getBaseInputOptions(): string[] { + return ['-skip_frame nokey', '-sws_flags accurate_rnd+full_chroma_int']; + } + getBaseOutputOptions() { - return ['-frames:v 1']; + return ['-fps_mode vfr', '-frames:v 1', '-update 1']; + } + + getFilterOptions(videoStream: VideoStreamInfo): string[] { + return ['fps=12', 'thumbnail=12', `select=gt(scene\\,0.1)+gt(n\\,20)`, ...super.getFilterOptions(videoStream)]; } getPresetOptions() { @@ -338,8 +409,7 @@ export class ThumbnailConfig extends BaseConfig { } getScaling(videoStream: VideoStreamInfo) { - let options = super.getScaling(videoStream); - options += ':flags=lanczos+accurate_rnd+bitexact+full_chroma_int'; + let options = super.getScaling(videoStream) + ':flags=lanczos+accurate_rnd+full_chroma_int'; if (!this.shouldToneMap(videoStream)) { options += ':out_color_matrix=601:out_range=pc'; } @@ -534,8 +604,6 @@ export class NvencHwDecodeConfig extends NvencSwDecodeConfig { options.push(...this.getToneMapping(videoStream)); if (options.length > 0) { options[options.length - 1] += ':format=nv12'; - } else { - options.push('format=nv12'); } return options; } @@ -559,7 +627,7 @@ export class NvencHwDecodeConfig extends NvencSwDecodeConfig { } getInputThreadOptions() { - return [`-threads ${this.config.threads <= 0 ? 1 : this.config.threads}`]; + return [`-threads 1`]; } getOutputThreadOptions() { @@ -649,7 +717,7 @@ export class QsvHwDecodeConfig extends QsvSwDecodeConfig { throw new Error('No QSV device found'); } - const options = ['-hwaccel qsv', '-hwaccel_output_format qsv', '-async_depth 4', '-threads 1']; + const options = ['-hwaccel qsv', '-hwaccel_output_format qsv', '-async_depth 4', ...this.getInputThreadOptions()]; const hwDevice = this.getPreferredHardwareDevice(); if (hwDevice) { options.push(`-qsv_device ${hwDevice}`); @@ -694,6 +762,10 @@ export class QsvHwDecodeConfig extends QsvSwDecodeConfig { 'hwmap=derive_device=qsv:reverse=1,format=qsv', ]; } + + getInputThreadOptions() { + return [`-threads 1`]; + } } export class VAAPIConfig extends BaseHWConfig { 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/.eslintrc.cjs b/web/.eslintrc.cjs index e29c4a9b48..de0a64bd37 100644 --- a/web/.eslintrc.cjs +++ b/web/.eslintrc.cjs @@ -51,6 +51,7 @@ module.exports = { 'unicorn/consistent-function-scoping': 'off', 'unicorn/prefer-top-level-await': 'off', 'unicorn/import-style': 'off', + 'svelte/button-has-type': 'error', // TODO: set recommended-type-checked and remove these rules '@typescript-eslint/await-thenable': 'error', '@typescript-eslint/no-floating-promises': 'error', 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 @@ - + diff --git a/web/src/lib/components/assets/thumbnail/thumbnail.svelte b/web/src/lib/components/assets/thumbnail/thumbnail.svelte index 1ee4463756..308137eae7 100644 --- a/web/src/lib/components/assets/thumbnail/thumbnail.svelte +++ b/web/src/lib/components/assets/thumbnail/thumbnail.svelte @@ -106,6 +106,7 @@ {#if !readonly && (mouseOver || selected || selectionCandidate)} +
{#each potentialMergePeople as person (person.id)}
- + {/each}
@@ -214,7 +213,11 @@ class:opacity-0={!galleryInView} class:opacity-100={galleryInView} > -
@@ -231,7 +234,12 @@ class:opacity-0={!previousMemory} class:hover:opacity-70={previousMemory} > -
{/each} diff --git a/web/src/lib/components/shared-components/navigation-bar/navigation-bar.svelte b/web/src/lib/components/shared-components/navigation-bar/navigation-bar.svelte index 0c8cafc01e..fc4cc58281 100644 --- a/web/src/lib/components/shared-components/navigation-bar/navigation-bar.svelte +++ b/web/src/lib/components/shared-components/navigation-bar/navigation-bar.svelte @@ -119,6 +119,7 @@ on:escape={() => (shouldShowAccountInfoPanel = false)} > +
{#if isOpen} diff --git a/web/src/lib/components/shared-components/settings/setting-buttons-row.svelte b/web/src/lib/components/shared-components/settings/setting-buttons-row.svelte index a367832cc1..f479112dee 100644 --- a/web/src/lib/components/shared-components/settings/setting-buttons-row.svelte +++ b/web/src/lib/components/shared-components/settings/setting-buttons-row.svelte @@ -16,6 +16,7 @@
{#if showResetToDefault} - +
diff --git a/web/src/lib/components/shared-components/settings/setting-input-field.spec.ts b/web/src/lib/components/shared-components/settings/setting-input-field.spec.ts index 7b59affdb3..642492dda5 100644 --- a/web/src/lib/components/shared-components/settings/setting-input-field.spec.ts +++ b/web/src/lib/components/shared-components/settings/setting-input-field.spec.ts @@ -27,4 +27,25 @@ describe('SettingInputField component', () => { await user.click(document.body); expect(numberInput.value).toEqual('100'); }); + + it('allows emptying number inputs while editing', async () => { + const { getByRole } = render(SettingInputField, { + props: { + label: 'test-number-input', + inputType: SettingInputFieldType.NUMBER, + value: 5, + }, + }); + const user = userEvent.setup(); + + const numberInput = getByRole('spinbutton') as HTMLInputElement; + expect(numberInput.value).toEqual('5'); + + await user.click(numberInput); + await user.keyboard('{Backspace}'); + expect(numberInput.value).toEqual(''); + + await user.click(document.body); + expect(numberInput.value).toEqual('0'); + }); }); diff --git a/web/src/lib/components/shared-components/settings/setting-input-field.svelte b/web/src/lib/components/shared-components/settings/setting-input-field.svelte index a92482b178..d3da95ebe1 100644 --- a/web/src/lib/components/shared-components/settings/setting-input-field.svelte +++ b/web/src/lib/components/shared-components/settings/setting-input-field.svelte @@ -9,6 +9,7 @@ - - diff --git a/web/src/lib/stores/user.store.ts b/web/src/lib/stores/user.store.ts index 0fc4e9e01d..506782f48a 100644 --- a/web/src/lib/stores/user.store.ts +++ b/web/src/lib/stores/user.store.ts @@ -1,12 +1,12 @@ -import type { UserResponseDto } from '@immich/sdk'; +import type { UserAdminResponseDto } from '@immich/sdk'; import { writable } from 'svelte/store'; -export const user = writable(); +export const user = writable(); /** * Reset the store to its initial undefined value. Make sure to * only do this _after_ redirecting to an unauthenticated page. */ export const resetSavedUser = () => { - user.set(undefined as unknown as UserResponseDto); + user.set(undefined as unknown as UserAdminResponseDto); }; diff --git a/web/src/lib/utils.ts b/web/src/lib/utils.ts index 7d32501395..d2aa26b35c 100644 --- a/web/src/lib/utils.ts +++ b/web/src/lib/utils.ts @@ -11,7 +11,6 @@ import { startOAuth, unlinkOAuthAccount, type SharedLinkResponseDto, - type UserResponseDto, } from '@immich/sdk'; import { mdiCogRefreshOutline, mdiDatabaseRefreshOutline, mdiImageRefreshOutline } from '@mdi/js'; @@ -264,7 +263,7 @@ export const oauth = { login: (location: Location) => { return finishOAuth({ oAuthCallbackDto: { url: location.href } }); }, - link: (location: Location): Promise => { + link: (location: Location) => { return linkOAuthAccount({ oAuthCallbackDto: { url: location.href } }); }, unlink: () => { @@ -292,4 +291,4 @@ export const handlePromiseError = (promise: Promise): void => { export const s = (count: number) => (count === 1 ? '' : 's'); -export const memoryLaneTitle = (yearsAgo: number) => `year${s(yearsAgo)} ago`; +export const memoryLaneTitle = (yearsAgo: number) => `${yearsAgo} year${s(yearsAgo)} ago`; diff --git a/web/src/lib/utils/auth.ts b/web/src/lib/utils/auth.ts index 1fbc158f74..91beb0293f 100644 --- a/web/src/lib/utils/auth.ts +++ b/web/src/lib/utils/auth.ts @@ -1,7 +1,7 @@ import { browser } from '$app/environment'; import { serverInfo } from '$lib/stores/server-info.store'; import { user } from '$lib/stores/user.store'; -import { getMyUserInfo, getStorage } from '@immich/sdk'; +import { getMyUser, getStorage } from '@immich/sdk'; import { redirect } from '@sveltejs/kit'; import { get } from 'svelte/store'; import { AppRoute } from '../constants'; @@ -15,7 +15,7 @@ export const loadUser = async () => { try { let loaded = get(user); if (!loaded && hasAuthCookie()) { - loaded = await getMyUserInfo(); + loaded = await getMyUser(); user.set(loaded); } return loaded; diff --git a/web/src/routes/(user)/albums/[albumId=id]/[[photos=photos]]/[[assetId=id]]/+page.svelte b/web/src/routes/(user)/albums/[albumId=id]/[[photos=photos]]/[[assetId=id]]/+page.svelte index 32fb7c01ee..cb6bac42b2 100644 --- a/web/src/routes/(user)/albums/[albumId=id]/[[photos=photos]]/[[assetId=id]]/+page.svelte +++ b/web/src/routes/(user)/albums/[albumId=id]/[[photos=photos]]/[[assetId=id]]/+page.svelte @@ -510,6 +510,7 @@ {#each album.albumUsers.filter(({ role }) => role === AlbumUserRole.Editor) as { user } (user.id)} - {/each} @@ -620,6 +621,7 @@

ADD PHOTOS

+
- + - + diff --git a/web/src/routes/admin/library-management/+page.svelte b/web/src/routes/admin/library-management/+page.svelte index 8fdbd60d52..fe5cb2686f 100644 --- a/web/src/routes/admin/library-management/+page.svelte +++ b/web/src/routes/admin/library-management/+page.svelte @@ -23,7 +23,7 @@ deleteLibrary, getAllLibraries, getLibraryStatistics, - getUserById, + getUserAdmin, removeOfflineFiles, scanLibrary, updateLibrary, @@ -99,7 +99,7 @@ const refreshStats = async (listIndex: number) => { stats[listIndex] = await getLibraryStatistics({ id: libraries[listIndex].id }); - owner[listIndex] = await getUserById({ id: libraries[listIndex].ownerId }); + owner[listIndex] = await getUserAdmin({ id: libraries[listIndex].ownerId }); photos[listIndex] = stats[listIndex].photos; videos[listIndex] = stats[listIndex].videos; totalCount[listIndex] = stats[listIndex].total; diff --git a/web/src/routes/admin/library-management/+page.ts b/web/src/routes/admin/library-management/+page.ts index 78831d38c7..5cb90cd7f9 100644 --- a/web/src/routes/admin/library-management/+page.ts +++ b/web/src/routes/admin/library-management/+page.ts @@ -1,11 +1,11 @@ import { authenticate, requestServerInfo } from '$lib/utils/auth'; -import { getAllUsers } from '@immich/sdk'; +import { searchUsersAdmin } from '@immich/sdk'; import type { PageLoad } from './$types'; export const load = (async () => { await authenticate({ admin: true }); await requestServerInfo(); - const allUsers = await getAllUsers({ isAll: false }); + const allUsers = await searchUsersAdmin({ withDeleted: false }); return { allUsers, diff --git a/web/src/routes/admin/user-management/+page.svelte b/web/src/routes/admin/user-management/+page.svelte index 8ced466595..0875f819b9 100644 --- a/web/src/routes/admin/user-management/+page.svelte +++ b/web/src/routes/admin/user-management/+page.svelte @@ -1,14 +1,15 @@