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

+
- + - + From e3d39837d03c9cd35a882eb98e708c8a595279ae Mon Sep 17 00:00:00 2001 From: aviv926 <51673860+aviv926@users.noreply.github.com> Date: Mon, 27 May 2024 10:39:59 +0300 Subject: [PATCH 09/14] docs: Add Google OAuth example (#9778) * Add Google OAuth example * npm run format:fix * fix * PR feedback * Fix --- .../administration/img/google-example.webp | Bin 0 -> 10990 bytes .../img/immich-google-example.webp | Bin 0 -> 115674 bytes docs/docs/administration/oauth.md | 38 +++++++++++++++++- 3 files changed, 37 insertions(+), 1 deletion(-) create mode 100644 docs/docs/administration/img/google-example.webp create mode 100644 docs/docs/administration/img/immich-google-example.webp diff --git a/docs/docs/administration/img/google-example.webp b/docs/docs/administration/img/google-example.webp new file mode 100644 index 0000000000000000000000000000000000000000..742e77cd37d57fab9bb26fb2a86d9e111d3b5607 GIT binary patch literal 10990 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 C)^w%- literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..ed6c31432de4440333033d4489cfbeafb241d836 GIT binary patch literal 115674 zcmeFXgLmY6_5~WVW22*vZQC8&w$rg~cWk3$+qP}n>Nu&__s-0n+w+@w>-_;Q>#WLm zRjsOg>vPW8XYYNY@KscFwhtIcRYXuuMUGt!2IwC@)}TP^kU+nlA<+5t>Nhj6OkheI z&<)66uaXqD*`LLDBi}>_cimkh)bZtAkg2?zJakT}sNOce=0D8cmWNZHyv3ih^}OV2 zrmL9%yr%BEP`q0md3P%qD+O77yKYzD`N|1@F1`BDb4MR{$$V_RU0u>WXK!?+UCLgd zZ1RP@JAB{+@K<#oefr)v9|rFDp81yE_ujBye_VGhb&UbO0j54I0f70c7yRR{ML_U- z!OP5x%bUxy>}FR^%xTvKU#oWyU#%L&3@(u0A9Qb0SSQ2 zN5DP)tI#6=064V-@V#W;bWQUC04J|rfX@KUcbDg_t*#ecIlvh&fR~@|i?hWDolE_}JX?yxrZ1J@Y;3 zUgPU^#RHH&(4M^BWM8`;x2(5D`3U)9oZLU`oI zFo6Dp?M?PA<}CY0_Jz+1kOM#h_`Nc{F@31^#7uYP`{2KYey{K{T+~)OCe}!11y&-m9fCeS+$_-}?w*mIGnptGH zvaf5(rl0xF*xPpWqc==65CJmtG~x}Z*?Wa9SyA2d~jh(G0_%K}V?cv|(@8+dMEnxD~tKvF~%lhatiiyBrR+4b1Jo z+nz(wJx@)5%s;Hv6(OEJ=G|N_I(fCKRBy2Fu|e5(gE0-(;$Nc7tMBCZXwrdE-f(h9 z7R4I&uZTbF#3iJ@+MU(#9NCl3GyHetuGxB&vfxnpE~!Idmf@!2ZiC?0q(ihS3iad? zZY$XlRWq*+zI4CXUG^>+)Hy)Oiv1X$uIcneb@yJ8`+}r$5!#yIa0c#UBDYOss4sYk zwVTTQSh%K>&FDI3mNI7+K%DvVAv@uDFR!_)lm?f*b-kc)H|*odr<0nVWOT{*9l2V! zO1b?Jx4~OulSO-nT7Kb*k3CKGb6(}nHP6^r^eIp}mG59&$ZDR|go+U?^Rj&Mqvteo z4G2FD-6+Fn+xdvH5$I%^+ZR9W{?A(t;3y|3K1w`>kSWOv8^dS@4w!x ze#&BACH)mlgxgmnW`uVkNq^7YpE;bCxzYCoB^xFT)6?K2s9d@iOAVGz-Yf-Xh2qw{ zb_f-N@Zxw6f%x^2D1QZ#VqlIhy4|qul-WISNXk;vHW`%U{j6ftfcf&L{5*>0k|wj2%d)ff znHkh|rL}sijo2=Te^tY;g7__F3&}i<{bU>4-PY?JzI#Ebf9(_WmLhRjWlcdsw8ngn zA50hiTku6~d7pb?6_8bSO?}qG&W%g;dV!HO}Eqr|gas>ZW$^q^090_vn_%SxCt zL2@@fw#rGA9-i(Tm37+LHu3o%Xiyd&&TtD&mUJBVeaK*pjbionIuJ8%l81OfJ8Av- zN$}HGQ@l8f$}t$Rj-w3We$LA_XC6rjHZv5C*qjDYUbAkw<}kj1!z!8 z1EZ@g(iXw34PI(c2-kz?-)hTh{T)R;Qsu!Hei1nHbD2($NnWAd(}u{(*U!q7=XA9K zNs)WA(#k8=Q`vz8{YiEwq^0MLM^n9s29oSMZyg2#bp!O@{+8!ItN&NR`8_hY{{vk9 zi83J>Qqp7lTGe09?R%H;vop!VL8{`;aw3*1T1iCz_4a>s(VvgR+}5@vJ}tP-u9xNk z2QL97)^CW8z!WkGogf6}r_QD?YGvv^k$i!Qc8WGLb-!1aq2<(pf?8+s`b&#zXwZMn zTFh4#RpTaJZ$y7}E5+X4O1%i z`*?Yq7$%5Mh(GrW9c6PC_TT6L2}rR)`jrMDCiS;k_0iYui-nCpm$(VgD4qaodJ6W* zI~pujd2u!RZS>-|y_hbrEE~Kt${QCdP`HLgn-Sul&qcd?B}o;|1}oo%z!nDs=n+!2 z4k*n2iH<3AkoCU28d!{Tilkq=fe;x;Ieko@u9Hky{Z zf3{LFfh2n5uVEvDcsBP3jQ$(*{^zwNQR?H~=_IB7*d3uQe!~8M7nl<1aR+E_hawVf zU4%cvULR3$zlA*01((^--!W*Fl)|P9E~@xK%!CaV?vr3_ze`JymmTSx!>Q_5C8q`# z#5lK6Xx>jt;;V76iCKSZ?Z1j709k#gAT2drs0nwABZ;+J$?q86Bo;{YfW7)nX`{N6 zXVeAy{?d;|4_<-r=2EfYfzoEWPMYB}F+b5Egu4U#L-SQhey%yX1zto>UR)XNzp&%i zA5f97!RXEO)szX69FHry?0o_hBG{shabXdaqpYFcCm=U#CemVPr%ZH&b z?{iOJ^=*|OyULX?l6RSCeuchidi%W`x3qTjox(Em0e2D3{UWUFd*DB(JoB}fPgFKt z@1TPn7sdQ##`DwU*!#t~`04~C8V?QlUc&kkUoSR3yD@L^ugw&S#uWS0OUz#Z^7?)bsw+ntm*Jq6;@4KkZRz%1LoCkdDUnK z(!)#*iT#|Z*Cn~IewDV#uKMx_fp!FUAvlduf)#8k;t(WXJ!0W0CQJwkJlQr?H3`Oz zeK{f=pt+wIo$Yjgt#78Wk-|lqzVT#DaZ0XGWgpoZdE+1C;Gb2EmGVsJd0gM;6e2I3 z#UvJ?=E%CNbHva~5jANAuEoRH{H<*zN(fh^Qd%&nk!CDS7fj7Ti4fyVuBd`uIa z2?oFzFnWpU8rnQP!9V~>fmZ7Jd(!?sojgP$bgYL{+S061#<^I$pF*l>idNwq5KzB< zADn-=dv<$k|IqQF<4evSn>skUy*aP@Ut@UAEV1(#BBb?}XTkTkvG$8U>Qzczvr3PP z`Y*xkM1^%p*^JRzP~fkoue{bN-php)41Nb8AVh7+H1l(bR9myL1xpkhSu6N~;IIj5 zY+`U_aZ@c~n6mWy*q<}=pz^%QwN1l28Yz%pc6Pz1;yDS_l>gL=Qs^yoZoUV(a98rs zhIGX;aX^+w5u$_VzFBBz!zW6;A>7T}{sz|w=8@1D;znUUMavCG&l*LUemhTQ+jx$g z!s;g?BNkGdsrXd!|MmH=_6dFD(EGYtXh#!xen~NwA83?aNWcPbzsZGPQ)sH=?5k}n z_B2Byz%f-)r4o7^uPG3%Z=PQw!i+pR+1@e<>DlYI!oaph9?I~myZeq|-3g_?ifFH+ zACQn%I$}pQ^zRYykF(Lq!W#pdabBK0m_c$3)8G8xC{@71G;it;c80d};b{lhxqr#c|*kd=g5TTsTI@&9DvECHr$^^PhKz3b+ zI2y_Q4)4vFjs0eDTqpO}DppYZzlPqQ-*|~qViY-s-l4r({9e4Kgd6EbDPf;?tv#Ap zUSKf5#4kWHh_ITP>FYN#ghI}72!9OG?ll$&SuEQm(7)gxm+nF4=@azPj>)#`3Kg-@ z@r~q5`&znqtgD6gaoKmNSRf~vQ~&v4NEXT@IMs23MGBA&)X1^rn;_*c{`E;?VEG!M z%_O?#ynNh)eB2wYNl2cnv?tF1a9YMupj=h~Y*Q6|A821`gO;WsO9`b78$5uPkaM*f zMqyv*9Ggx=WVbuIV{#5ncsETt-6!MaLiE6oCK9>)65>c4XJqf~Y6;Vaw@@@O->z$wT4X!Qkpg96pwq7UXI3h*7#KaP?F%cH-7s2=A z8HC__ft9oMC{@1ud##F(le3C!xA*fru6**ls3IP%{Lwak=b{Dc4&%2=t;yu1GZLca zb))oOWn_KMW(SC#+BI-{0AmQFo>$^yp#Fm<&RDDeUz&I?<%p7C83!wK4bO{tBgHhF@7a(i2fu=xt2ZY(Gm2wKVzq=Hr&)~O-y5|yGrV8w zTZlSa>)qbCT>cjU@HdtqUp5W`m!mH9R|Wnd3ja@#IAx~&4cPyX+mc9pSR1H?b!upq5<8HiWF&2qhZJB)!mARJnbAK zfVxI5{pF_E;UxS~(wy*tMWMAs^Bn#a42W(nstORw#1WH( z7C;sbTu%P#c)d}iJs^u^OiafX?;9Pfft9O6I4Ttc@3`zA>1KTEt<|E)t6WT|ms+<{ z{tYdDmG)*!AUElbsySe5rdMQ6He9r%^O&%1+?c`xnIT+7)F1uB*z`V&f8?+KjFg!- z+R17!BY%Q9KgE=N#p|Fb63CXyZ+*lz-jzKrslWoAI((T1XGX)!?@>O2?-pHrLU8bs zH8@USKjfJU7$8;brISRBhBA_pFBmM@#}IrH;@nb=deCpq45d9|iq@?Z!p=c_?wP49 zCU!kVNJb@%O{*o8A66KZLb0;%`Ql=@#rVl3(}*T{n#)CgJlEX;Q(^Fu4Y7UW&U-ZS zib($}PcN4WitSbFo|6*fcjb9wWyN^Rt=IZore>V+&wanZyF4%u#eSi32l$4mmaCkPjj%#2R%Y)V4 zXZNtJbn44-W<#qs{w@fNkBw zeoamO$b8e3L}f4@PAAfyWDiR$j=r(KjIfm1e6{a#eo+_urM;c6<*TN8UU8wgce)r< zIt^!I5e~;uQ5g2fa`g$7QD5TtxpyV8B!t95>7hRm!KoWgZX3RgJ}5(_klJ6;o?D(~!{diugGQOwoVg+3_ z%M14;ae$krw&4lZXuNkvC#cI%OmxsfXE$BF?WoI>uaxKDY=0TmcB>#V0>>V2jnpTP z2Yp}GwiP{N89Iy7F$Gh=C(*}$aCESGQjFBt`2mF&E%nRhLx!L{pU#hI{U1^Jtw43! zkf_G5>jwJ0{aqo5ob(-8+qbj;?l)!#rtqoLq2bij6dk)~(5>p^wMo5jUVwOQrK~cp zF+;=jrs-q8F&070362efFFul#$Uy;)dG@LKcPzAr`J*+{yK}JI*`$N6)cs5NW7{{| z${q%uUt|lX3^VBuh4V|0)kC_AKz4fVX@Hqot^cq*SK26${7bLd61o(4AzbDR3cR7d zX;P3z{)Zm;v+{ur98}A|SH&8026@G+RYd_`IO$rv;1A?A}@ywPQHqhi9X6l<^22*kIbOJ|Jx+_LvQ}U#s0?VEd+u)ZR1 zgl*?vuVyY2O7b%rHiALgghe>ZiKh4gKZLCnqNX-iA)lgt18F8}5WFw^w6zN)Wm$vZON?q`STEl2$U z_D;mlbrwhGIyGZ%a^7FP{=6%lub#;I>21ZIMGxen=WZ3#4X3Q_Qm8-6hIEb=)O}h| zFjsp%B4ZXA_`4|0kX!h&bTI!MQ6K&`#A310-l4r>FTNwe1e!uR;F(VgH4h_THhQul zt@pdtn0&s`yl8s@{%LMtqvS4(0QQ%hXL+f+SrY%Bx-)hEwCBPHMYtGc&^{XT^zVCy zzu8xCzgNiLJP}LA+BD-pTo(6!jd~ooe;fDzn$G<+eIgUSd#1E3>@4nHif*L6Rk{Uv zg_efX+Pi|nyXG+QSAsY|`JiZ<#yHK(=|R&%0tYI-u;YWdfO3hKQfg z+OJ03;M-w06o=OUS`+0px{cO}0prx*CQ=F`jJWR+BpX)jwC}qKx6*C=7E9qou*>6oRy>BfZVdyNh;?>p9RtaL2rsBB3ExlV#kBa$0_?+BaO2LzPn zO$mQBW=WGiimFW^c=T#~p|zOWdI&Z94=t(DG$^mp@Sxnh>grhnI{e@Fo zC9QnWl5~rf|9Fb?#ZQSBOr)Jw`-b|He9@1?%tI#T?Vvu0k=4Fzn zo)S02)z!)}GSL;i-6Qp4#M}TVJ6B&XboNQfvX=HvR>;iDAEW{N?>8Ni zr$cn5E_KXbXe2my&D+KYX&u>-jcJE=Qo$%xI>u<4wByl9ZmLB|d3NzYZ}|Aepelw? z7X{S3(umDHUa(;*Ab4DBu|84`Im2rEAKxc`vc2%$gg$z;;*Nnz*W(!sNx%7=v8F8U zpN9sDWYC*Mh+AGIK=;01*Oi8~WmWc411lBn9X2)jTw^y0N6?7J!-=9RNMcp=RWv~@e%(DVnTYpMO6dJWPwyMxd|&$5 zj#eG=j5@N+#2I8#$$4yJiG}(r21)Jl!%cto3g~Bxn)djR$kn#Z{I$WN{;aXWOasyJ zh#d%JR7r2Dc-d!Meiq}DlT)|GAP+rywhp7aJ?XrG;#E(NLp-pKr0svqb2PU!;_ULv z1xNV44;)x3%Ce!Rn0IRQ^$-(pdmN7Nfd=jhDGJ@WdEc`tvI;25P|)trEhi116Jx;Q zUbr`BI zd?ZX)rI9^`!Z>FrQ8#)y{Q23T4W3)CQ#UkJkNFhkvg(DrEUUO-{f2Swx&t&`P|K^$ zHH=U5;bUGC`Ch7xqJCgaXx0K_*|5gG$aJ;!fBjQ3MYR(d{=$eKCOyYjdET^m8V~ya z9w7?f!mq6(IIw1eCM>ek;!7@3+uy^BQOo!iEh3n-bNM~PmaEirdX~|9E z=c`ipzZyTUx#~V)Q9wv@ZN@zy;7HsBJy>xrfjy+mmQ~B%+L_yH>bKfMD%&_tXsV!pdL=KX|^o_NV!?%9b&0;Hu`M$&_fopJg zP$XUIT(VN@=|rXvgHeU@NOv0{lSv-~aVHCW%W{@-aptmw+}B(l(V>wL7zRd0zM5W& z+`S0lL%)F;oDK85TyVK(gAM|6NE6g5o~KwWn6KfXGJ0h6C@D}K;icLQt!0lYxs-$- zdFA8fW~(hrhn=}(6`E#{I%O>JU&XZWZrJl1Q!TfH*d;Z1MG*j)3#S}5yy#dWJ@T9E ztbAhLuw+S`w*(I8op2H=^+T@8(BJ+vNLfzivb<46t8{@ZMNukV&|zAXKsPQiV!wza z8&`Y|X){=Tf`j(x=iBqGz2AuZ&y8Y?5GMTGN7qXHJqthrZ<8v?)9lWGvwx%rKM&z_G|&;=Nf4;phgl11}?C?Rwl4G6#72J82(~ z5VMGR`PCRLG*}i+cxUIvx%uadu!hHO6$*J`OI&2{Giwcd$irrJThnr^AW@UI%)`Y2 zf6GgfqV8?Ka`0KVI(tvG}`el z@kIsL&6u@75xiZk(xK~R>=0A;83(zB33&AFWv63VG>@j1Pof5!AJYx;6L%=XM(q$w z#<$!W3Dlke!}>7a3aD$Ho4ol4im*Kl%S1d5q$i1#Mjfic%gb_F2#FovlNytbkIesQ zXszKYQH(M)lUI-1LJTKb{$a;F`!r~fuG9wE#;Usb`pE~^>H8vc9CK|0 z-Xdp%0>k6yc=!#n4ku>=K100m*EX8endsU^F-4$-)WF4t zaR`i`#v?A+^Qnl9tVx;?+Uh)6waFJM8@shaU`+3L(M{AKn8r;NTDU9b&Zc#09|;{b z@pjjpzSr5#pA($k2HsnQXLJDt2;mQJ^gwsmsgw~6J~r~q-20Xkmf!v~`5=BzF zn#j}Gwa{U9JGwpraSs+rlnxTO4)@#|!L5IphlT1IFgTa&whKf`j8NNzVf z#)%eU<~()nKZwh34~y(SPeqqEJ3OD&Pw~_p8ZH!p<*4l#tbMY^eoRr~deS5f4EBa2 z(D3SIDf1%(U19nW|KN@!HRI1VJzQIx2O(C0CF$CaBkQW-Lf%PO9ZUy`-Q)pP>)cU7 zeIzX&QXjwnNF(F=i@oGGrS==~&+sCwSz|DIEU-Qvi1XSc=t8E`dEwYHZu2u(!p~qu zPuD7m@`zMZNfqU00(IPS5@EOs5UgS}(~tr0n^`Y^5hx>>Pte_!_{?+?I~PHUUi^7( zLnx{*qv?rLLD)2RLR6#a>r58E-1ffCL{g94N`T(;`LuE%ev8E|zsiC=zrCxMoudQt zV=WHWFOxzc_f2Dk_vL_B<+Bf#{z_GVjH{DkIH~L518b?sA%bl}ST0N-HpA4BpcR=; z(pAhElwwtPFILV>^uZRtv=D3DRS(4!dtlB2fl2NM#LICPTi6;VIc~+?jlNF<`m1bT z%s@1XAK}(VUfO05(JD}`ilC94V5-bdKzL{u3S?Rzm#pc>@+|}~C#UO6w+L;oO^mQX z*?{_a$9d<*>B86z#YOF|Uc?WECnt#4V^=Ygmw)~Pq+Iu{e`!1j;2Ek&a24ozjODj-^M#@{phDWi$RNap)?h-+eiG@?0Pt|r&( zLG$D5>N8ke`ok(yMbZpv6|?eeZQE&z@JWL1!-sEZ-qU%oVFlI|NU7uXy0@pN#B+YO zCFh8Ha3|s|z(~49zPknYfjXj81dQwttAD0bCUmon_jBREPZX`UBLjePaD^q^eW{{G zz#E~tqX&G!`@+~I9VZ$b!8`mten3j*W_#EjIEwrmQy{%YN!%x&RWe~m&E6Nmo0Ks@ z;1^q$(Tg!L0`miK+grqwu*^rggo8otOH4?ZDDW0=`2z3lVUX|n>7C|>r?k~xNub6l z`VkOOCO|f{mNH0XBNm17G(B^=T}zLe@?k19G+4@rJn&XJTlY891A=hEXA{7rRZ!45 zFpI4*S=t9B$w4(IX-PrVuc)VvAcciH1X4Th`Zf5i`m7yV7fj;$9-qp+_U81Z%@Ypz z7bn261azgXvDbG>F%4%>u)?zMUxee^PZ1*llzOR(4}pB2w>w&fse_w#H(1A@SuW<6 zk_vSMi}EZ0+75)<30hbg3w0K~r_QL5JV1v}*`KD}4JWZ-_|Z`K$gZjQE&+N+jjV%% zm3`3u;?Z@sMeb4Tb`gI_JY?qz2LKzA(O$qX&2)B;_1e z#)7)Lw%j_qC-ti+{>)NzA!q2Cr_V5PNu;_RX3|Kpid6gphX=i^tr}(5%yxH7#<`+B zP5EO-u9u7B{CXDm6RxY+04$vGd=nmOq0|K-)B?*uD%^t4Y`g?6Q{Os&{dK!9f-R5U z4w=J@(JaY*qo(C1)d2bE0v=64n&5gix*y?vxMqK44oS*do|t63$M=n5jAuWOBXaD zVe+A=xgqI*Vxz_HJ?jiYXF9`gpS#|sR8wXsOD-s1CO$oE%@dtrlmHOCG*K3j`hdMI zKnH}yr!%*Z=Am)nJ-B3Wj&59OfAq(lfPSdRP_hP&Zq7t`&G@P>FpEokreI+5S5|yR zu9C}+y!6de&?~pTcUA#1|BmP9bOpC@HT=~w8N_F4hu?i`6)kWt?X~5d5$}q>Y!F9W z`yN&E>NM80yj`w~{RVb$TY0Q2J=2BNl+_4DNcO4=`b0;^awUQ)H?}=l|H^1-w{eao z0eHqqvVW5T)#$|dJVpHnNtpdE0&(eVR@GY31n6S7B9dKVjAqAb3aIIL;ZUl~OK7Q4 zmU>2Mi=an~yDkD_kJo5r3Io8^Bqv+Uc4AFN8@EfHC4N@q{{Ecwm?$VR;Cm7y8oX8% zG~HW=D=3_s^~w@y0XDZpS6a^^%vKuSI1;iX^H~1Nd@jhtIvZO*uM?PSmI4y!V^p1- z>eU`|_H^lM9gpl5oq;Xqqk#n?Db~QDQq-!vx`IAYbLLisz)+)KF1MAQB0OF&9XMO!_KTUKko; z`bvv;80rZZe4e0cK9`M{DogIenJAkz7kgHoab)J;$Tw@!^`^Pyc&N+kmP9b*rd{1? zy5|87JfnV%R0E@lORK_fIsSyPMp@?q$5A?l;i{%cQ%KGfbO?x$kbN8Z>3VsmG|uT` z{g7>_7D07RaNRe@Siw49+gEPG6=5*;3|)KND;V=D3_%D&s(_l%+{v6{JbzwDeFAS z*rwqN#-wRNFf{t$r?#XvZOAB8o=hY`)|JuRA9QS`)+WX01YT;v9uLm|L&kZ#XV%0N zU$)PKbv1j(vp zsyT931i2HcpOtL-l$|I%bgGRz z0dbQcBE92Hfm_3`=`)!LkK_9(v!vF*ba&PYZzQqh}NT1wD=(^E&ct! zucKT8*+%41?%Z}kQP-s@{k%L>^D;lT!iTYc0z)@EV?!V2l5$UlCcjs6Ul9Lc6x=(B zz%5>Zv!TQMKp3W&q5y*|G~A~1bp~1E9pnkB2I%h6q=jXJg>-wEMI~lkh95XjcWC?o zd+!M{G)WpV^oCoPK1F0!cP-kTFwE)G9z=l(`y9U*y7vxxDJ@}|-eQg`ZrajfHv=Nu zcC$&vSfi4M=vW7Zmt37`u(NC=H54#G^t21n95<(iEgd@qU;7}sn;Jj)Is_Ds)T<{j z%A-!shP=%l=G$#ER6vBq-iZCqu*}$Aha$Vx4j=4yL4^Hp6LTS&*jbS5wvpKZO&A#f#`dT46ea!5Lv-3+h}#n=*x-VrcMKsrAIs(p=@<8aGU@5g7IPg2#G|o&1Pe~)L)?OY?C=vM3XC|C>{76G96-?7;k1n z4G)E$SKsI~GzeRcyab2{!~ic*!B-z3R!9e z)UE>r1Z+X6n{A3?+#EH!c#uZSz(GSEwA zS93VsEmik5M)^l>%l@13KF12h*XfzGF;ZC;;Tu()V7e0<_J(ff;?Jmp2f5~RHnplq zm4ynEBwJZILe9Bd_34O@yy7bQ$X=`W7b3t{M)gRn>=mP;jk@vKI#xlVmk^1MtRQ@r zgsb1imve6Le-s*&2BLCtj=VLB7L7ZMyF#{kXd@xYra04{V}~hEu55{n%RQ?PE;Mkw zZYcpMvkisA;}(r?1TweR0!p9J)CUamP(#4^-^=*~UyWi!zAlE4rOHN()HnJgv|L>0 z(4zNTd8VKPKd|3nzi)<$+$uUajCAx;W7WOmw$bfveRPGDS^!B2czI{+9erv!SUnz; z%|$3{&f~e#sGP@;{^|RE0xlHSZ(tCzeml{EIydAR{$)sy${S2-;@(+M$YQLCjgYe& zsFBgxS}cj+m%&8xW$I)y>FOI@$d(F){ep+byMW6ZXn2=DZIo5{dcskc52#H7WadJt z*aj!7Heo=DBrHPp8E0{h3i;2M>3BE1^Ey+z^P5aQgTX-Qg;9FWrR{^5vO04ENaR6- zEMuPhPKkgbF4WHp0@Z8M7PIkBv!CZ>C8|sD4Z#lM>XLivDYlhEi1f3YP%T+?m*6T7@6KV z14O08N5_k)UZSd_xu_V=%ZPGB0_fg;4reO{{~b*OBUN*-KH-W7h+*Wi`&bCFu?^IHH_ z2Id0;+h$A5U=`Ej+9W6d(CkV(W2SDDNM3*YInze4ij~c0DL5DXZGLj>WAFVQG#D8# z9H-3%C-n$~E+vv#L5J0$RkQ6A@5|P1I6nbP8t{{Be+DyBMEJ?QZR_@-3wTFPFJ_o_ z4!3Am$kDIQ94=VYs!RV-gbH)a6H%5=K2y@F98pW!gNpslQM^rnIM3)cREz0Ly%SlQeiEYiC z9O0-2XToy0R1cn(V+fZed*yP(d6PFJT5e%`LNi(2)K^U(a(?5^uMXo{O>4))aOB4q zYzaBrIQV*hVPv--18*Gzl~Wz;NhHXmO>p*i3R zf`ImHPoAG7Uv6v^B6;Q(VHuyB8l-s%OUvXEZRgp5IdH&b+u2Z?&N=Ouk#f#@+X{;tsT< z%(LGHZ3tLO7J2clUpaCn(7;IaWsxDJU{lNFbA?3ZT~XxQE7-jd-X?OF1m&2S-ROgF z0$WK|H8{dBkyAARo>T?ZgyxC&R#BvG+-L@GH|_TViI67Z44O{WeE5h0T|7#6-o*^O z)zEKA$BDumqRWfN5BBPIym?1#i0R+r2yu*?y>vQgf4dIFfH)}v>rU>XyZeCUbB<7( zOa+^T$>odDu%YP+2&s%(ftVhDe$_zFun$;&?G4j7g`i&Afpbyv zVvA9fyo;Z{4*F?4*`lr*;VT&?|-%55FbUh>cNIBg21w$4eb)M=nq;-<(BOkj6 zkS1n|-;O&UfT4Ctod6KP-yJIxSPEk*my@q+QR>oWOA!+JtT=xolyS>bH7@8ulSLdJ#CgD&E9x-!v^wkJ05knC&o1aXe{dlgba-1wttP!fhTjA$j zlGFR#kjS-Rv`X$CzU~s-RuvH2tvs)_Vmatbc4NpTw3_%gVAsrS=sAyYeo~;Wu+eBQ z^7Sg=EQ?;-X8YD_eOj1(-Dm5;(8=|US{XOfrj{Ur#a)3D0g?`=1o7PYRvnPP{gGKb z(i@cZq!KlAt1sxzMJ%QV1k$A{b)s9~WvujA9O99W#f0!VRtq$gmPh!uYp~T=g|J(9 zkIVe~9#8!1K)^xe?p181&gP!)Mp^Q#%q*p=q;!UbKstr!Q3o`JcFmB;XhU=^Ek%=! z$P^vgw^x==0YSym(8BXTty3gPq1nI$ip+Bj`__3fc#N6-is{goSBCP7WEQoSo{;QGFq$bT&FAIcn}gt z>!P*%cx+Ybxom0Yy&1`XW`!wVJb7_y(Rnlz>uIQVLXHCS3ljbx$KMQvM86a-mGqs< zM?-p*dSO(XMFHDRZwhL|@3XYO4@ zU6hk#XHt2~+I#dJ#DmehHvnls0rW2U%x{WIG&LP2^PBr~0g)QEWt>Pl-IlI@>p_J1 zWbI6_^Th}KRlq(6H_+z6U&i#r2E=KXa-^qwE)qO*Vtj%43=;J^Fks48qDq}+aiIZ3 z^!s;pyO8ng)vj~^ifd1aF5kB>H}Z4rIK&IvEn^=NawiDR>&zU6)Cg5pVN_E2vmv7< z%ukGPS~xYx;r@`cA+=yxSohvpu&ekfh_Zx2r%2jLHR7Ct<6BC7`claPxyS}3TIy^{ z5TRQ*!jjc{E$7>`#QFH#FOL4rWx}e4M7)@b4%@-i3PuA^@_aMt|mmDD36$__r+DW@@RIPyDM`-Z)#`x za^p0&!|R|9O}Pe3o%m{BZF`&W?7KOI{t%B>Eauj!qu@q${o?(+Ry(Y|OuZl3ykZ=D zQI5AzLZ=AAj^8<%3ouaFS2oE7%YBt-$tk(0k6zKahOGn>+1%Lll)K8~jRjVCsg5fk zrJ*V@Q0)g#LfDB?&EZkdkPT!`p@`}e2s=d*xbvVSF<0*Jqc;Xa{ZY%O^C}E@UlStT z^U~R1i1unJbm6@=ndUn?zBOYmL8l<(Y-h$49!IK0Kxb9?r0?58($X*e<+aF@>ypJ} z-=UX#6V(=m@qxdqko35Q>ZSS*P$Yn^$6Cum->wP17_Is@vr zYYnbTSV3WY!lbMzidmRNgB|&I;Vc49gQbu&{ap7Jl7;b@x)V63;~ukg%3s#9JrR3A z`-9JV?^*(8o`ObSDW-G0=HMV}awDLI+hcj4zq0@IkG_m&%BvFRG zq<_!flLwCWK(Q%`J)HWSFiN6>-Z`->r2x#{dLsi;GD%$CzBH|orK9d8N4k|7n0@_c zyPaUA4#%3UiavzxH2A=RzM3(Z9Y@ZRLpV>qQ$lH63%9F)_JT6za6^5wjt_x3Kpp$N zsJW2}`ms+Qn9bBap8`rc>Q- z0V5CY_}aubx5HMTMxaIrni8(yZ0aF`Ewg`M+K8YOYj=IZBoXii*2UyHSQiZR5B^}VhIZM#+`S}*~MU3IMDf|-l=nkE~3UQbi3VStZifE2Xx+A|KCtyD;wWm};llkOxLm57Og zVdV#woCPh(y$*OWHQG~_-LPaHAHQLb56EkK^z>`=KvI|k1u*PqbNT2VFUWSMXnsqz zwKzym)PW3jeY{cg`@v2gDt<=L5Rj?*a`iqbgkKd{W#&IveMJl zVia%u1rEo&?lw1Xr3rCg^Sk}E?W}xZkSceOADM?{DSxuXT^3jH`TCu2xXE~cU zIu$5+R94K+K(rGX&yf@CM4!Z``cN;_hSh+h+5y6#iXMbeaJW z;AYtOi7><`@L3#Y!n%{{1VppgowS!!o)RTr<&y5eNc|%goD}NDhDgCi?`V-FNcRw9 zJ%q9Yc1+x|Qp{R?9Dy;-?h1hCzqYfU6-CvAMjRKs_E_D5Uf6T?vnSiv22q#~HVx)4 zPIf4r01c&Q4Z%p<5t*>(#$uYYL!jFrvoU$lo0G>y_<*YuwWV!A+?MQIG?JLdlx3d}=dt2*%A>*S ztSNwJ14XbUWHf)o5L*s@^|X5sJde1bfp#Z(nX8`u!6|1WJ&qwQU-2c@PV6lyYcNY@ zKrmH39R9P#LVlnOLrUAes8D*yN57G~8^lr;P$Tb--VgQQBGt0S4}4+PMwFXY9k!Ia z8xInvE3!_;GS}C!_;JTnx%^WDK6sLHKX5w~*;yMCeY|uE-0FzX&FGo&(RtBgr}LQ_ z!roSMnNOw1;^q~zG@#7HWzfBTyfoKIYO$G7i1LZS<@T3}4SPtsz11Eu=EW0UlnT@J z3axKd0SlJ?^7G#keonw%`YVi)JcMv3;TIw(d)c5-UBR=iVP7(hIg4!Upt}Gi4It}E zl(l{aGLtEi7MwX>qx}q0ze3(#@N>I@{Bni~of1o3q1HrvYK`nVf5hWk9sy#Q@%w>g zej7VMI8i@6$>N+I?L7M7mj09fiqlx_Gmuz`Hy$^OzY@n!cP-8pgUts=SO@KJ6HMQ& z>1N1%EFNn}bg$bDTyNbrmGpa8<_jD13mnxYU)c1xsEJD2=*MQ_4kPUMBYI*rqQ0N& znR%{$EewW1eP9JeCsx5U6UVGnda-_P`=E1>%fI!5; zR5!;ZBQw}Nl=mCn$bK{ff!uX&Nc5Z719O4MYbpZA22T`AN%iIgL8zT~I+MW)?@N>U zvb9M{max+Q@|ZqB>VLHS@S<3zGxolSz5ot&r@DB?9uNRBXV}nHEyxzo%OrRMlbXgo z`%ulSag_yzv?OBj(aUkz->^py5r-4A>v>BPslMH>d>(K(>OTAokufzaIZ{aQ2wd-| zB@q};Oj%V-Uz>N|Ln_P+y(HOrxJ9k?F*hGkw12*R-KJW%p)1@L_G{ImdFUrw_EwQU zZh)|<&1S_a*_aLpr!~s62=ySP8Q+^uVvD5evK)N(D)~Gp=nC+RT9hz*$-JWzmF*mV z<_tnffG^Rx@FsySfG__PNOwgSje>(VxH~{qkqv*AL@sYR`5ag%7ybWG_f4_F0MB># zyS8oHw$1O_wr$(CZQHhO+qV1tntz%$P5RP@ru(*YvdKQ2ot^CLoT1D^tK35fJIjW= z8uNTp0}k{oBC9>sHhYETUhqYyQy%);eVGgD`1muD>0V0gfeE#M%qj`Bkd4#a%0q=d zsMV<8Ig#$10*ULQf3<KfAHID_SRiL-(tLfehTXz?@PbNtq4&t)o^>?Hb(}G>_L3R+^_~|bK!(uS(yc>P+)OBO1Rgr7nLx2hvSyGuK&rNp-u9+~k%L zCe5vx%nAJMwqN}mR*nYEdtcs30w63=ogv%W!w&iFCT3F)kUt+jM*TKGz*<_Vk%QQ? zS>Ihx2CzUZnH%mDyw^za6{j!-Gj$ufK0gu>TJX1bdjKnUt~{+*g5{K@XBjPoTVYFo z$)m1EBIGooeuF0&E`pA%Sc|1|Twq~^B;()dSXZ_sYmxR4WuP~)G1`c5Q>s!U%Q^!c zD$ZRa@!##qiV{_*pY$|+?lN^CY%bnTSm|X0g8pD63%agjGjxrG)i(ZsppU9o?3Idt}hYRSq|p>8E9YDxssx#ohqs0B}z%XAJ>kkEgccXeGb6NYq1Tn{5fPIes4 z%mu^0rBt?TFU&NdG+oDXN>B?aL#o9GN>2S#^IWDI$EhU82*%&u!kCQExTSPW58%vhU>wS?R<6KoA7}8({sjMT8{Jl}8Yrvn^>6En1nwosij8aEC_}Yf9F~^vS7%<-!q^KhF2Tvz;A%rJc=0jm;$uIUpApplO^k7MM zjMK39F2Lhoiog$PygYhRPD;mkZ%x9zUlw0h(Ru{rgKyf@F{9M^J)BsLY)!<@Y8}@W zG?=pb^j{s6DA1O~u!-btZ8{~Z*^Ed)`l0a`?4FgcxK;Ww@mQrQazlFW<sni0YUkYr5jWYCcu zs|;o}rnqQ9znCTkOoPD_K&HCxK#D=1XnZ|P15G@x@O`@nN4c}_f)84=PWIq=(OZZw5Q{vPhzmUS`%c{||q&>%pJXsOn#cZop7 zV7k$ei4eDH;S|fEgS&v9uKIZZKwH#Wx|-BDLwGNVSEMCiggw&ZF%bDc&NAj6+Br`| z7t`q4=&(;@Ro0hKy)mY!5+djt30UH(?7Z>xtL#^F%)#2n2Ap1g`f`lh}peh_X;-M9IYGtnomp7+M?Z0miYwJ}uI3K9QGLthdmO*dVcEj%g{k6?qcEXH zw(RCne->4HL#>}tNIab2BRn%cWM%_jj6zPd>f=U6i)_Tw+;|QKnY1QA*1@N(fcM2b zVs^~2uJoIt55*v~=@&Z_<{rEK(}qvNI5bfEmP;z&syDjw2zAu$ZdxaZ z0{0gyGn%*QuUc*VRi|0~aQFtJ49%h>D;^u0EA>Me&As%8>W+UvTpPbygQWJfDP2+Q zqBWlLv1;v#XuslbC}X14X}-GTBa*03npHAKWL~S92f zWWCx{89l9xeV3^96B8W5zGPeN7Z<3hETNXVT06cwE~gmwC7IQmD0^s|bxI!~o8tC} zu+b|s&?uYJ8<#eJ5H&fch`tAiY7yl?~OG>REdQ%CogFD9`f@u`V1cIV8=>o#7%>1mq z;Nf>mnTZ_#7)sWU07sY zjgYl1-W=xn1*^>AaEIXUN@)sDB|6zh!^)S-OUN%yOt~lrydZgKtaTiCvVnQ8ycB!0 z8m*5-tsjN{JPbY3?fPS_=J^A1YTCU;YfG=W^HA$T5x)9eQahPbVO5r&=0wNVW#NoK z>Hy`-8ks`0t1i4+94UyP0<_$io5Wni=F`?1&*<(*fIpZsY&~BH`#f^mrK(OBjHW-A zTUOajI59()RYtmyHY8bSouBwawk2Z`lY{d$*aiRquLN+%3~e7UI?ZqS?ncBWw@LCH z7K(NFqGe)Y?n9iCGIh8=G6eq!Qm^B^6i%NPpm+p!5dRGlP z<`p8!NUkP@Yxv4ao?wI7+=_*WGBiHaBwqKj67cIfL1Uq$5=CgMCr5#=(?uDh{tIY5 zp0$x|zUr2Dh_Gpk4je;aTzb>R;563r6PH?_8TvjT(s*pNAI7K=^J9;0AqC(Q;LtlW zMub6T1|VGiUUWxoykEScSwPpVhEgJSoPAAF7Xi_WbL{=`qB$|!B)Yu-L$~!g? z?9;t5y3;`|U6V79pY5pgv<*eiPD@me<-v@R!k<%NSgTkWy8=DQr>hg3d$T@YA@j27avVw$b8SGaRfH)fr z`qUW;b`f;PntTSd;ZRUbUKt2a3TjJvLlk@7H1=0msy#$dMrS zqaYhh zWT9Y{%=%K-#jt+y-0lNw0k#djx~2Dqr2yJW?=BwKAobO0aNNmWZHt+pFwL{&-Fx8D zt|rq8a;3{&0xMcqj6LR=&t3*SUHVyNri?Jr&Vo}ZOKql8j7mT9X`#O|^EPf5+9u+jX+xE!bNk&I)+{z^ z^19$`t0(VgjjMl;;;rU-1Y6|lCDDRk^4|Pxjg68IAJLOK00lS!OlByx;Zd6vRP0&4 z*(6Cc8x2C7J z4JFkht#e)dN4J;=7)V?;@vm;}Sw^nmHLmIg$qG+-DRM1`MGx)NnF$wZDVK#E-Y4zB zP?Hw6H&tLqnx&^sa6SMT@I=sdS^N#J$;N=x2%JtE1k+KK)tuJ+j*`wskv)kYlwb;%VZd*IZ=x15y z5{8T{P~rGU^2RAAp=wWyWjuj`E>|$fZ@ox8O!U&+odGl?)x*XwP^!}Jmw-V{9%v)e zBEu1YGA=|E8;r{s&9bpH=qK1F7{@%*^rmjIiR(~6+Jk=6uFCyqrRylt9H~ii<{|5} zySJb~Q*j&JX1!RMe0{9I&=&WH49z;?Cwz-x4*oM;r?+{IGdex*C{ECW*O>QQk8HVE zAgSF+$rTokU&mww_JsD-Gre`DnMbf)VFA?*$RwB%l~bhG5FkcfhAhqiyJtyMgwpi& zv`+ICj(_d}w+Qg}_+cH9_3KF16x22|i;9BX?JUAa-g(Spe_g+PG{rVvc^+m78HAEF zC;#`0H3qAp9m0Tt#Exd{|9KgR-pL4V&ItEF*SJky^(JI{W=URaze{Z*4IKD5B% zON`cMl1{}hUR56hps7*w3p**};61T+MW8(1Dz8_4V2Er-a)g<1nam7~+IR(V`Bl-Z zKTuVMJK9H=xv`(d(n`meNEtwG*_3rs%*#vswbS|2%@1mlAqFFzYM5Ya-=a;b5zFyi zaCSr3*=J$cg-Z%$?V4KEu&G`<=KtN)$zjqD3AsCi%GP2ANfVe7%{fXA-s?tEQ zB{j=%TPaBk#^w~D6(wkc**pi_!6?CTdHDRcTS;WRzf;BzJpTh-yoi7YJ znB{&q{(tlXD6B&)O0aif@wW@mqI-~61Dho8Bf%kG**^!gWS4mVx{8wsG}gXRo<^6) zS&KlU48&TCVJpyC`7l=gCpgWH&|G2yoo>lIItm?M%9_tH#Sm1st(dJ4kg^TG2cA=b z*%HhP86_$Ya~7@eJd)b9>yrWjwE#EdQs+9d$CK z2NJouY>wE}0{jR>O_|v>D<+xkU8<;^460%s%k}y3g7LaSvjFRMs?=)`Bo|p^D8dZ5 zEL9Q=8t=MgTxQ+A>xnlcRLn=26V1e|AA1^Zi6>NXMPhtIP(|GA=*gXTub3wk6#|tF z3!vRn=+WnT5DPtz60+GwA)`UY;uLI27 z_=(x$K8J-I@O>YWVrr`no5ZNF8LSe^^K`O9dySphqRB#M*%QgrxPHFrEU2KyRF@A6 z2n8rbr+@&3(UJW3QyLvzhp&dKYOF@NlslUkw^0Frpd-gnR75sAdA6qlfBhd=*lt!8 zNO!kqd+_WKW%%H@#8(jyGl?Q*tdi#p9f9 z!x%e#1_b9p9)`)0X^c{HIvvh{6BUJRKq3>@#s%V*pL$%;HYN(IsxwCPnN{?}cCY!tn3&?k6JC3236oQos`Z*TpT%^5 zYSi@5!u%lRul6Q*%Yqh%ZBW~FOTUHkB@%N(a@tbflSX1aKaE-6wPjf?j098HToD{D zP+E9ZBu5cATfbPd8eYHKOWj1Rei)G5V05w9tftXvXBYfWAbcHy?xu06vfl(47p~Xl zMC5@F+i2x6s{Z!S!LRShoo^CYV+j_hK7To3UE=YXFnR4;VdftOg`o$zxOD369?CIrD!ER+ub%A9a# z@{3@*s9kyAB{_9dg(^p}ImSqS!$4|VlUlW7{R z2s5XIiINV_b0wd<4daP#wM(Pocb-M4#N@fSy6|pLe2Ac~AWjT9EKWtELP$7mL7$Sn z>w&S|=iMFI+fVsc&07s%$_;B^e~uPBL{)5G^*D|$fWbxYM3(os`>1UcIlC8CPr%Dp zBTIfdCXFcP(ZBiX6*uZM*-G87K!W~-1)kiD_yCX3B2rWU*D1V4nR-Z;2zG#|lx0(# zx&F91PVM6C{yks$uc%>?lx)KLhB&lJ>=6Clo;%JQ*|;;yD#xqHQ)sA9fi!Y`O4rwPpWh96$%;#UtGUh%p~aa6WY%4yCqbzdm3rd}R%<)G@eq_N}xB=jNziV{4ZM8#x$ zm^0+s2RU<}fOv_9^7=vn#6L+FXK#c{O|Gl;@QH-)8!|{#tK~yB;N+g!K6%%<9)7}-wF)b-g@vqi1JE4Cs(+;o9HRtZ#%@Yb5c;&e4Y>k_qrbQ@!&%^BFd#>in z@Ch1j7(YVyL-(xIAAyk9$c_bva4mLLWPpPnyZ!ab$WlOox+?ynmcvL+v?kxS^RK1g zFnb(MG=R_LT27|U;prNF{4s4~_aMu;i!k`p0WmB${IsaXX>z0^-7V2K#&3E!nI(xl z=efdpA0gfx&Mu*d%9;_E@&N7?UHZ_bal3Xb#ARd7{Dipd#iss~8q-@Fdnk&Er70zs zq7M}2xx`P!|6r8%(jRY;ZP=ZSAOjXX5nkl#I)nbc+hdm7#R7{8t#fk{sC~9rBG%)*P znHL3xaK38>%T@XIB1(Il&LMR7C)@t774=Q2LcXG>=L{G3-Je|)!A@0zRHRC6M#;<| z2IMG!=UEoAMF=j6JQ&)3!$tfR2#{yWJnaref$XVAiN{?eAtsWM%{I0mHzjG#cpSe6zc3v)@1ikvj3QzXaxcv7!#`QsS-sn z-%#+WYWc2Jz6Upw^p;A&j*Lmv4^&AbSngOsvCV!il^FZ~USeIUOg~%56VukqTy-M~ zULx;I&i{WjY4ED9<@FL^yWJQ_)s#Mb5yI6X_$fz5Q^s|isTfgdP>^*JZ-EMlWte6t zA&A5OPsI2?28d8aBOYh`T%L1A=m>5CucTu#Rk3+Q%G6bo-qDZ~tu}kaU8rkMsqvdz zEQ>ZX83iGd?E&15H%wi2LKOuIs7DR|r1zr{rds3yyjFy>*Tl=E>BmLJ^!}!8mfqY= za$(Hc+zsC+;Fpcb(xgApS*B2J>H^c#ns&Y_rmXJl2_q)r(GQRWivFg znLyRARg7Q&+Y+Xw)PtIR58&@CHGg!U2(*9-cONp8<6cVQ^nFNPpkrZxa_)TyL5?f(6);^T~fipnK zXa*OR^yc#@3#@h-9f#G#7XMnL2n&kANgONk3|xFy_ueK`Q8kKoh|oGRQAM#UC1B;S zT2)(lB|gS$e(@$SpCh(i(pIpbaNYJx%v?wUj1gSqRhtJuYS|~KD;kAOa<);oxUM6? z5KbxJS}42QU%yMJxTws1^}q05X)Pyu1hV~(gl=ITUG{XS@LH5YS}apr?(_rEsXwd= z{EUePVtMek_@*t;38UEyoR{6WcCD=}L1nH%ex14xXscQ1FODHL>JxO~rkU=b8yLJY@7-Iu^@;PAyvn8=078Q8NX??bf82mHk`g3uI7 z#o%QjafCQ5RcO|ea6bj;4?TtJu_1F!-HNx5`^QB>^fV@UXX?iT;8Ye-VLv<5&C-fi z$U0O`eC~&-zXi>;)1*D%rZ>m<^ng{{RoQizV4x+0Ww;#&P<4}Tu{E~xt!cr3`wyF` zdOM1=0FVRyPb6~Zpf3H%ONE|UP7AK~jwa;iPK8a#e-}t$UU___4?>OnXr3 z=Avfb=p&1=Oo)vLs{e!B61dePC-f99(mTTy zEsiTP?;Lq&p14g&eCyd6lnF2_i~rpIhP9x3?`%xq8rr2h*()iV1$3(JU(xxsJDJTQ=@ol(kG1g&Cazk5;o?V zD^z7V&i`{p8}-XcsU(3twwOF4#}pd0eCOd#sV?+mJ-wkLFZg=T3334)559HDZ_?AVG$ej%0E@6hc(A3pLSb*@#TB!Y;AQl@}RVH zq128eo7;3xlyWud&&zLPLSV+M*V4q|m(=ECfC~{H8Wq?@g|=%Q!FdG2OiO);^}v*AAbES%I-FF*C;V~A@mZz83M1<8^X2m`_R8n}FWb)( z2)A9OcNp9pk zdDKa-CN%Xi6j#6p1by;>#~IuN~-q1DaLf=#)X10k%} z>eQINb2Zu;2#QJ?fd6!S!_*%sU-F)gyNS^)e9X1bP&gnxmr%~8OJsydFi*Nvus$-M zaUs<`QZoctED9lmEq6U*g3ul=o(NLKpz--JrbtQbJ|9yNDfC;~!-2gLNQhhG%_4#oxxq z33?wZh4bI&OdOK4Kj-Jn(f#vq~_lTDI@PMML<*=X(y4S3qIU ze5LSZ2IbTPi9HPV=@QAat06nU*a#Fg=7WuN*dTe~l_hbWIMIto`DB*f92f8xITv<) zt5C-Jc}iux$(OuBGa|A?(f}fjEW%4WPFw%i&Y$cM1lj5(;nc7qGimZ?LK3!*b%q4H< zhDsC8h-XbuGcqjU6-#}Kw5?UEM5Kd@acq4dZ*57Fpx?}pPcY=c!53_J>OOh+YwIBO zsNa*Dr5nT*rt2$A|uOo?$h+7S#V_!S&&-iaQX z09uz@`yp>|X8ggsmylHcgfVe8`TngnfO=U+axQK}nvWdB)j}Pv!+oUC$G~Dw_Z{!-M^YM1PUS4;Prv85v~I!CXr}qur2RV&v7DqMA9%}dFFu3 z^o&M;-Uk;>j7R_+YJU$+7GTL%ZAmS_c#<4u)bKvk%p!*v|Mu9`^n!@ZDu<`H%Sd?H z(G%+Y5#J>LcC@(EY=cJ@FnviMPth5$)2IxM2L1_I;Rncu**MGWxmn;_d$eDi5*#|a zS9%HC>Y(FUJ=~)A zU(}|HK8emIoi-k*g-HqU^H(NGGMdcv+y(m*L4>_-NXUQ|g>2@jf9Nrh20--ru{3xY8cu5XdAWuHZ zEoS!nL)XA&B6W#}uu$S^bHgHJPvA(ZknfA0!Nio0Awu=n5)o@)DM>z><}%=i37)$s z@1wduPq<)vH?CB&1~H&??y2B{fdvD3Fg6|?V@j2LfS-)Q>*AZS&&+3>tnIxRd5w86*bGakeq zc$U~h#bOBO$Sxqw=(2S$no{LpQo}$1m_zwd=%`Nk#V)^&GaY8>G&4&Kxxi<*jv)EB z?3rgc(Lbc8%Byg=v@G2iRpT8|7=3J7S^K)mGABDW>>Ee@!1T{Vc;w>rqfSRsF+VGB z&Tf{NLk4v$Ke-lB8y^ZMOeJmJo3qCSUC%Uz;sJV4%`M>AjQ}DwOf4(*;=w>#k2w@& z=k34AW1Jx-b&)!d1p7_GLmO&+6{eFp-q`w7xAm68x!(t%T&sm#$@PYOvskT)Zyq*% z{^m0pMT}7z*pxtriz{S38`+FJ1g!oVQ*{AJ?u^T9kjH`UZvPddLi1dDAwgWzM>lsgB_|eoQW{9rjKXz%e*t`ZJW$h;F7O(t3Mb$B~ToNWkmCuil>>>Lnl{)gkG8kFat`XNCkZHvqyAK*j~G z6o5uS9K{1r6rrAK>r}AJUHo2T2pOf@D5jc*)B-ESACInHWQB7!EyVn>P}f%a08Zl( z*kn2B4NcSq!VOLyKLCjzIo|=fLN~+zi!8@j75sx%FmT~7`)Xo_XvW^H5-RL7EFY=f zH-i3;F^LX9Ixa=1IoxAQwE-WDyZK)&pu5kFtmaAs>3unJHkX*^ahmaV0OjuN#6;N{ ziv<@q&1T2y==FxHjDDHiKdFo!i(mWiHMG?^%Ld1Pn+p1zouTS2`VAB|&8Mj2PcXL5 z^dmqr{FGx_P$08O1Irq^h9^0io!pf6X+??`<6*x{S_({lHf)M_8-5;H1zlV{B#3`L zbpoMqWr}A0S{Ni{KwwpaA;SCc%Ky9Dz%v9QMOBy52EvnfpM3%0XO?{lvQoVLZCl%z zL*W;9S7X`8Jl=x8QvKEInW+{X_Uqx@a$t$hBLkPNxUBb|mljLh#Piu#PYq-_*;^&0d1%63-*X3@O z89w)Mdl^yj5KNIW1z>Qd#yGB<2EeWw>JjDoxR&YSbaJFJ0K3ZU&S5fp-&Q1P09Rt;pN z!g`CVRQS1w0t;D%Deu}GrcR_sUVFQpvw``JKz%d@7r_9xCcnq84klv-sYh|6VU0N~ zS6vPz_XK0Y4z&GI^>rqpfyhLH;cS`MIcyLP8=(rn&A$n*h~>%}S+Y}iZBTc^rDPTF2GHwa#6#`uL$J^4-1vH;Cg{;u;&1wW>S-05)2TSkdHrF8> z<6cU>2`#Jl^Bbr+=S8#D$kRy$IgA^Qpa_U!V_0VqOWPXsO;Ww(4Alp&ldSMkgGo9UEQ{K4x^E+vSe$@xq>b8YSuzZYR9c)?0NIe(%o8n|EyZbJa6u3buH)>K z#xNl9c8Xgf4uw3ZmuxY69__WUWvY@ol53IkE1OU=#sw3TYU1D37=e)OR1*+!Z&D|kdEo#K}!72w557C ze)my6d*`Bsz$!(=T3%dEmLm}7W6w{yyNx~tC12ZxCamlr`v@1^ zF(qjOQ-rDsG?_H<+<+~JA8vo%!|T=vGu_+n)a|P7AMYSkXtW4Fi>|(SwGFolf$*4S zM)<8{C-2u5AK&GY+#*&2m5un<9Tv1K^9l1gFkU?ANn5W8Xnw|>;dKoRjA}=xHLB3x zzn&LsLZhHMFCM1~1pzuf=WdFkiamgxV90sU;wVXuIbnksomG zED1vU4OJZ8!E5 zzpWR_P$>M>F~W#NPor4bHyfbuR_T#-ua-#<9DQRtE~@y+mq$%UVlE1$Ns2#4rJIgS zDerR4H^ibBzx(!ok)c|ir%91@-pmGP8ND3zC(giIVyMr|MI|R9tUFbCY@K{&xaPu9 zi|PD;n>I~{3pjzy2lkoD`_0&#e^&+h=^fNJ*MfOxN$#HgHHwS^_$X8uJ0bsl`WWG0 z;xP!g&j43tnNC$vyQP=7HwRbuPC_1L>6-~Coa}}*$aSNk6=vH`l=5)ceHdh2N1|h+ow}{=` zIc_QbPYu3e)SjQG*Bo|b9Bmy9GiGBC)ib2KsM1uRjEy=5^_^_w{wB>aXdvR@QgCrX zru5$SN(JGc;-OxLTC)9|=MG&T5?A_AX?*~)Yabfwy`9Z>983(M5nS!LA1%$n-u5Rx z7Pjyh9`YGG*!XGcPELa+)5wy6b&mFu#jgDX>t&ui9m6F*>e9v>v!p;fKN{*UQtP_L zbJpLr=8$94N83n%Jz=bB?Pk1Sgh$jP7Q9@t9OdiXPcJqqzDzGQFhKlmi|ev4^4PRi zg0io7?uS~N-_8AY^{>r?K6V7qOI2&JePOTRlD=_jVRz=bdWs4t4g$kGs7M#Hq~D)^ z`^^8hIl=HaK4c3%UODcbXJeTf6VK@$Y9N1jn`W0KA7rs9&A25WuN-&PR^RKpt*RgE zdp&IMK^aEiyzBL5_sdz~H_=)~)w;UAx0W6}#2b<={q)*7OE!=H5B<(i1HDsd3#D z`_}Cc?8kTqbyugMH6y<+3HPuOJ~du1pHjh50Vsk_DYcY(W$Z{Vak~ZZ$#5Si~gG-|7(tprn95 z%X?Yt;HQ^!n8_YquRT6er_Lu(&4M#^TC;0;g)@|_^}!ly<8(29&7U_2cIQOIR8$Rn z`r|yk&HSOY)~gp_mK3%rO8R#AR4&cRhq~UkI3Qz^DpX+@Y|y+;K*Q;WLmB~8pP3l^ zqBvY>^Ia6p+0w$>j-#RIB5gX;y^vOAKw3>boGQalLb61BCzm~a-SN7Z^(Wr}Mk5D? z=c|k}jUF6qmjD+VqcAVNMhD1FLDw9KaenWCCCC_dt~j~anRkrxljY{nVsoIb@WVKW zI>FMoE+2ySa z$W+p2LUn&zdv|3QV^<(T>D9Aex zh@3V;S`ofW=eL(NbZ$QT?ii~dSAlI4(r@|N7si4lPXO*V<1Sb>rDzvzN%EW+ zHu>Cm>~QwFQ#|+7JGEJwAVMQAx|f5)00-_X4Op#-b)sM^M$R#>_}^ye9pEv|WFgp9y-R@hCRgf2fevti ze#lMOBiqGcwy7VfLHK-Gij(27-zPE%6NV+P zbM4!H%@DXo!Zm{G?Rq@S-hVvZ8vMJ~f-f!2zoFqNp3^g(@8I`&q5r)+?tR{0J@Rx4XWMqB^+1Hcuo-K)r`NK7}-)C~yAKJ>nc zak>;}xHFazn>Qp(@}nZJHW5;7rgcjsS^D?SdVhjEZruI80^LTIBg&`Mmoy?1(BbTC z`1gd)O2JCmc^tF%WU+8x+Vo@phbDYTgJ)_#IaqH)b1SJFWu>m zi8qfJ@s-uQwkQyECC>CJUBZ8Daf?*0>t81&v72Aa%0mqDx(;I@XjlOW*C88mz(Fi8 z;xDgU|8>lw|8P2TZ|pDbo?XP=e)Ce4oOa0^I{Q7JLn2eeMDJf5Lof}@oHcK13;b&_ zQS&!%Ls}{IrZsalfA3H21iwKXoJ0@xGxDrs$ugw1?AZ@42cmbW#>4vRfXec+$?)_{ zTEk?_V{Uxw`}Et7^3xh8vX<0rN!Ld)MIZAfrRQR1>fG%C%M1&4lO-{*is)L{deTu6 zzZcWsV9tpFe|DK49>YVqCz2sv-?Xv;5*lt}a>ol_(|{kAM@}*^&%QVcgc4|IT5M%($>L8%_W5L+ zYvkelu-koGL_2Gc@IF2Odv_KFJCR*NvUx@JbWl0nI8-YyYqrn+AfkJoEju_{671X4 z%Y_$rhLE&H-wKip>M3m5BzMnRNUyx!KwUg4tAqLej~b*HazH?F;3hgO)5f+30!@4P z*=xaHkRe2u^xhvmBmoPs{2Uw?<7v<2>D{Z2SyV|tnl&9==)oFdX(zz%ceH7KO3lHe zTZCjGQwo&X9bJGX7$s0frZW%p`V$DgF*MSpW_v`w|f-QGsu)b40j-Xv+An z%r|x&H}n3iOQPY7&@-}_UqYlQ6q5{i5c7SHTwz0C;r?KmMnbR+UTA%Z?3hh@9~U^o zn~lXi#tuYc2*NQ9XW3b&x&8Q*3AK!kr-b4)ANXVvjv|m(VIpzX%P<;xc=4_nT~(VE zW*Q9ipl3Z5QF7TA97r3(Ty#RVX7!To3`E2(BmH13G5BW)YX{n;>A_J6(F+g@>Qw-( z?%Eb~1EnwQXFS407-}-Fla>cCuGH<8(Z2$`IVl88)wlT!?F|`nhv{+urg{EEtPoy?v_7S;mkLe(}KKCo_)H@v#l{FDkqf zqw8v)RCvaQS5@B0|JSCG4;HyduokF0X=z+3Yv<=KX}Uep+w+hk3Gb;bI-)>f=3r+4Te2&_P~AZT8zE?cXy^&ysrs8F%kiTiP`Df zO12gNSwHz%k(l)cxS1H>mW{Fw!dFZI8egGSvKnFYgOR{^#&GP-E*fymT>(^FDl<&- z#8p|y(NTGNA*HvetQ-Y8Hd030e?j0=h8kJygZ*ebql(SYSM+r-Rik+~Fll!h1p+n* zA(3B^upM$(3}EbXp8_E}q_Ef@@vy!Bf(oDiI~0%5C;!m~$*1~1LI1VtAN0Si`aeNK ze$!{NggI*A)9HrJl3(&9Yg_WpK_0-Kg_s*ujHJmO@zC?oZDLqWXH#Cy_vh2rEXx~N zWXINecgvz^y)Zm6Tl@yJaK$ol>jHu*RaE;Aul0tM4m9H4`dbQz%)euQ%&%^x0ULrR z{SRLv-?Lq5J(yoX8L-m<^zk7GK%%!?iaa<;Q7Ryq1F`70K$uT!6zmGav#1Xrgd*cP!$hm=av z9yH>J;4*XVF*%RZFv5|pd5%ZfMzEC>8R&D9(YXeM5BRs^&saXw5zsVwCC=(X$wO$L ze>S^SEV?xvPiAs>?gPB{G8j&TJurxI@`A}u?;%Ap=f&IaB%$VcwEqHT!y=Vue9D~u z`4L4e>iMRT3`d>oN)B8QPtQQEik1BB^Av~oq05UQXzPNc+E-2>^45tj>Ews5%W=-> zV{!dyP868mle8Q8WM#gy*Q=;JbzdG_R-bjJ?@T2jYz*@24{t58#$(Iz_Nh?SG4xX^ z5q^NLdYGJGw84BdM}?`A=deRFxbTNkyUMz{lhoBZ0AEH$-6DN=rYi!NKqLn@67CJ< zxvr})Ha;fLSX_|ag4R-l&o!PPGq+A7hGqsUJ*=8+j(h;nHT4yNONa6v9&3Xn2cFDV ziUyh$R@J~>(*r`%U+K1#oLV7W6o%tb=Hpn4xq*6YZ)%QVv(uLw*nd1InhXo*4 z4*zt-(`D%zTY#ixYF?(emDssIDD(@~*3;eP*+)C zjmxBsC8nbqxg#}u<5R=IW=8?JL9dkimmopiCFxfOQ^|8Kl~V~{98v*p;f zb;q`C+qP}nwr$(CZTpUG&))C7_h)}@?8bIQbVWsXRAxtYRb*A>$wU9jhx!F3P>{j2 zFmJyU>bxIwYh$I!A8Juf1^u<6rzGrEoNq^^JjR=w&f^NiueBGEThqbbaML7f> z7x0J;sHa@IvDXD|JAa^yr)9{p%`bYEBb%IKIW>K>IS^IS>knLH;usJV5La#h%-x-w zc&n4;8(FE|O=@PA)n;ZwrVwU?W*pF$%NSi;^GlF)(|}(HL~St_D15cE$Xsv2E#!I{ z{`t`XoERQG%Er~^oZAx2eL>LB5-lER%FnJg&A)tr>7|`hhM`4RJ@^frRrS{6d1ERB zobb7W;?Pk+A6mw{GhreZ!-5A-mRSmP`tY0orSObR6P%NY<}Lh=8;J6Vw{e_u z9L2B{0W}B`%Zcjj_$hSDIEux-quO^Q-W>?Domg>STGWp=W3r?3cVA{70Hm`9*z>HC zjnv;%#3+Hg)RNjkf^=$I6&6u>?7bfm8lgFKs>_JjPn6L-c=XKW?^14vi^eCbM|AU` zE%d9elN8jN@ASh9r*{2C%`3%si3G=}L>U2`l}F5q7!fC)<;41wG#RGo&R*tpLS3Kg z1yrYIVuaR>(x+siQ*@FnKhEkSc4e%vGxthTLu$G#V@y{cOU55P-&!!1u|HTDM{VBG z1^3VlE)`;GC;8r&C_u9#szLLLOUn1-!McTL6?zGTYI!b-QACpzzg zWVRrGH|`N5=o)T-l9X-Oxj`N_nI`*H76`F<}|bh+N5q$vUT9V424% z;*<#g`w<5$!-Y|{QHCI;p*h4mCdpVxti!+J6thEjAT*th*(enUja4zm7_2-zkNCj& zf6_vW@*b_yPAvH<`L8`QV19x1$V`l$a7F*Www-T|5yLq}67$S}%&V-KSE;RNbeba6 zDN;H4KPQY`$O=;W@yG{+47~bOU!LXMgOv&Cg!z04O1X`&T>5%-i3V4nMnWFV{Bkm- z>b?XE!smNi%@~?l;$T=-NR-)f0kR)A*@9d*h#BT1MtB`eC6CqqYI`fLcAF!1E+okk z4IsAFUZnIquKm^->@X~vwVgs`#em9p1{1(5ZeHd>cnzj&&aBtWRLlmy;s&G zM8nSU>tG7&EV-y#Ei={1R>s%tuZdN22oBTP=@%vj41#XJW~4_~`6jdRHxluU<1%`s zRatS#tqd^gWcW_dziEm#qwywT4cJA0ym+?hxt8`xgw1`)wx&dyvW}O{!Lk`RKuWp1 zRt$xjd+u)|p=PzZ+ziU|=U`xiowKZuHfkL867tyDCW#nz_csPJhA#c_u>)onz$GN- zx3-G+=l-u8uhd-dDkoYAmVm@lL(L&N(v6`uCA9fp);+LR=lOtgD5$Zv{FmCf3Ys7h z{psMvj1I%m6!bFe)Uxtw>iOM+>ci$Uly#5P3!O$(Wp@^TsYV+uJ{dm9B4dP@^|QdMDVgNwvz(^g&-22H1=I(M)WH%$G^~&x;6|)1IcL;(3)Xf2!t69= zlHG(8@|OY-rC*NXNW3FTo?}Bc4jxU9&4r9BsfyQtA0gAOW45UM^V-EB;L{>p0JMQE zZ1XBz0HHu29Ft-d!jhxGp{0k|F3YQDq9WVu(WLyB^-n?}ygJrHRsl$xrx76G(M-p& zR0)!_qZSUiX#`{p#R5R?ID&&fDDxwjI@v7_$?E1b0(Yzpz>Rx9ier9Zai>Ab`6`Bx zX3!F zL-zj!%Yh;{;qFn@FpjqY$cHo3rElqEyO2@AHWMBwyb{C+y>0k_@23{8UB<>zDG8mb zU-L%{7U)`yYTU}Cm_xVR@R!2c?c~+a&Z2TaBd!QEF*dX@+F)HggP5eD1GHYbf0<{6 zK>qaTFSZNb>oGF2*Dhk5$2S4j$mta%rQiEKD&X5~IVCEd{WYo6jV{is6JXb3VJUSB z9C@9UoZ|wi!iM*@UZ`qVSmSVV#q>btFh%Xm^{LIti>N9At-KK~nw(o5%cHAobHG`N zb1+08{@~E$^J8*cdj}6qtEbn~yoQ6*ovMRR`$Dy5KY&1Kh)mmyn-8a{Kk~7jqH<{R zp_1fR>8`E32S7QaZQ(^?j(2l^K(pD{MdhQ%&5Gs+RZ-BEc$AzZpsKaXjGhS!s$A*^ z96;x0CzF^LidbKXFji9!pQq7v`0J;r9?QJ4fEq(DDrM4@OD>DvT!CwfEdeeV_ryFRqop_f8wgx^vyPc!M9K?@y1ACzdhM(bST z5bFQ*try{0BepydPvj2(fQr#wLnJryy)PhX=AYo9o8F|hd1IV_RY8PgtJZxF5qg5A z3?BJbhmcn*Gzy5Blr_144{)X__B{L}e$8HpCItkmV8MYZvD8KPB_w1~NX7O-rXLI* zu$gI%RAXh&Z&3ZRs4ZVRGLp0nKec%#YFYVp%*ac5PBXFJ@u9_4))&v&+4@Is>1tD; z>`w$0X%=Qn$DkiCRi$NdSSs5jQOjZWfd)3UMt1`rD!zM5N@&WoZ0eEz}=`u7wlEY;8nR+U4Ve zRuWQXgKrgUNDK(lz+)iFar{wsLG+#{ah>B~eI|sI_r-ame2|k;IGO8?Xk~P4sMt8M z-Y?wL5}zq0SBLKdk1l=Vj{y}8B1Sqa{9389fBA!VF=8WfaUMWSh`j3zvBl-GJtBVt zSdBps_ymjEO4|VY_+CiDw7|aoEcK0zZ^>?I6LzDj@P!b}25AsFR0h!nC~F4`Um_lD z9BLa5kpqCeXT2IY_J&N~0>cVt_OZAR5c`c_+0@)OL_$wdA=}lC=EZ$*L-m;7=9N%c z0Sn}x+-jR|K^u2~wmzB;7GTUeq}%>jeKHTI5@bsW(-`~CoiK0p)?N(DE|htvoE%{} z1FkaRc618RRYUL)SR9B~>ji8Zf173AC?CS{)e%i<2llq0aDMfDy)e zrKzr73E==yzwap!QXS%8DOI@#JECHYkE_w6zXXrU^{1yvf_QSlUrHv#_(ag29~`!*U>x2d9NGsv&l zIEF<_^Mu{|v0H9CmIdcsAeIkz&jOloEw6X9JvOg|2#PCC`wmHL?le7YW-w?nic6!5 zm3aCaeVk8w#8{*46Qj>KMNfkfq`Rh%w5X&2T4)_kGZ&O`q6Ox}W;Zi9n1(9Db0UK} z9HG+g(kJ1+lZd4=q5c%?fI?7pbpC5oS<`lT-t~`-@ilSr6O1l3!7B_oN)~5+gl>ps zj0&W|$5TMMSF%Dqg}l?!;!3oiY2F9TqJ|d_q>~ZQQ~+6>e{>LL@M%(4jPC<0j6w4T znc2hu0O>m%n6obeb{>C;KLqh-L^{upthv=z<015npv_D(dIaMP0Yg z?FtfN8i?@fD)-mGWaKcw%hu=US}y_`4%OVVvf_6~WoyWvcV}Qg5D|gqjUEv%9NrYebsyq^wJ&i~5n|;IA-KPYF!aRT?EdEx@|g^o@BOxt z>4pI5g4oiNOpq+}O%Rn5%Py^-`4a9un-vu95N5OH-zxdvmr%9vB^ceC>LSr8<<8fW z#BMB0Gk-B;%^%(l_hJ#QU+%Ga>LWEebiGKFSuur~Us58Cgej&;p%va$35`GH{2$Q7 zBO1Dt>T-bi+r3Pj7K_Gw4RWr8lkcL77Gw*Y4dftJ#RWFRWIo=ZJw_L&?& zlkHYqnG;+pG>NxijcgxGMj{D{#m z+5(zG$9Ht=UvwB#>;n_QAvZOZ#)P7BKa;m8O)NADH&6LU*fAc337B@ZqI5*hwTU5r z9|Tz_MQ`qc)_H@Ar4F1Q0(LMn0iE^sxd#-pa|vC*5!Zt>L&>mnQ&@12V)n8pyD({% zqiq%X+0D`I$9U4Yp!f&+;D)RGBrLy1rlFp`886zY{<-0*8V~LmC@f72$tY@2U zj$dqKpY8zL_&=c90Aiak<#I!wpJ$Tl2~W~&FU_UlJOu5rLqT};7b7e#$x?jfk_h&w$&pVL@%||gjk`Do(5xqf1 z2fzMeeE7NTiTPj5af(|0jtTE&PR93VPv<6mP?$%%Ut5{U$i70_b;2M~JlFf#p9T%; zPvK1enDiJJ{*Uc(vOE93ItWR-0j;hU4WD*Z`3q|DO(jT8 z-Dgi@V#q9N)L2JOMR%@=XkhXgU`2&k$b_5XA@Cm=QP6bT&>HG7i0M~VlTcG1s=@02 zBQ$!C|GP3_ zG)-pm1P7FPCH2uV@`2?kqp&h(OYP#?79ji7v04>Ve)Ugfs#J}!0b8)mrBAe=@q4)$#E|gq9DTQDi-)`kspvj``_i(xw8?O z2!hYAiogE5VtK29A7m*Q;4laM^{~4;Y@3Z_c4s1yy%W2y;T0S|86J5wAl2B1= z5Vy1&A77g{G4Ms)uxh<>hv9+`ZOiuEQaG=%n8YG48)=$1m=ZCSWWlgm6Oxju7tii0 z{_d$y86XkP*)&csg2%FQa35kY$gZ}We*pr_060pxdu9`4cRKm=1J@j?&g0(;XsgrR z&oz@)qgfgo%5xeM$?c_6i9AtFUp9+Y^$_>qJH565zu{`ZexAgNxbI6MM#uXPIBM(- zaFb$~^y7E}%Sp7o3ivbBvMS%~^;O}(tFs=VQN6|`gm^&@#7iSe7-acRONfLn$3aI2 z#b|OmH`&PL_kv=ILN)Vay+80W#fJY>u1VvB&(KRk0)~26Sb;V6-azVqIiYjTmUd8q ze;;FMHb%Z}uS!#*p~9?GWiMQ*%MKmB`uL8lU$F|LQACBA!ar*K_*8<}+12>=2hX<@Ind&Rz z^}@n))QTUMU!LMREcez`Ppf*W57q|FpXauY>9a}R?br%?BKP&VYUiX?`YJOD5JCpBkaY}_I-J8+ABKp z&bq&)xQxfcy-|9b9`c}8Ckabi=WEmM(s7Bo_729B&apRYFx~(3QJuHp!wZ%U_8&!> zVOaU@N#;h+%(3y^6i_uSRTl=xYI<%pMMx1=SON^XpE66}sfE(%OdHnnzbN`}Rzp)C z4IICUAE-GX;N?`H9jUOBdYZ%t)16ELjW7@x=-MMw?O2_N!RdK;oiWvqb#%NGECfCm z(yx&Y9lI{Wp2@kzlr7Q!GWk7MFPKakV7afrK*{$NEsu?W3Sw1*oRw$wc{* z5rv|#O}0!sjB5Jya>#y(mOTrELE+xb=R&e1@qxo)>ABiDKk51Yq`dPPbVup`Lnx=p z0{^o7ni@GcVpW;`>p;X5A0%#Ekqqop%SJIR#VehsTor9wc>@E&yJanA5 zIxoI&s>aEEWHxAewEcquYY9`>uPsNk&FUN1g#`E=zyPRMY2_Xg**KjVs1g?3Dp2VF z`Tdw(*4Iyj$aL;Pi*>A21Ktk(`5w6E9`4%o`)MJ>8^$T7pmH3w@(i&n(YW49J? zy9N_cE!FN^bz^8uhLw%TUSY3H==`f={|S$wk_>?S!=pjPd;Diy=Ma3{3Wd0U_O^vDD9US39cq^XTH;n9ez zpexZ?5%<|{3=>h;Cy;zW(Ppy;yQ-nL)-9_OBnXH%hDa#J(NE82-2qYRRRw9Cjq+?Y zKT+L6mXf&x)T9yr?HW!7j*$r!d5q?9sA+htS#zPHAE=^yIJg*kyuEucu*FTNqGYPHQFHLK`?+7nLV zo05ME&8xv7+IWO1)KcgsrgV9T)mn0x#or3GXKg7EKpC2*7>fDY@p|c)t#_Ae;=@2j!ZXp9mo zBS^OpsQqsfi@3cBd-6ZvUT%he9rytNAillnn~_f)v(+$MGC>PH5qjv*`y)o|l*s)H z%zDc5MjEc?+C4-!Y|zKeikE!LE>W+G)0$Okgpp&h7fLbi+?*OE)A~I?Adj+8IBtwks$Q;W(PAgXie&_y>Wxtum%W?-)RuuQo< zd5(Ez{gqUIJz0Ap&H+8;yY~+Z!|@(94(s|cO)UkyJkEs?qP0*mrB7MVL{vU1;Omts zUUydWzdqpTf{v|P%nPS?<&Te%s2I6=`hl%qZ48|FdwOm5P3hfO_J;E+P<I+8LVZ=NI6VrP_k$1 z?3SDzRCm_HHvAxS|804Y{(*a?xfI_@WlP{ywF`*|Aft^CoC?n%x-7LK)?o(Crr2sk zyW?lvRWWH3>{F~G&Iv4giLPur_%#;hBKR>U$QhEF+l!)7IV@%~j9cJa>+Y!ofyGnD zxp}vxQiP5XhPL#M3&i7Jz`*d=+FpR2(0yb}PMtLX2@wVeTmJonQJZa=_Gs4Y%^3T}N|1J#nxNOMvo!^2 zLY{fsw?9dELDe9CMP1WAQ+;dvC)9eA3yr^Q8s1%ET}1i)yPMY(&zwQ^0S`=dARIk0 zf`yvRF7M1NL27K)XbsK}Za9tJvc0*D(OE;$zq3}v7)LHDUacamnDA3GId9peNA;q& zlhM40&0PU#LC4>2A!2oGM9}|^vsmKQJF^%sQGVRhYT?9oX**8-6ymJ{=Ze+mE^Lbo z1+nZqAlwL5;~aQvvO@wlfU~b?&ff|a{sk?!rD1YWrNUE^bBDSed&-Xd6<$ZioH*R4 z+Be5dMKTP#5pOTH?+#83u&#i2-P<#Ox)_QwDP&TKhV_sDo&@%Lf=bG{us-ZK=&*D@ zOGcH4gHjftc<|6Ktct@_} ze1oJAnVM)8J?Q5j!R3tm<&}RT4zqy#O_$}$?RT%so6A^evHCfvB}qD$-Qh>Y&S2p9 z3QnNY$4#PCuhcTURrG`jv4)2O=6NMMgR1At`qWc3k)J&Zd^dn-cjTY#x(|^-6uR;i zb(d_6NGT0|!`UJT`96+SdUghs)W&crL*~ws5$FpRRa`SLqQq5sjHdE`@6WnK!@-D< zR2L$x$Iw@82I*)SM)w317TO`~Rwd<)1X;Sp(0b@+XHtvk>}hGnu=fs6dIcN1`W> ztCC%lwuS)<^;YDm&!NhDD|}053}&he)8CTy+a)E*x#}oeSerq1?A$J@6b`ZeH3^2OV-n zIc*i3zvA13WNZb!eOY>_LTC;ik@n=nd$c-J?qyLssh&5E_6vI93L&=JE662EcCQk- zz=2n(VQ1>83dk^bIOxXBe1gM`;pt}n`&}djA7~`W!b9BPpc^#s^9|I6q?r5cv=ipO zArU5s^mBj%u2Moz)KcUTq3&?djhgrch8n`sO#SvciSyskNRvbc|4%zl!YCCrf)JC; zq9%!Ai4B|s9w#!A)*qtiVTdcyu1m>IaWDGVj(%q{7vyjFFp8a>-RB6aoQC#IoR`Tq z@Vee>zAZ50v>G5NCJgAqS&aD?JH8^=k@8d9S&Ulma5O0IiUty%_KNX2F5Jpml-_E7 zULn`EMoyl{NU%XB!*jRsgiGy67dG~}v8*%QvoPxKb0D?vY(~^d_^^>n9FE+)Xz#4n z`ynUrdaFwq9dQZj^^aRhw<84Ep3D%-+RLi02 z@O>N5T(~ExXA%rRqiyY|8odZqj zL1x-91tBCG0%guwz0#gon5CX*n*maD+|fx_n)8#nX}>CmH}MU`kc?9*bB26obf9N_ zHQXTFG}*DR0xeOW5iUZm`?S4+8BBOvwxV z*?$ByPYEUPp8K&Ah$25`8QOa>V?-Pz;ZfGtYwbIIYuyxTH0-U>n7{nVXwM2Qs;zAH zzYrBG$jJ|`0(x)l*^97hVvNaOe>C$bi;$vew<2JF8ZChgz)_2Zl{r|m- z|DXFp6c$Yp(LLqJlz22PNi2 zLZx8=*lTW>Tax3IKVRS?f4>OC)`lP!+}#$vW+2% zg@WIYrE{))uLLp9FBVD0ba|$!8>iEnmi_Qf3al>bcb*sw8mjLjaI4g=;6z(dCX%+E z%|sB!R48Y#31(ZZwQ@%Z9Om7YWr8(AB56=i{=W|f-Y=PpTNM(HnW#Lb2?QHnp=~Jo zUb=Ahq6+ocAz0}`kx*+MlKiwb!l26ZiNdIg1B+I9$Y#wpG_Rkg$;cJ z{RUdodMVdXmCJ0!_m2MR?B6%?aDhhFBwe4CFL+__@yb54{8*jy0@-)`Cze7?Kdqw^ z-y4f}CrBou*5=${MQe~?%Jw7fD+3c;OqKBz^lW%F{G z+vuYtnOVhJB`nxX<>kkd8diFot#hJ5wEW>wOe>W{K>_9ks-GP;UomA z{lC`vJckVkbu=lw3K_iATh6z4=yl#*lc=2b;8VF)Js($q&(jW;E7SquJ-odulL+uWD=K>;H)II0gbX^$X}-Pk&_xLi=`1T$%+x!=JgcICnOaww)ccy>KU+@4Ym z!$2BzGZ@t@823t=>rVK?@*>8!XQ<;J(`1WUhVy=g>awfXZ>yRf#wG3J+pHCpADH}m zx^`GXy~&`f*;h_fEJ+q62VoF}_O<@|z;frmcf=LJA23nTWETU#V)vqQHmvE8&dY{j zR_22%6U(7T2@;NsrZyFg4o#E+L;aowK3l1($p5T!W|?#v^O^!6to%$8D8SB2D}Cz$l1;C7K3| z33Pkm{My^#f2m&4M4gUiE#{ajz>x-!Fp1OBeE529Cqvdw_I|HT>C6bw6od0^^W)G)j`FJ%}}gtw<@qA z84~ySp=&etRm_It7ETvpzg6*IG=Zz`nq5oyb}dSubCFtcIk$t^wiUftxm+{}x{()Hb4FpZIWB|TDZ#B0w#o6gEl8R8ol91VP2ziUar$Ai z(=UHRrK$8g)Kdq2Is_joK%EX8yAQpN7RR@6bv6XtTzTzU}FnLmOQb z#Om)Y%Z?FZ7tw@qR%HRrLf^(J=W%KduGX{)0Nh@`7sL;RP+rZvCfJng#Rzyt;alr` zAG;*-potS>IqPG#raREsu)DxX-6pQHU8bIiP)@@}FyH)?H3C>B{s9#-$fc}WZZYd`>%0!hN>-IV&D`ClwqLy zx5P1i<>>2|)JJT2%U#A?3F=n&4SmnQtcgBm4jbC3OLz$=N71?mYgxZBvAP?Jn^^8R z{^tMrm|3oj%=I7Wnd^L5$An}p=MD+jNYn3H9O$KY&uQ^bTJL+TsB&blZBii-S0O#2 z2qyP;R)j;!Q6+`jWsTV(Oa%HC$Ox|grJ7l+&%_Je zA2p$8$347-58j>E*v5QnH8tBhcLKKYL~4k?Xudn?(YnZ8Sj$6!=tup5_WrC28m8L! z?hSTU&aoO|L3x)N`El=2kox&A`Rzi~G6}^Sp>q$82=dr zem)qITvgCkx*6lMK?4|)gz(?mCMC=a&2J`j{|^wMEXDFK3#_S#*Zfn;&EHbnBdzu@hw6-DgAKJJBpNc_e)&*j0&#EWULyIGld&xLD;AJIAA|ix zA8z}NN>d<63Yun@`Wvq05o`6KOa3DRI^FKHtO{x38&Fdi%y5>2OjqMB^(ajrg?W-3 zhSnzltyImD1%1I8f=oo92$vCuoEN$Y|5KHf(h6~slUUn(Hy6b)HXoKx3q=Vj@Nz1* zYj@^qh^ZI@*nT6&3;9E~Igy4RHANQ)WZmclAcd3Om>2mB2NH+|<|DHJ4?N`?WP)*V z(fy6ps0``{ApUtbs|cx|n3tE#2sSKTR=S9`uFwUcWPZviBpK~!+nr`r?Uz;{#vchq%v%?`oKhKXr4n@NSn!S`CXxX_F-# z=Bn?DZXED}*!8uqh7Kjx3Rre3j@~%*xY3ni5khpXM3oJ`MSUHG_uDJFwP$P}mI*$t zmk=RC1Nt-=9l4y@sIRa`st#OcAAsIHHDaqb8n}Kc5gYvI>}O4zx|#T?Nf>Q6(Z;nq zxI->{MK-&Y?Sg#Fi!hw$OWZZy66=GpHt?I0zaHaS=rcWg5JsHu{eK7-DO!N*17-#E zwvS&WU!^3N-a-CcS3GQ^FZIL>R~`U*eXy-jI4~@0BR4I&Jyq0m)-p5#J zLO%kQ_+xx{ROv*pA78dBmfWJN4~Jmz#cW3_FfQfOd8O;d#J(WMXxjV@CJ%k~XRo(@ zci|sbHp~MsMQ<$TF&&F!{zX_P*tRcZDKV!$Kw|-Frw3pk*T+OYxSX!TGlNNgkrRUl z*X+o@W2gHRIZ;a^`vd|)*T7H64%?K3!`SiFml?{CCSfzmr%SJ|_4%rb4QHX$-ilKL zti3E?9^|MBj}Jm$ zoy08`Zjbk)F)}9Q;NYv-DY+#t$CH#Ie?sfMwX$-cNIf?bnH!Kw?KwZ~UZTd5m2rTD zVq%U`ksHmv2o2h?==tq9m?h1dYdk(Hi2&bL+I7|Xvq=NROc7;TL#|*@Fri2EfC#*_ z#Bfh7CYXKz`syQ`Mu`k6^Un<>541)m!3z}g4}zYI#zzWw(4ZH0f!8+A3CF~zlR)Zi z6l5iuOYcv%nULloO6ctmlttAGNtrzps-xXh)=*XZzx|2Y4H@HEWGO^XREAwrHJ%z7 zx;FS}il1z_E2D#sHw>Va8O&^rbj3>$xN`fw*qimR2lwM(rr~huryR7rq_pGxsV!xLE8JAp zO)({qUC+e)3bYyC-|%pWh^z7JmK;4JOpe;Mj9DJyy9scN9$0m7(5Oh@&Ccoxg0Apl zn^x4kGu!e%5q5*amV;<>Q7Ax#Y0k+C}!?T`CF5qfH&abZ0LbPb8LZ{&Gb{JS0ePZf}EBYxNv?Bi9meP3irj> z14UI^?$mR+xQy_y?MLDlA|qIdIW2W zik;D2>VsOZOT{7o;|V}0u)u{&WSUXM%YDtYyKK8usLDe#K$u%z!*9&|r#DQf`v#}` zOAg04k7>QKh(4}Z|9%R1(0xKn$E=PZyG~(X8hp7&zJBvKIq*2cq8#N*_soNCiZf-i zSP5A*I2(gcK}u#qg$vpcd1?M}u?(wSIZ@3KfF%Yr5M+nX5W`^? zlsyTE!l=l3SdxvQx#Ijmgi{=rM;V*f8>up3dpo`x=J~EhL;m zjlf3=AtKU3%orcAZv*P&x;T&&F9@Pr5SgITFu@80p1hMI-7$H}Un9&Wc!p2;V8ho?B$?$y<_tdCOa3V7I;zv3-vS@wl~Oh^D1|5q?yD z2uArgb%e;X=;aNTU4kAE8qXKtC)0CF;ve^6zRDoLnz6v*q*kAjj`Qnr3rhRqTWPbk z$T`4_vzkO>9(%Z05~|L)X)75}lXGD65MX;^KDR2f;EmQMWyQlTXOuTh=tPBAa1|(gK2#1vv;!nU~@9a z)n+zDcpv;gEImX=KlpD9T~hWCy~@jJO06EzbhPae!ZC9MD|PV&F0V#*PRnQIIc?oq zB{du(v!^#wu^LeP{T(UT{_0U4$r5kbnkZKaqw6Fn)1og&&9=PfSW zLUtwIJOU`7nlhXkVYm5_s9Tz993)qR-o*ZYQV3pu?pEp~tOab!u$N2;=t z(QIXFl|ywMzdO$Q03|+gvd<5DmTwq_(->-2DeQaLmu7lQ8M70%QPO|ofa~ zl;{@NuL#mezh09*i8MJ@$It;NeXte!q5>#?_b1i*|J2DctJR?rk;f=)YnZ^}tQr@J z?EO^Ikh$3u(H>-Cr~R&8G9(5>%#{u|3R;dTvu;U>o{TpL28yw84DO!0 zUNr(JKJi`Blo;OtNTwBFUk~bU)PII^ghHN96VbST2Z))l$S2kYm6ue>xw5oQYX}rH zu=o6V)}P|syqCy~OY~wvA#1PBQ7Efgh1-Uc*!wN|OPBwb8NG@w0&8CNwc}^i7?A?~ zd8|;W>||ni$y`os0F%Lg6$TzZ`^XLEUGXLX40Fv|v2cLzxEb4e*_JWTq=d^w`L=i0 zZXbV;SC*lkcD1>aEZ3XA%Uq}sCaa<2EYiPJJFX*t`td6N>VmMNsX9!fK4jSTs8|dV= z@*odCVe%fAZmg%Ur2md251Z#a|75Z6gI~in;^7uSbf#*EtDO~De45bo@;EdgG!MEP z72b{N)Q*rm(V_RLBllP<&bqi}sljc$p$92yurP^rAxX7EM=*DT`1F-kI5}PVVv{6$suO{Y1=1OrKZI^DAAm zBw7!?>T$`no(&7D9f4qzN0a69uD&t7{pg~!Z^WYm`HJEf?WC>$mRv3gy)s(0>YE@! z%@ki)c6;GxdE00Nr=C0gu&A54s^U!--5E?VwzLx^xaojtO ze6_`~#tL%>|52X24P|0IQ~}+tQdBDUcX}5T=J9xo!zzGuNn=h~zZ3LOlstNI; zL$v|2EFBp!u;JQ9^=Dn#>MkO6 zxK7u^p9(BLNWvNcwZ10SRcA=U*&4!!B`{m{*0~Cbd{xeB?Su+=OfV2R4mqCT2#}jI*HL}Nf*dhy^-5;#I zyMnFd_6F`!Ue_Z>cy+}lWdi~KVv{ew{WZk~m&#Y2jn6|Gpl-k68WFBjO5uKpQ=m7l zcqqYqRmg^&Rh=M`?XXHHWb>-VIJryVTH$D8#&r{}2Z+cX?2%tBW*w>;F#>s4_&-yx zRYI6pa6U<5jM8#bJ|%%;(|I|a8M6rKO@F{br0>EC8a0(YFqvDT?OTwnc?DUx47_HB|pTB*`MoD&AiHT_%KVZf)nIOF4rUD~s8?<9;Pq=rth6w7X^Y z42{DWZ1@FL`ab%Tt=EIgv5KZ$T5QnT6dx*p>GkXK=q&JQ_(@%)QvS;Y*lYfF`G`!0 zjVxb28`L98EjD$ypli|c!P-K6uKrO6LdahPI@y59Y{RP_n$m}~y@m%m)R&;10jzc; z$IZ54w6IVwzY_XJJ9P@T|BQ`pbL*RSZo?Ugw7vx}k+hPm7Szmdq64k{9LP2ENqz6y zsQ+mD#~g3##fL*Tq2p;yUzS!RB4qKk$$W*(8uVq*`I(mJb>|W$eVBz;zFR*GO4P@) zyR6AjmpZR1aza2_$=3!+MPx2rndr;Mb|ulN28@Zy7DFjq$Z5orD6CUT%mML&6VH=a z^Qa~Ds@=L6QEg1>+P!Z)zMiVOtinKf$z_Dt!X3z^G6xuzf6=teo%mWx{zG(_=?5M= z3OOa(NZn??Df{k8^4>?kJ>H%yU)N=C5mCcP_k0$!wdkmv$Z0fjY-$Jv@wl#V0LYhM z0S4Am#hE<^f(`bjxs&5gW)3{zO}n5;YN`_A>xPJ4?tYtjqpD<+T!`=iwMj{XKRwet zI;%E8>kvC820jJD7BQ%|62N)OLU!)AJRQxUSJaEi`=&^C^U`uJVgfMyJw|AudW@E& z_swc{h zhyl;p;JLJE;_X?$J!-=;GEKX=oxEd=0 zon=-p$JTEx@(_Gu+=+dX$S2Z!Uk=b&K|WJzAYtt0qMyK2N~P>P{7?k{!TOr(sdmvN zi?-B^d=2yM0G<>|+v(470?In(%9fJRs{ zPYL0Wos+Dqy0^`TU+!!n5hVqv&=PtvVwKRA@TB=Ju^ivizBr=vxfz}wg$5Grt53=` zXfBT}LUYPZ*Z1u!KqAhW)D>Xm+nVric}T3<)h93_cD&iKm8}g_i8p59^)bijkmuJ)9gugr-;ZS@kR|4FzHR#8jzuoLND> zNR$(2E^VJ{gDf3d8C&9(2W0mkemwL*behY@vybm#Md!36Iop)blh_g1`+%b!TY$LC-4crHwFW8C$DvX_s_TbAObYEL7p)38~O zv%|+qV}xuDNH47J?H3$gXtJZc7=r{1z-1LnJA6{%*BDBG$Wz8>Z)&r8*>3*#+wICA zX#bTtq$P&|Gjs1h624<%${4NYm4q+&aquoui|$nP=N}&59+qx$sTY7E&eDYb3~CGJ z`@Tp}=WuWESF%#ylX5npU9u?7D!-G@Y9pt~0Xq4rxgv%mDLCQG^ZZ2ifNb$QbEQ;@ zCb0vg4avX>TWKw~E0Q&E?_G%HK>)}d@0H4Gz>^$f%y%5?1nG*};Zl#&b7@*qs#&il zmE{SRfmTw_C8Etq=stGW598Yl8X?GRS(GmwS*C-@TvfAE-z{_#f(GN^TWcRYun5An zO>qe{sL5zs(@H?6{{r>rS?Am5h%O@$ylBLo>F5*&Yp#W+TyQjQPf0b1aJB@BqaF3y zU(OQz6ND6b(zc*~V=~gwRBL;?zQ61?1&Wrhzz#G5|7vfGj9mVTC>boB_tXo!40WOv zqsbbeEx>Z5)kg)a8<|AGwm-+#EUzw8gvy#;0-t_?lTZHkh}BNCvG*Q6>Pzu3`n`eQ z&%mzEdt2f+l?=UZGj(NWUhEj=2;jNf?|XC-yp|!2Y+`C!YJnIn5{DIB+%BS4f~xA3 z&jkxNwJr#N`k9@zrw0T62FH%x4W9Q-th(V?cPrOwE3Wmy+|Kblq2G2+a#-iNgT(q+ zg+eP=f}%cP{x9mjDMk~Z+xBbQwr$(CZQJgip0;hZNw-sj_RO_S$Qu%_rppTFTBozVk$%({F7Ve6(de=lP_BWRPTmbE%&`@4flLa!(^( zL#KF{VK?DHE|vmQk%niJRN%^7QyjP9rjszim0Aw1p2lKy{UQu57~xg-;MBhEdEcdV zz?+<*@7)&g0V$|LV3f8U$8x&lL8@twnvSFU$@lr1J=DKz%u zUImuA;*nZ@zZ127hLL@ng$8)J3DkHy#!)l}6bbY)=_Cw1uts&Bge^iM58^QH13}CA zLw38rzGSPEBUUz!N;Q4ML7!hfoTTMsFL{nu;z5{OKd8;NDxWs#Y_we5@=JrrOz>r| zwwydd^RqLPZEo?oilWvBvmJu6?oGZt1k4pynPQXJsq(`xwm`&-@^R%r0+&y@HD@z~ zbMI+;fP|WrfTI*Dr&tFkf&63| zolOs+2PH}!L>xmoLU+kQo)og+)@eJ4al_Py*vWXXTbK!6yC3B7>SWbRShbG9%HHMz zPc~ZtudAZaVGw}4*a52ZPWqJ&3I&(>lf>J!y8puYJuf2y!&ks#$rGt*9;r~($QuSsimZ$ ze10VTB3zv%;%GzPLK&S$;@~D1kLGpi7gMI*fSyLq;PZpI6V9uUcPEmzVK=G8VJh*0 z9z{CxBCb}YP5R4c$p!N-UWnUi_P_#rskqxMETFX148;N1XQspRE% z%v#TGIl{cvJx;&oqSIE1BhbBP-wKti1vIW=(&S8N_~^sG-oFDE3_k$C`NY3(GBHWp zgTRyzJhJEKL;spW$7@$9Xm3CjbZH%_4?ec<;1-KC@|E;e`b9`@Ohkg1h^0~o zwL?6C^-a|VvzWlUNL6Ybj9{Q<-wk*#;3t0|5oer;t)(mNz)=Y!y_3TUpr+sNDQG1? zbtdpXNb$sZSQzV@c>{c9={K+rWb06Ymn8^ zWdY=0fvE~bpwR-N+`TY|Ms)=`!;j*9K@IExm)0xmPO=@Uu@$T&cbWE@_~I74xWD2q zpsVo|LIoymL&l5%bSc>6{#S;QFI!s zlO`O+5mK+2v6R|w)8Gl$QtoE;M0dgC46K4%kz96_U}ZHJ$+Ak7QuO?x^#$%`=?yh8Bepx&$8nZ+5 zwRUx+tw8|wW+Wc&xsE$Kc9^zv>M`qP(`V4jW5}jaz?8})g)EYd51jq)02$NIgW(-R z;wUmjN#DeBQvPvOWCGJlD1-(z(6DvtK#|K7A);sL{P>UmrvP2`G-NR7%7x1Qb{>oi zM1M>_2Q|;qyWCgn>dorkS}`tg9}&ACZ<~YgbP1t}Tm|VkFN-$mti%GyF3&z?`iLj~ zxM|cj!C@+nItPtxj?DPk9Y!8V)>FZy>@yc9_OZGCzsw+_UCq^90Cv#GD<_&5PrsHi zG1aasbp9e#uF97Q!*3nZc z$T>;K-p2XQouJ?&<)Y0RWw5}cYS0qs6Ji}}!f|uVee>9HL1(RrM$G4bM>WN_ zll00U694kou@#*!Mi1q$1ht>e`W9+unRj_jj?fVDuTyJuQ%1p=px#V)B_H7w#Q5SU zFqw0X*uP-4<3g+ghM~eQWI8Pay>)=JZUpy@_OlBg^Zs1+-o?~pRd)JV zx!FHBc*L^4x>Nb3+9cVFud8VKb$6W8*4QXJP$DvaN5ns*um}3+Y`*;PRAS1ZmW_cZ z=2u}N>F54KJ_Eq1;d9M;WM6by5l@JOklo&QVohlEg5F&2uM+^FCAHA2xnGKbjQ&A) zjls>h1#q_%IQcT2Lnv5+wK++qtMs0DU?_x|bINdW9#o}-x!{5HN`y%MiQdcF{KhO5Mfh-_g7pI|` zRD_xzAc-@c(}oTs9Vrrx!>--keY@s0-L&%E%u0G=OvRcQz73oMeT>xZ9)YqMkU#aL z#R+$=iN02!A_A4a9cDF=m7T_OQog(9LWHcEU*8L5KilEC?B8#c2NuM~*^Qj@TUZBq z65~MBu*LSmL5q2k7&_KL3∨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 H00000em=Vu literal 0 HcmV?d00001 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/ From 298370b7bea74c7d9814e53bdcb5540ddfa82cc4 Mon Sep 17 00:00:00 2001 From: Michel Heusschen <59014050+michelheusschen@users.noreply.github.com> Date: Mon, 27 May 2024 10:19:08 +0200 Subject: [PATCH 10/14] fix(web): validation of number input fields (#9789) --- .../settings/setting-buttons-row.svelte | 2 +- .../settings/setting-input-field.spec.ts | 21 +++++++++++++++++++ .../settings/setting-input-field.svelte | 8 ++++--- 3 files changed, 27 insertions(+), 4 deletions(-) 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 8199db17b2..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 @@ -27,6 +27,6 @@
- +
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 @@