diff --git a/mobile/openapi/doc/CreateUserDto.md b/mobile/openapi/doc/CreateUserDto.md index c7b8b4ca28..647d7f0ff0 100644 --- a/mobile/openapi/doc/CreateUserDto.md +++ b/mobile/openapi/doc/CreateUserDto.md @@ -12,6 +12,7 @@ Name | Type | Description | Notes **password** | **String** | | **firstName** | **String** | | **lastName** | **String** | | +**storageLabel** | **String** | | [optional] [[Back to Model list]](../README.md#documentation-for-models) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to README]](../README.md) diff --git a/mobile/openapi/doc/UpdateUserDto.md b/mobile/openapi/doc/UpdateUserDto.md index e40bf5eb77..a7422c53b8 100644 --- a/mobile/openapi/doc/UpdateUserDto.md +++ b/mobile/openapi/doc/UpdateUserDto.md @@ -8,11 +8,12 @@ import 'package:openapi/api.dart'; ## Properties Name | Type | Description | Notes ------------ | ------------- | ------------- | ------------- +**id** | **String** | | **email** | **String** | | [optional] **password** | **String** | | [optional] **firstName** | **String** | | [optional] **lastName** | **String** | | [optional] -**id** | **String** | | +**storageLabel** | **String** | | [optional] **isAdmin** | **bool** | | [optional] **shouldChangePassword** | **bool** | | [optional] diff --git a/mobile/openapi/doc/UserResponseDto.md b/mobile/openapi/doc/UserResponseDto.md index 8741e0dcc9..658f5b8d1f 100644 --- a/mobile/openapi/doc/UserResponseDto.md +++ b/mobile/openapi/doc/UserResponseDto.md @@ -12,6 +12,7 @@ Name | Type | Description | Notes **email** | **String** | | **firstName** | **String** | | **lastName** | **String** | | +**storageLabel** | **String** | | **createdAt** | **String** | | **profileImagePath** | **String** | | **shouldChangePassword** | **bool** | | diff --git a/mobile/openapi/lib/model/create_user_dto.dart b/mobile/openapi/lib/model/create_user_dto.dart index e292eb481b..cd5c2a2d61 100644 --- a/mobile/openapi/lib/model/create_user_dto.dart +++ b/mobile/openapi/lib/model/create_user_dto.dart @@ -17,6 +17,7 @@ class CreateUserDto { required this.password, required this.firstName, required this.lastName, + this.storageLabel, }); String email; @@ -27,12 +28,15 @@ class CreateUserDto { String lastName; + String? storageLabel; + @override bool operator ==(Object other) => identical(this, other) || other is CreateUserDto && other.email == email && other.password == password && other.firstName == firstName && - other.lastName == lastName; + other.lastName == lastName && + other.storageLabel == storageLabel; @override int get hashCode => @@ -40,10 +44,11 @@ class CreateUserDto { (email.hashCode) + (password.hashCode) + (firstName.hashCode) + - (lastName.hashCode); + (lastName.hashCode) + + (storageLabel == null ? 0 : storageLabel!.hashCode); @override - String toString() => 'CreateUserDto[email=$email, password=$password, firstName=$firstName, lastName=$lastName]'; + String toString() => 'CreateUserDto[email=$email, password=$password, firstName=$firstName, lastName=$lastName, storageLabel=$storageLabel]'; Map toJson() { final json = {}; @@ -51,6 +56,11 @@ class CreateUserDto { json[r'password'] = this.password; json[r'firstName'] = this.firstName; json[r'lastName'] = this.lastName; + if (this.storageLabel != null) { + json[r'storageLabel'] = this.storageLabel; + } else { + // json[r'storageLabel'] = null; + } return json; } @@ -77,6 +87,7 @@ class CreateUserDto { password: mapValueOfType(json, r'password')!, firstName: mapValueOfType(json, r'firstName')!, lastName: mapValueOfType(json, r'lastName')!, + storageLabel: mapValueOfType(json, r'storageLabel'), ); } return null; diff --git a/mobile/openapi/lib/model/update_user_dto.dart b/mobile/openapi/lib/model/update_user_dto.dart index 4e2747745e..570eaaa7c3 100644 --- a/mobile/openapi/lib/model/update_user_dto.dart +++ b/mobile/openapi/lib/model/update_user_dto.dart @@ -13,15 +13,18 @@ part of openapi.api; class UpdateUserDto { /// Returns a new [UpdateUserDto] instance. UpdateUserDto({ + required this.id, this.email, this.password, this.firstName, this.lastName, - required this.id, + this.storageLabel, this.isAdmin, this.shouldChangePassword, }); + String id; + /// /// Please note: This property should have been non-nullable! Since the specification file /// does not include a default value (using the "default:" property), however, the generated @@ -54,7 +57,13 @@ class UpdateUserDto { /// String? lastName; - String id; + /// + /// Please note: This property should have been non-nullable! Since the specification file + /// does not include a default value (using the "default:" property), however, the generated + /// source code must fall back to having a nullable type. + /// Consider adding a "default:" property in the specification file to hide this note. + /// + String? storageLabel; /// /// Please note: This property should have been non-nullable! Since the specification file @@ -74,30 +83,33 @@ class UpdateUserDto { @override bool operator ==(Object other) => identical(this, other) || other is UpdateUserDto && + other.id == id && other.email == email && other.password == password && other.firstName == firstName && other.lastName == lastName && - other.id == id && + other.storageLabel == storageLabel && other.isAdmin == isAdmin && other.shouldChangePassword == shouldChangePassword; @override int get hashCode => // ignore: unnecessary_parenthesis + (id.hashCode) + (email == null ? 0 : email!.hashCode) + (password == null ? 0 : password!.hashCode) + (firstName == null ? 0 : firstName!.hashCode) + (lastName == null ? 0 : lastName!.hashCode) + - (id.hashCode) + + (storageLabel == null ? 0 : storageLabel!.hashCode) + (isAdmin == null ? 0 : isAdmin!.hashCode) + (shouldChangePassword == null ? 0 : shouldChangePassword!.hashCode); @override - String toString() => 'UpdateUserDto[email=$email, password=$password, firstName=$firstName, lastName=$lastName, id=$id, isAdmin=$isAdmin, shouldChangePassword=$shouldChangePassword]'; + String toString() => 'UpdateUserDto[id=$id, email=$email, password=$password, firstName=$firstName, lastName=$lastName, storageLabel=$storageLabel, isAdmin=$isAdmin, shouldChangePassword=$shouldChangePassword]'; Map toJson() { final json = {}; + json[r'id'] = this.id; if (this.email != null) { json[r'email'] = this.email; } else { @@ -118,7 +130,11 @@ class UpdateUserDto { } else { // json[r'lastName'] = null; } - json[r'id'] = this.id; + if (this.storageLabel != null) { + json[r'storageLabel'] = this.storageLabel; + } else { + // json[r'storageLabel'] = null; + } if (this.isAdmin != null) { json[r'isAdmin'] = this.isAdmin; } else { @@ -151,11 +167,12 @@ class UpdateUserDto { }()); return UpdateUserDto( + id: mapValueOfType(json, r'id')!, email: mapValueOfType(json, r'email'), password: mapValueOfType(json, r'password'), firstName: mapValueOfType(json, r'firstName'), lastName: mapValueOfType(json, r'lastName'), - id: mapValueOfType(json, r'id')!, + storageLabel: mapValueOfType(json, r'storageLabel'), isAdmin: mapValueOfType(json, r'isAdmin'), shouldChangePassword: mapValueOfType(json, r'shouldChangePassword'), ); diff --git a/mobile/openapi/lib/model/user_response_dto.dart b/mobile/openapi/lib/model/user_response_dto.dart index ccdb9b552d..3a637a7e30 100644 --- a/mobile/openapi/lib/model/user_response_dto.dart +++ b/mobile/openapi/lib/model/user_response_dto.dart @@ -17,6 +17,7 @@ class UserResponseDto { required this.email, required this.firstName, required this.lastName, + required this.storageLabel, required this.createdAt, required this.profileImagePath, required this.shouldChangePassword, @@ -34,6 +35,8 @@ class UserResponseDto { String lastName; + String? storageLabel; + String createdAt; String profileImagePath; @@ -66,6 +69,7 @@ class UserResponseDto { other.email == email && other.firstName == firstName && other.lastName == lastName && + other.storageLabel == storageLabel && other.createdAt == createdAt && other.profileImagePath == profileImagePath && other.shouldChangePassword == shouldChangePassword && @@ -81,6 +85,7 @@ class UserResponseDto { (email.hashCode) + (firstName.hashCode) + (lastName.hashCode) + + (storageLabel == null ? 0 : storageLabel!.hashCode) + (createdAt.hashCode) + (profileImagePath.hashCode) + (shouldChangePassword.hashCode) + @@ -90,7 +95,7 @@ class UserResponseDto { (oauthId.hashCode); @override - String toString() => 'UserResponseDto[id=$id, email=$email, firstName=$firstName, lastName=$lastName, createdAt=$createdAt, profileImagePath=$profileImagePath, shouldChangePassword=$shouldChangePassword, isAdmin=$isAdmin, deletedAt=$deletedAt, updatedAt=$updatedAt, oauthId=$oauthId]'; + String toString() => 'UserResponseDto[id=$id, email=$email, firstName=$firstName, lastName=$lastName, storageLabel=$storageLabel, createdAt=$createdAt, profileImagePath=$profileImagePath, shouldChangePassword=$shouldChangePassword, isAdmin=$isAdmin, deletedAt=$deletedAt, updatedAt=$updatedAt, oauthId=$oauthId]'; Map toJson() { final json = {}; @@ -98,6 +103,11 @@ class UserResponseDto { json[r'email'] = this.email; json[r'firstName'] = this.firstName; json[r'lastName'] = this.lastName; + if (this.storageLabel != null) { + json[r'storageLabel'] = this.storageLabel; + } else { + // json[r'storageLabel'] = null; + } json[r'createdAt'] = this.createdAt; json[r'profileImagePath'] = this.profileImagePath; json[r'shouldChangePassword'] = this.shouldChangePassword; @@ -139,6 +149,7 @@ class UserResponseDto { email: mapValueOfType(json, r'email')!, firstName: mapValueOfType(json, r'firstName')!, lastName: mapValueOfType(json, r'lastName')!, + storageLabel: mapValueOfType(json, r'storageLabel'), createdAt: mapValueOfType(json, r'createdAt')!, profileImagePath: mapValueOfType(json, r'profileImagePath')!, shouldChangePassword: mapValueOfType(json, r'shouldChangePassword')!, @@ -197,6 +208,7 @@ class UserResponseDto { 'email', 'firstName', 'lastName', + 'storageLabel', 'createdAt', 'profileImagePath', 'shouldChangePassword', diff --git a/mobile/openapi/test/create_user_dto_test.dart b/mobile/openapi/test/create_user_dto_test.dart index 8e2e7d445a..b38665fd3a 100644 --- a/mobile/openapi/test/create_user_dto_test.dart +++ b/mobile/openapi/test/create_user_dto_test.dart @@ -36,6 +36,11 @@ void main() { // TODO }); + // String storageLabel + test('to test the property `storageLabel`', () async { + // TODO + }); + }); diff --git a/mobile/openapi/test/update_user_dto_test.dart b/mobile/openapi/test/update_user_dto_test.dart index 8cd9da9944..4ff0bc8b63 100644 --- a/mobile/openapi/test/update_user_dto_test.dart +++ b/mobile/openapi/test/update_user_dto_test.dart @@ -16,6 +16,11 @@ void main() { // final instance = UpdateUserDto(); group('test UpdateUserDto', () { + // String id + test('to test the property `id`', () async { + // TODO + }); + // String email test('to test the property `email`', () async { // TODO @@ -36,8 +41,8 @@ void main() { // TODO }); - // String id - test('to test the property `id`', () async { + // String storageLabel + test('to test the property `storageLabel`', () async { // TODO }); diff --git a/mobile/openapi/test/user_response_dto_test.dart b/mobile/openapi/test/user_response_dto_test.dart index cfe0ad49f1..136643b2aa 100644 --- a/mobile/openapi/test/user_response_dto_test.dart +++ b/mobile/openapi/test/user_response_dto_test.dart @@ -36,6 +36,11 @@ void main() { // TODO }); + // String storageLabel + test('to test the property `storageLabel`', () async { + // TODO + }); + // String createdAt test('to test the property `createdAt`', () async { // TODO diff --git a/server/apps/immich/src/api-v1/album/album.service.spec.ts b/server/apps/immich/src/api-v1/album/album.service.spec.ts index 69a08485ca..bea9fa3d74 100644 --- a/server/apps/immich/src/api-v1/album/album.service.spec.ts +++ b/server/apps/immich/src/api-v1/album/album.service.spec.ts @@ -39,6 +39,7 @@ describe('Album service', () => { oauthId: '', tags: [], assets: [], + storageLabel: null, }); const albumId = 'f19ab956-4761-41ea-a5d6-bae948308d58'; const sharedAlbumOwnerId = '2222'; diff --git a/server/apps/immich/src/api-v1/tag/tag.service.spec.ts b/server/apps/immich/src/api-v1/tag/tag.service.spec.ts index 918a0994ef..69c92f82cd 100644 --- a/server/apps/immich/src/api-v1/tag/tag.service.spec.ts +++ b/server/apps/immich/src/api-v1/tag/tag.service.spec.ts @@ -27,6 +27,7 @@ describe('TagService', () => { tags: [], assets: [], oauthId: 'oauth-id-1', + storageLabel: null, }); // const user2: UserEntity = Object.freeze({ diff --git a/server/apps/immich/src/utils/transform.util.ts b/server/apps/immich/src/utils/transform.util.ts index 92ce5bf64e..b03ce6a2d8 100644 --- a/server/apps/immich/src/utils/transform.util.ts +++ b/server/apps/immich/src/utils/transform.util.ts @@ -1,4 +1,10 @@ -export const toBoolean = ({ value }: { value: string }) => { +import sanitize from 'sanitize-filename'; + +interface IValue { + value?: string; +} + +export const toBoolean = ({ value }: IValue) => { if (value == 'true') { return true; } else if (value == 'false') { @@ -6,3 +12,7 @@ export const toBoolean = ({ value }: { value: string }) => { } return value; }; + +export const toEmail = ({ value }: IValue) => value?.toLowerCase(); + +export const toSanitized = ({ value }: IValue) => sanitize((value || '').replace(/\./g, '')); diff --git a/server/apps/immich/test/user.e2e-spec.ts b/server/apps/immich/test/user.e2e-spec.ts index e208c21b52..83d3577d7b 100644 --- a/server/apps/immich/test/user.e2e-spec.ts +++ b/server/apps/immich/test/user.e2e-spec.ts @@ -87,10 +87,10 @@ describe('User', () => { ]); }); - it('fetches the user collection excluding the auth user', async () => { + it('fetches the user collection including the auth user', async () => { const { status, body } = await request(app.getHttpServer()).get('/user?isAll=false'); expect(status).toEqual(200); - expect(body).toHaveLength(2); + expect(body).toHaveLength(3); expect(body).toEqual( expect.arrayContaining([ { @@ -105,6 +105,7 @@ describe('User', () => { deletedAt: null, updatedAt: expect.anything(), oauthId: '', + storageLabel: null, }, { email: userTwoEmail, @@ -118,10 +119,24 @@ describe('User', () => { deletedAt: null, updatedAt: expect.anything(), oauthId: '', + storageLabel: null, + }, + { + email: authUserEmail, + firstName: 'auth-user', + lastName: 'test', + id: expect.anything(), + createdAt: expect.anything(), + isAdmin: true, + shouldChangePassword: true, + profileImagePath: '', + deletedAt: null, + updatedAt: expect.anything(), + oauthId: '', + storageLabel: 'admin', }, ]), ); - expect(body).toEqual(expect.not.arrayContaining([expect.objectContaining({ email: authUserEmail })])); }); it('disallows admin user from creating a second admin account', async () => { diff --git a/server/immich-openapi-specs.json b/server/immich-openapi-specs.json index 7c2ed1bdcc..8699ae3a00 100644 --- a/server/immich-openapi-specs.json +++ b/server/immich-openapi-specs.json @@ -4122,6 +4122,10 @@ "lastName": { "type": "string" }, + "storageLabel": { + "type": "string", + "nullable": true + }, "createdAt": { "type": "string" }, @@ -4150,6 +4154,7 @@ "email", "firstName", "lastName", + "storageLabel", "createdAt", "profileImagePath", "shouldChangePassword", @@ -5529,20 +5534,20 @@ "type": "object", "properties": { "email": { - "type": "string", - "example": "testuser@email.com" + "type": "string" }, "password": { - "type": "string", - "example": "password" + "type": "string" }, "firstName": { - "type": "string", - "example": "John" + "type": "string" }, "lastName": { + "type": "string" + }, + "storageLabel": { "type": "string", - "example": "Doe" + "nullable": true } }, "required": [ @@ -5566,26 +5571,25 @@ "UpdateUserDto": { "type": "object", "properties": { - "email": { - "type": "string", - "example": "testuser@email.com" - }, - "password": { - "type": "string", - "example": "password" - }, - "firstName": { - "type": "string", - "example": "John" - }, - "lastName": { - "type": "string", - "example": "Doe" - }, "id": { "type": "string", "format": "uuid" }, + "email": { + "type": "string" + }, + "password": { + "type": "string" + }, + "firstName": { + "type": "string" + }, + "lastName": { + "type": "string" + }, + "storageLabel": { + "type": "string" + }, "isAdmin": { "type": "boolean" }, diff --git a/server/libs/domain/src/auth/auth.service.spec.ts b/server/libs/domain/src/auth/auth.service.spec.ts index e371618aab..e0a02c814c 100644 --- a/server/libs/domain/src/auth/auth.service.spec.ts +++ b/server/libs/domain/src/auth/auth.service.spec.ts @@ -306,7 +306,7 @@ describe('AuthService', () => { expect(userTokenMock.save.mock.calls[0][0]).toMatchObject({ id: 'not_active', token: 'auth_token', - userId: 'immich_id', + userId: 'user-id', createdAt: new Date('2021-01-01'), updatedAt: expect.any(Date), deviceOS: 'Android', diff --git a/server/libs/domain/src/auth/auth.service.ts b/server/libs/domain/src/auth/auth.service.ts index af7cd449b6..a1b223c81f 100644 --- a/server/libs/domain/src/auth/auth.service.ts +++ b/server/libs/domain/src/auth/auth.service.ts @@ -122,6 +122,7 @@ export class AuthService { firstName: dto.firstName, lastName: dto.lastName, password: dto.password, + storageLabel: 'admin', }); return mapAdminSignupResponse(admin); diff --git a/server/libs/domain/src/partner/partner.service.spec.ts b/server/libs/domain/src/partner/partner.service.spec.ts index f29267dbd0..b2f55ee3ab 100644 --- a/server/libs/domain/src/partner/partner.service.spec.ts +++ b/server/libs/domain/src/partner/partner.service.spec.ts @@ -17,19 +17,21 @@ const responseDto = { profileImagePath: '', shouldChangePassword: false, updatedAt: '2021-01-01', + storageLabel: 'admin', }, user1: { createdAt: '2021-01-01', deletedAt: undefined, email: 'immich@test.com', firstName: 'immich_first_name', - id: 'immich_id', + id: 'user-id', isAdmin: false, lastName: 'immich_last_name', oauthId: '', profileImagePath: '', shouldChangePassword: false, updatedAt: '2021-01-01', + storageLabel: null, }, }; diff --git a/server/libs/domain/src/storage-template/storage-template.core.ts b/server/libs/domain/src/storage-template/storage-template.core.ts index 2350811c62..85c3d12b2b 100644 --- a/server/libs/domain/src/storage-template/storage-template.core.ts +++ b/server/libs/domain/src/storage-template/storage-template.core.ts @@ -4,7 +4,7 @@ import handlebar from 'handlebars'; import * as luxon from 'luxon'; import path from 'node:path'; import sanitize from 'sanitize-filename'; -import { IStorageRepository, StorageCore, StorageFolder } from '../storage'; +import { IStorageRepository, StorageCore } from '../storage'; import { ISystemConfigRepository, supportedDayTokens, @@ -15,6 +15,7 @@ import { supportedYearTokens, } from '../system-config'; import { SystemConfigCore } from '../system-config/system-config.core'; +import { MoveAssetMetadata } from './storage-template.service'; export class StorageTemplateCore { private logger = new Logger(StorageTemplateCore.name); @@ -33,12 +34,14 @@ export class StorageTemplateCore { this.configCore.config$.subscribe((config) => this.onConfig(config)); } - public async getTemplatePath(asset: AssetEntity, filename: string): Promise { + public async getTemplatePath(asset: AssetEntity, metadata: MoveAssetMetadata): Promise { + const { storageLabel, filename } = metadata; + try { const source = asset.originalPath; const ext = path.extname(source).split('.').pop() as string; const sanitized = sanitize(path.basename(filename, `.${ext}`)); - const rootPath = this.storageCore.getFolderLocation(StorageFolder.LIBRARY, asset.ownerId); + const rootPath = this.storageCore.getLibraryFolder({ id: asset.ownerId, storageLabel }); const storagePath = this.render(this.storageTemplate, asset, sanitized, ext); const fullPath = path.normalize(path.join(rootPath, storagePath)); let destination = `${fullPath}.${ext}`; diff --git a/server/libs/domain/src/storage-template/storage-template.service.spec.ts b/server/libs/domain/src/storage-template/storage-template.service.spec.ts index d14f23d7a4..06f000568e 100644 --- a/server/libs/domain/src/storage-template/storage-template.service.spec.ts +++ b/server/libs/domain/src/storage-template/storage-template.service.spec.ts @@ -4,18 +4,22 @@ import { newAssetRepositoryMock, newStorageRepositoryMock, newSystemConfigRepositoryMock, + newUserRepositoryMock, systemConfigStub, + userEntityStub, } from '../../test'; import { IAssetRepository } from '../asset'; import { StorageTemplateService } from '../storage-template'; import { IStorageRepository } from '../storage/storage.repository'; import { ISystemConfigRepository } from '../system-config'; +import { IUserRepository } from '../user'; describe(StorageTemplateService.name, () => { let sut: StorageTemplateService; let assetMock: jest.Mocked; let configMock: jest.Mocked; let storageMock: jest.Mocked; + let userMock: jest.Mocked; it('should work', () => { expect(sut).toBeDefined(); @@ -25,12 +29,15 @@ describe(StorageTemplateService.name, () => { assetMock = newAssetRepositoryMock(); configMock = newSystemConfigRepositoryMock(); storageMock = newStorageRepositoryMock(); - sut = new StorageTemplateService(assetMock, configMock, systemConfigStub.defaults, storageMock); + userMock = newUserRepositoryMock(); + + sut = new StorageTemplateService(assetMock, configMock, systemConfigStub.defaults, storageMock, userMock); }); describe('handle template migration', () => { it('should handle no assets', async () => { assetMock.getAll.mockResolvedValue([]); + userMock.getList.mockResolvedValue([]); await sut.handleTemplateMigration(); @@ -40,6 +47,7 @@ describe(StorageTemplateService.name, () => { it('should handle an asset with a duplicate destination', async () => { assetMock.getAll.mockResolvedValue([assetEntityStub.image]); assetMock.save.mockResolvedValue(assetEntityStub.image); + userMock.getList.mockResolvedValue([userEntityStub.user1]); when(storageMock.checkFileExists) .calledWith('upload/library/user-id/2023/2023-02-23/asset-id.ext') @@ -57,6 +65,7 @@ describe(StorageTemplateService.name, () => { id: assetEntityStub.image.id, originalPath: 'upload/library/user-id/2023/2023-02-23/asset-id+1.ext', }); + expect(userMock.getList).toHaveBeenCalled(); }); it('should skip when an asset already matches the template', async () => { @@ -66,6 +75,7 @@ describe(StorageTemplateService.name, () => { originalPath: 'upload/library/user-id/2023/2023-02-23/asset-id.ext', }, ]); + userMock.getList.mockResolvedValue([userEntityStub.user1]); await sut.handleTemplateMigration(); @@ -82,6 +92,7 @@ describe(StorageTemplateService.name, () => { originalPath: 'upload/library/user-id/2023/2023-02-23/asset-id+1.ext', }, ]); + userMock.getList.mockResolvedValue([userEntityStub.user1]); await sut.handleTemplateMigration(); @@ -94,6 +105,7 @@ describe(StorageTemplateService.name, () => { it('should move an asset', async () => { assetMock.getAll.mockResolvedValue([assetEntityStub.image]); assetMock.save.mockResolvedValue(assetEntityStub.image); + userMock.getList.mockResolvedValue([userEntityStub.user1]); await sut.handleTemplateMigration(); @@ -108,9 +120,28 @@ describe(StorageTemplateService.name, () => { }); }); + it('should use the user storage label', async () => { + assetMock.getAll.mockResolvedValue([assetEntityStub.image]); + assetMock.save.mockResolvedValue(assetEntityStub.image); + userMock.getList.mockResolvedValue([userEntityStub.storageLabel]); + + await sut.handleTemplateMigration(); + + expect(assetMock.getAll).toHaveBeenCalled(); + expect(storageMock.moveFile).toHaveBeenCalledWith( + '/original/path.ext', + 'upload/library/label-1/2023/2023-02-23/asset-id.ext', + ); + expect(assetMock.save).toHaveBeenCalledWith({ + id: assetEntityStub.image.id, + originalPath: 'upload/library/label-1/2023/2023-02-23/asset-id.ext', + }); + }); + it('should not update the database if the move fails', async () => { assetMock.getAll.mockResolvedValue([assetEntityStub.image]); storageMock.moveFile.mockRejectedValue(new Error('Read only system')); + userMock.getList.mockResolvedValue([userEntityStub.user1]); await sut.handleTemplateMigration(); @@ -125,6 +156,7 @@ describe(StorageTemplateService.name, () => { it('should move the asset back if the database fails', async () => { assetMock.getAll.mockResolvedValue([assetEntityStub.image]); assetMock.save.mockRejectedValue('Connection Error!'); + userMock.getList.mockResolvedValue([userEntityStub.user1]); await sut.handleTemplateMigration(); @@ -143,6 +175,7 @@ describe(StorageTemplateService.name, () => { it('should handle an error', async () => { assetMock.getAll.mockResolvedValue([]); storageMock.removeEmptyDirs.mockRejectedValue(new Error('Read only filesystem')); + userMock.getList.mockResolvedValue([]); await sut.handleTemplateMigration(); }); diff --git a/server/libs/domain/src/storage-template/storage-template.service.ts b/server/libs/domain/src/storage-template/storage-template.service.ts index ff188d29e1..91ffbb886c 100644 --- a/server/libs/domain/src/storage-template/storage-template.service.ts +++ b/server/libs/domain/src/storage-template/storage-template.service.ts @@ -6,8 +6,14 @@ import { getLivePhotoMotionFilename } from '../domain.util'; import { IAssetJob } from '../job'; import { IStorageRepository } from '../storage/storage.repository'; import { INITIAL_SYSTEM_CONFIG, ISystemConfigRepository } from '../system-config'; +import { IUserRepository } from '../user/user.repository'; import { StorageTemplateCore } from './storage-template.core'; +export interface MoveAssetMetadata { + storageLabel: string | null; + filename: string; +} + @Injectable() export class StorageTemplateService { private logger = new Logger(StorageTemplateService.name); @@ -18,6 +24,7 @@ export class StorageTemplateService { @Inject(ISystemConfigRepository) configRepository: ISystemConfigRepository, @Inject(INITIAL_SYSTEM_CONFIG) config: SystemConfig, @Inject(IStorageRepository) private storageRepository: IStorageRepository, + @Inject(IUserRepository) private userRepository: IUserRepository, ) { this.core = new StorageTemplateCore(configRepository, config, storageRepository); } @@ -26,14 +33,16 @@ export class StorageTemplateService { const { asset } = data; try { + const user = await this.userRepository.get(asset.ownerId); + const storageLabel = user?.storageLabel || null; const filename = asset.originalFileName || asset.id; - await this.moveAsset(asset, filename); + await this.moveAsset(asset, { storageLabel, filename }); // move motion part of live photo if (asset.livePhotoVideoId) { const [livePhotoVideo] = await this.assetRepository.getByIds([asset.livePhotoVideoId]); const motionFilename = getLivePhotoMotionFilename(filename, livePhotoVideo.originalPath); - await this.moveAsset(livePhotoVideo, motionFilename); + await this.moveAsset(livePhotoVideo, { storageLabel, filename: motionFilename }); } } catch (error: any) { this.logger.error('Error running single template migration', error); @@ -44,6 +53,7 @@ export class StorageTemplateService { try { console.time('migrating-time'); const assets = await this.assetRepository.getAll(); + const users = await this.userRepository.getList(); const livePhotoMap: Record = {}; @@ -56,8 +66,10 @@ export class StorageTemplateService { for (const asset of assets) { const livePhotoParentAsset = livePhotoMap[asset.id]; // TODO: remove livePhoto specific stuff once upload is fixed + const user = users.find((user) => user.id === asset.ownerId); + const storageLabel = user?.storageLabel || null; const filename = asset.originalFileName || livePhotoParentAsset?.originalFileName || asset.id; - await this.moveAsset(asset, filename); + await this.moveAsset(asset, { storageLabel, filename }); } this.logger.debug('Cleaning up empty directories...'); @@ -70,8 +82,8 @@ export class StorageTemplateService { } // TODO: use asset core (once in domain) - async moveAsset(asset: AssetEntity, originalName: string) { - const destination = await this.core.getTemplatePath(asset, originalName); + async moveAsset(asset: AssetEntity, metadata: MoveAssetMetadata) { + const destination = await this.core.getTemplatePath(asset, metadata); if (asset.originalPath !== destination) { const source = asset.originalPath; diff --git a/server/libs/domain/src/storage/storage.core.ts b/server/libs/domain/src/storage/storage.core.ts index 87827d0c2d..a5d66f1b8e 100644 --- a/server/libs/domain/src/storage/storage.core.ts +++ b/server/libs/domain/src/storage/storage.core.ts @@ -10,7 +10,14 @@ export enum StorageFolder { } export class StorageCore { - getFolderLocation(folder: StorageFolder, userId: string) { + getFolderLocation( + folder: StorageFolder.ENCODED_VIDEO | StorageFolder.UPLOAD | StorageFolder.PROFILE | StorageFolder.THUMBNAILS, + userId: string, + ) { return join(APP_MEDIA_LOCATION, folder, userId); } + + getLibraryFolder(user: { storageLabel: string | null; id: string }) { + return join(APP_MEDIA_LOCATION, StorageFolder.LIBRARY, user.storageLabel || user.id); + } } diff --git a/server/libs/domain/src/user/dto/create-user.dto.ts b/server/libs/domain/src/user/dto/create-user.dto.ts index 11d312faf3..a99811b04b 100644 --- a/server/libs/domain/src/user/dto/create-user.dto.ts +++ b/server/libs/domain/src/user/dto/create-user.dto.ts @@ -1,24 +1,28 @@ -import { ApiProperty } from '@nestjs/swagger'; import { Transform } from 'class-transformer'; -import { IsNotEmpty, IsEmail } from 'class-validator'; +import { IsEmail, IsNotEmpty, IsOptional, IsString } from 'class-validator'; +import { toEmail, toSanitized } from '../../../../../apps/immich/src/utils/transform.util'; export class CreateUserDto { @IsEmail() - @Transform(({ value }) => value?.toLowerCase()) - @ApiProperty({ example: 'testuser@email.com' }) + @Transform(toEmail) email!: string; @IsNotEmpty() - @ApiProperty({ example: 'password' }) + @IsString() password!: string; @IsNotEmpty() - @ApiProperty({ example: 'John' }) + @IsString() firstName!: string; @IsNotEmpty() - @ApiProperty({ example: 'Doe' }) + @IsString() lastName!: string; + + @IsOptional() + @IsString() + @Transform(toSanitized) + storageLabel?: string | null; } export class CreateAdminDto { diff --git a/server/libs/domain/src/user/dto/update-user.dto.ts b/server/libs/domain/src/user/dto/update-user.dto.ts index f404e4d16f..db8ecba7fb 100644 --- a/server/libs/domain/src/user/dto/update-user.dto.ts +++ b/server/libs/domain/src/user/dto/update-user.dto.ts @@ -1,8 +1,34 @@ -import { IsBoolean, IsNotEmpty, IsOptional, IsUUID } from 'class-validator'; -import { CreateUserDto } from './create-user.dto'; -import { ApiProperty, PartialType } from '@nestjs/swagger'; +import { ApiProperty } from '@nestjs/swagger'; +import { Transform } from 'class-transformer'; +import { IsBoolean, IsEmail, IsNotEmpty, IsOptional, IsString, IsUUID } from 'class-validator'; +import { toEmail, toSanitized } from '../../../../../apps/immich/src/utils/transform.util'; + +export class UpdateUserDto { + @IsOptional() + @IsEmail() + @Transform(toEmail) + email?: string; + + @IsOptional() + @IsNotEmpty() + @IsString() + password?: string; + + @IsOptional() + @IsString() + @IsNotEmpty() + firstName?: string; + + @IsOptional() + @IsString() + @IsNotEmpty() + lastName?: string; + + @IsOptional() + @IsString() + @Transform(toSanitized) + storageLabel?: string; -export class UpdateUserDto extends PartialType(CreateUserDto) { @IsNotEmpty() @IsUUID('4') @ApiProperty({ format: 'uuid' }) diff --git a/server/libs/domain/src/user/response-dto/user-response.dto.ts b/server/libs/domain/src/user/response-dto/user-response.dto.ts index cc9a75f4d0..53da95fd52 100644 --- a/server/libs/domain/src/user/response-dto/user-response.dto.ts +++ b/server/libs/domain/src/user/response-dto/user-response.dto.ts @@ -5,6 +5,7 @@ export class UserResponseDto { email!: string; firstName!: string; lastName!: string; + storageLabel!: string | null; createdAt!: string; profileImagePath!: string; shouldChangePassword!: boolean; @@ -20,6 +21,7 @@ export function mapUser(entity: UserEntity): UserResponseDto { email: entity.email, firstName: entity.firstName, lastName: entity.lastName, + storageLabel: entity.storageLabel, createdAt: entity.createdAt, profileImagePath: entity.profileImagePath, shouldChangePassword: entity.shouldChangePassword, diff --git a/server/libs/domain/src/user/user.core.ts b/server/libs/domain/src/user/user.core.ts index 2e75231568..6a986e4cb0 100644 --- a/server/libs/domain/src/user/user.core.ts +++ b/server/libs/domain/src/user/user.core.ts @@ -5,7 +5,6 @@ import { InternalServerErrorException, Logger, NotFoundException, - UnauthorizedException, } from '@nestjs/common'; import { hash } from 'bcrypt'; import { constants, createReadStream, ReadStream } from 'fs'; @@ -28,6 +27,7 @@ export class UserCore { if (!authUser.isAdmin) { // Users can never update the isAdmin property. delete dto.isAdmin; + delete dto.storageLabel; } else if (dto.isAdmin && authUser.id !== id) { // Admin cannot create another admin. throw new BadRequestException('The server already has an admin'); @@ -36,7 +36,14 @@ export class UserCore { if (dto.email) { const duplicate = await this.userRepository.getByEmail(dto.email); if (duplicate && duplicate.id !== id) { - throw new BadRequestException('Email already in user by another account'); + 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'); } } @@ -45,6 +52,10 @@ export class UserCore { dto.password = await this.cryptoRepository.hashBcrypt(dto.password, SALT_ROUNDS); } + if (dto.storageLabel === '') { + dto.storageLabel = null; + } + return this.userRepository.update(id, dto); } catch (e) { Logger.error(e, 'Failed to update user info'); @@ -106,14 +117,8 @@ export class UserCore { } async createProfileImage(authUser: AuthUserDto, filePath: string): Promise { - // TODO: do we need to do this? Maybe we can trust the authUser - const user = await this.userRepository.get(authUser.id); - if (!user) { - throw new NotFoundException('User not found'); - } - try { - return this.userRepository.update(user.id, { profileImagePath: filePath }); + return this.userRepository.update(authUser.id, { profileImagePath: filePath }); } catch (e) { Logger.error(e, 'Create User Profile Image'); throw new InternalServerErrorException('Failed to create new user profile image'); @@ -121,12 +126,7 @@ export class UserCore { } async restoreUser(authUser: AuthUserDto, userToRestore: UserEntity): Promise { - // TODO: do we need to do this? Maybe we can trust the authUser - const requestor = await this.userRepository.get(authUser.id); - if (!requestor) { - throw new UnauthorizedException('Requestor not found'); - } - if (!requestor.isAdmin) { + if (!authUser.isAdmin) { throw new ForbiddenException('Unauthorized'); } try { @@ -138,12 +138,7 @@ export class UserCore { } async deleteUser(authUser: AuthUserDto, userToDelete: UserEntity): Promise { - // TODO: do we need to do this? Maybe we can trust the authUser - const requestor = await this.userRepository.get(authUser.id); - if (!requestor) { - throw new UnauthorizedException('Requestor not found'); - } - if (!requestor.isAdmin) { + if (!authUser.isAdmin) { throw new ForbiddenException('Unauthorized'); } diff --git a/server/libs/domain/src/user/user.repository.ts b/server/libs/domain/src/user/user.repository.ts index 4a3f55de90..984a7bebaf 100644 --- a/server/libs/domain/src/user/user.repository.ts +++ b/server/libs/domain/src/user/user.repository.ts @@ -1,7 +1,7 @@ import { UserEntity } from '@app/infra/entities'; export interface UserListFilter { - excludeId?: string; + withDeleted?: boolean; } export interface UserStatsQueryResponse { @@ -19,6 +19,7 @@ export interface IUserRepository { get(id: string, withDeleted?: boolean): Promise; getAdmin(): Promise; getByEmail(email: string, withPassword?: boolean): Promise; + getByStorageLabel(storageLabel: string): Promise; getByOAuthId(oauthId: string): Promise; getDeletedUsers(): Promise; getList(filter?: UserListFilter): Promise; diff --git a/server/libs/domain/src/user/user.service.spec.ts b/server/libs/domain/src/user/user.service.spec.ts index d999522951..808c6ebe50 100644 --- a/server/libs/domain/src/user/user.service.spec.ts +++ b/server/libs/domain/src/user/user.service.spec.ts @@ -36,7 +36,7 @@ const adminUserAuth: AuthUserDto = Object.freeze({ }); const immichUserAuth: AuthUserDto = Object.freeze({ - id: 'immich_id', + id: 'user-id', email: 'immich@test.com', isAdmin: false, }); @@ -55,6 +55,7 @@ const adminUser: UserEntity = Object.freeze({ updatedAt: '2021-01-01', tags: [], assets: [], + storageLabel: 'admin', }); const immichUser: UserEntity = Object.freeze({ @@ -71,6 +72,7 @@ const immichUser: UserEntity = Object.freeze({ updatedAt: '2021-01-01', tags: [], assets: [], + storageLabel: null, }); const updatedImmichUser: UserEntity = Object.freeze({ @@ -87,6 +89,7 @@ const updatedImmichUser: UserEntity = Object.freeze({ updatedAt: '2021-01-01', tags: [], assets: [], + storageLabel: null, }); const adminUserResponse = Object.freeze({ @@ -101,6 +104,7 @@ const adminUserResponse = Object.freeze({ profileImagePath: '', createdAt: '2021-01-01', updatedAt: '2021-01-01', + storageLabel: 'admin', }); describe(UserService.name, () => { @@ -150,7 +154,7 @@ describe(UserService.name, () => { const response = await sut.getAllUsers(adminUserAuth, false); - expect(userRepositoryMock.getList).toHaveBeenCalledWith({ excludeId: adminUser.id }); + expect(userRepositoryMock.getList).toHaveBeenCalledWith({ withDeleted: true }); expect(response).toEqual([ { id: adminUserAuth.id, @@ -164,6 +168,7 @@ describe(UserService.name, () => { profileImagePath: '', createdAt: '2021-01-01', updatedAt: '2021-01-01', + storageLabel: 'admin', }, ]); }); @@ -231,6 +236,22 @@ describe(UserService.name, () => { expect(updatedUser.shouldChangePassword).toEqual(true); }); + it('should not set an empty string for storage label', async () => { + userRepositoryMock.update.mockResolvedValue(updatedImmichUser); + + await sut.updateUser(adminUserAuth, { id: immichUser.id, storageLabel: '' }); + + expect(userRepositoryMock.update).toHaveBeenCalledWith(immichUser.id, { id: immichUser.id, storageLabel: null }); + }); + + it('should omit a storage label set by non-admin users', async () => { + userRepositoryMock.update.mockResolvedValue(updatedImmichUser); + + await sut.updateUser(immichUserAuth, { id: immichUser.id, storageLabel: 'admin' }); + + expect(userRepositoryMock.update).toHaveBeenCalledWith(immichUser.id, { id: immichUser.id }); + }); + it('user can only update its information', async () => { when(userRepositoryMock.get) .calledWith('not_immich_auth_user_id', undefined) @@ -255,7 +276,7 @@ describe(UserService.name, () => { await sut.updateUser(immichUser, dto); expect(userRepositoryMock.update).toHaveBeenCalledWith(immichUser.id, { - id: 'immich_id', + id: 'user-id', email: 'updated@test.com', }); }); @@ -271,6 +292,17 @@ describe(UserService.name, () => { expect(userRepositoryMock.update).not.toHaveBeenCalled(); }); + it('should not let the admin change the storage label to one already in use', async () => { + const dto = { id: immichUser.id, storageLabel: 'admin' }; + + userRepositoryMock.get.mockResolvedValue(immichUser); + userRepositoryMock.getByStorageLabel.mockResolvedValue(adminUser); + + await expect(sut.updateUser(adminUser, dto)).rejects.toBeInstanceOf(BadRequestException); + + expect(userRepositoryMock.update).not.toHaveBeenCalled(); + }); + it('admin can update any user information', async () => { const update: UpdateUserDto = { id: immichUser.id, @@ -481,6 +513,16 @@ describe(UserService.name, () => { expect(userRepositoryMock.delete).toHaveBeenCalledWith(user, true); }); + it('should delete the library path for a storage label', async () => { + const user = { id: 'deleted-user', deletedAt: makeDeletedAt(10), storageLabel: 'admin' } as UserEntity; + + await sut.handleUserDelete({ user }); + + const options = { force: true, recursive: true }; + + expect(storageMock.unlinkDir).toHaveBeenCalledWith('upload/library/admin', options); + }); + it('should handle an error', async () => { const user = { id: 'deleted-user', deletedAt: makeDeletedAt(10) } as UserEntity; diff --git a/server/libs/domain/src/user/user.service.ts b/server/libs/domain/src/user/user.service.ts index 2f7fc37762..ee61b2e582 100644 --- a/server/libs/domain/src/user/user.service.ts +++ b/server/libs/domain/src/user/user.service.ts @@ -44,13 +44,8 @@ export class UserService { } async getAllUsers(authUser: AuthUserDto, isAll: boolean): Promise { - if (isAll) { - const allUsers = await this.userCore.getList(); - return allUsers.map(mapUser); - } - - const allUserExceptRequestedUser = await this.userCore.getList({ excludeId: authUser.id }); - return allUserExceptRequestedUser.map(mapUser); + const users = await this.userCore.getList({ withDeleted: !isAll }); + return users.map(mapUser); } async getUserById(userId: string, withDeleted = false): Promise { @@ -165,7 +160,7 @@ export class UserService { try { const folders = [ - this.storageCore.getFolderLocation(StorageFolder.LIBRARY, user.id), + this.storageCore.getLibraryFolder(user), this.storageCore.getFolderLocation(StorageFolder.UPLOAD, user.id), this.storageCore.getFolderLocation(StorageFolder.PROFILE, user.id), this.storageCore.getFolderLocation(StorageFolder.THUMBNAILS, user.id), diff --git a/server/libs/domain/test/fixtures.ts b/server/libs/domain/test/fixtures.ts index 9752ccee6c..ab6567cca1 100644 --- a/server/libs/domain/test/fixtures.ts +++ b/server/libs/domain/test/fixtures.ts @@ -43,7 +43,7 @@ export const authStub = { isAllowUpload: true, }), user1: Object.freeze({ - id: 'immich_id', + id: 'user-id', email: 'immich@test.com', isAdmin: false, isPublicUser: false, @@ -81,6 +81,7 @@ export const userEntityStub = { password: 'admin_password', firstName: 'admin_first_name', lastName: 'admin_last_name', + storageLabel: 'admin', oauthId: '', shouldChangePassword: false, profileImagePath: '', @@ -94,6 +95,21 @@ export const userEntityStub = { password: 'immich_password', firstName: 'immich_first_name', lastName: 'immich_last_name', + storageLabel: null, + oauthId: '', + shouldChangePassword: false, + profileImagePath: '', + createdAt: '2021-01-01', + updatedAt: '2021-01-01', + tags: [], + assets: [], + }), + storageLabel: Object.freeze({ + ...authStub.user1, + password: 'immich_password', + firstName: 'immich_first_name', + lastName: 'immich_last_name', + storageLabel: 'label-1', oauthId: '', shouldChangePassword: false, profileImagePath: '', @@ -536,7 +552,7 @@ export const loginResponseStub = { user1oauth: { response: { accessToken: 'cmFuZG9tLWJ5dGVz', - userId: 'immich_id', + userId: 'user-id', userEmail: 'immich@test.com', firstName: 'immich_first_name', lastName: 'immich_last_name', @@ -552,7 +568,7 @@ export const loginResponseStub = { user1password: { response: { accessToken: 'cmFuZG9tLWJ5dGVz', - userId: 'immich_id', + userId: 'user-id', userEmail: 'immich@test.com', firstName: 'immich_first_name', lastName: 'immich_last_name', @@ -568,7 +584,7 @@ export const loginResponseStub = { user1insecure: { response: { accessToken: 'cmFuZG9tLWJ5dGVz', - userId: 'immich_id', + userId: 'user-id', userEmail: 'immich@test.com', firstName: 'immich_first_name', lastName: 'immich_last_name', diff --git a/server/libs/domain/test/user.repository.mock.ts b/server/libs/domain/test/user.repository.mock.ts index 94263ba8ec..011273dc2b 100644 --- a/server/libs/domain/test/user.repository.mock.ts +++ b/server/libs/domain/test/user.repository.mock.ts @@ -5,6 +5,7 @@ export const newUserRepositoryMock = (): jest.Mocked => { get: jest.fn(), getAdmin: jest.fn(), getByEmail: jest.fn(), + getByStorageLabel: jest.fn(), getByOAuthId: jest.fn(), getUserStats: jest.fn(), getList: jest.fn(), diff --git a/server/libs/infra/src/entities/user.entity.ts b/server/libs/infra/src/entities/user.entity.ts index 5fca9a89d0..06c6981263 100644 --- a/server/libs/infra/src/entities/user.entity.ts +++ b/server/libs/infra/src/entities/user.entity.ts @@ -27,6 +27,9 @@ export class UserEntity { @Column({ unique: true }) email!: string; + @Column({ type: 'varchar', unique: true, default: null }) + storageLabel!: string | null; + @Column({ default: '', select: false }) password?: string; diff --git a/server/libs/infra/src/migrations/1684410565398-AddStorageLabel.ts b/server/libs/infra/src/migrations/1684410565398-AddStorageLabel.ts new file mode 100644 index 0000000000..6c6ea9702f --- /dev/null +++ b/server/libs/infra/src/migrations/1684410565398-AddStorageLabel.ts @@ -0,0 +1,16 @@ +import { MigrationInterface, QueryRunner } from "typeorm"; + +export class AddStorageLabel1684410565398 implements MigrationInterface { + name = 'AddStorageLabel1684410565398' + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(`ALTER TABLE "users" ADD "storageLabel" character varying`); + await queryRunner.query(`ALTER TABLE "users" ADD CONSTRAINT "UQ_b309cf34fa58137c416b32cea3a" UNIQUE ("storageLabel")`); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(`ALTER TABLE "users" DROP CONSTRAINT "UQ_b309cf34fa58137c416b32cea3a"`); + await queryRunner.query(`ALTER TABLE "users" DROP COLUMN "storageLabel"`); + } + +} diff --git a/server/libs/infra/src/repositories/user.repository.ts b/server/libs/infra/src/repositories/user.repository.ts index 9206415576..0fa1121289 100644 --- a/server/libs/infra/src/repositories/user.repository.ts +++ b/server/libs/infra/src/repositories/user.repository.ts @@ -6,10 +6,7 @@ import { UserEntity } from '../entities'; @Injectable() export class UserRepository implements IUserRepository { - constructor( - @InjectRepository(UserEntity) - private userRepository: Repository, - ) {} + constructor(@InjectRepository(UserEntity) private userRepository: Repository) {} async get(userId: string, withDeleted?: boolean): Promise { return this.userRepository.findOne({ where: { id: userId }, withDeleted: withDeleted }); @@ -29,6 +26,10 @@ export class UserRepository implements IUserRepository { return builder.getOne(); } + async getByStorageLabel(storageLabel: string): Promise { + return this.userRepository.findOne({ where: { storageLabel } }); + } + async getByOAuthId(oauthId: string): Promise { return this.userRepository.findOne({ where: { oauthId } }); } @@ -37,13 +38,9 @@ export class UserRepository implements IUserRepository { return this.userRepository.find({ withDeleted: true, where: { deletedAt: Not(IsNull()) } }); } - async getList({ excludeId }: UserListFilter = {}): Promise { - if (!excludeId) { - return this.userRepository.find(); // TODO: this should also be ordered the same as below - } + async getList({ withDeleted }: UserListFilter = {}): Promise { return this.userRepository.find({ - where: { id: Not(excludeId) }, - withDeleted: true, + withDeleted, order: { createdAt: 'DESC', }, diff --git a/web/src/api/open-api/api.ts b/web/src/api/open-api/api.ts index e135c3d50f..bb885c8a2d 100644 --- a/web/src/api/open-api/api.ts +++ b/web/src/api/open-api/api.ts @@ -910,6 +910,12 @@ export interface CreateUserDto { * @memberof CreateUserDto */ 'lastName': string; + /** + * + * @type {string} + * @memberof CreateUserDto + */ + 'storageLabel'?: string | null; } /** * @@ -2450,6 +2456,12 @@ export interface UpdateTagDto { * @interface UpdateUserDto */ export interface UpdateUserDto { + /** + * + * @type {string} + * @memberof UpdateUserDto + */ + 'id': string; /** * * @type {string} @@ -2479,7 +2491,7 @@ export interface UpdateUserDto { * @type {string} * @memberof UpdateUserDto */ - 'id': string; + 'storageLabel'?: string; /** * * @type {boolean} @@ -2579,6 +2591,12 @@ export interface UserResponseDto { * @memberof UserResponseDto */ 'lastName': string; + /** + * + * @type {string} + * @memberof UserResponseDto + */ + 'storageLabel': string | null; /** * * @type {string} diff --git a/web/src/lib/components/admin-page/delete-confirm-dialoge.svelte b/web/src/lib/components/admin-page/delete-confirm-dialoge.svelte index 4838a6d9cd..2561f0d1d9 100644 --- a/web/src/lib/components/admin-page/delete-confirm-dialoge.svelte +++ b/web/src/lib/components/admin-page/delete-confirm-dialoge.svelte @@ -2,15 +2,24 @@ import { api, UserResponseDto } from '@api'; import { createEventDispatcher } from 'svelte'; import Button from '../elements/buttons/button.svelte'; + import { handleError } from '../../utils/handle-error'; export let user: UserResponseDto; const dispatch = createEventDispatcher(); const deleteUser = async () => { - const deletedUser = await api.userApi.deleteUser(user.id); - if (deletedUser.data.deletedAt != null) dispatch('user-delete-success'); - else dispatch('user-delete-fail'); + try { + const deletedUser = await api.userApi.deleteUser(user.id); + if (deletedUser.data.deletedAt != null) { + dispatch('user-delete-success'); + } else { + dispatch('user-delete-fail'); + } + } catch (error) { + handleError(error, 'Unable to delete user'); + dispatch('user-delete-fail'); + } }; diff --git a/web/src/lib/components/admin-page/settings/storage-template/storage-template-settings.svelte b/web/src/lib/components/admin-page/settings/storage-template/storage-template-settings.svelte index 837fea142f..7b778a5078 100644 --- a/web/src/lib/components/admin-page/settings/storage-template/storage-template-settings.svelte +++ b/web/src/lib/components/admin-page/settings/storage-template/storage-template-settings.svelte @@ -171,14 +171,14 @@

- {user.id} is the user's ID + {user.storageLabel || user.id} is the user's Storage Label

UPLOAD_LOCATION/{user.id}UPLOAD_LOCATION/{user.storageLabel || user.id}/{parsedTemplate()}.jpg

diff --git a/web/src/lib/components/album-page/user-selection-modal.svelte b/web/src/lib/components/album-page/user-selection-modal.svelte index 17a2d34859..5dc10300da 100644 --- a/web/src/lib/components/album-page/user-selection-modal.svelte +++ b/web/src/lib/components/album-page/user-selection-modal.svelte @@ -21,8 +21,8 @@ await getSharedLinks(); const { data } = await api.userApi.getAllUsers(false); - // remove soft deleted users - users = data.filter((user) => !user.deletedAt); + // remove invalid users + users = data.filter((user) => !(user.deletedAt || user.id === album.ownerId)); // Remove the existed shared users from the album sharedUsersInAlbum.forEach((sharedUser) => { diff --git a/web/src/lib/components/forms/edit-user-form.svelte b/web/src/lib/components/forms/edit-user-form.svelte index bfee78ef53..0b509bdc7f 100644 --- a/web/src/lib/components/forms/edit-user-form.svelte +++ b/web/src/lib/components/forms/edit-user-form.svelte @@ -7,8 +7,10 @@ NotificationType } from '../shared-components/notification/notification'; import Button from '../elements/buttons/button.svelte'; + import { handleError } from '../../utils/handle-error'; export let user: UserResponseDto; + export let canResetPassword = true; let error: string; let success: string; @@ -17,18 +19,20 @@ const editUser = async () => { try { - const { id, email, firstName, lastName } = user; - const { status } = await api.userApi.updateUser({ id, email, firstName, lastName }); + const { id, email, firstName, lastName, storageLabel } = user; + const { status } = await api.userApi.updateUser({ + id, + email, + firstName, + lastName, + storageLabel: storageLabel || '' + }); if (status === 200) { dispatch('edit-success'); } - } catch (e) { - console.error('Error updating user ', e); - notificationController.show({ - message: 'Error updating user, check console for more details', - type: NotificationType.Error - }); + } catch (error) { + handleError(error, 'Unable to update user'); } }; @@ -105,6 +109,24 @@ /> +
+ + + +

+ Note: To apply the Storage Label to previously uploaded assets, run the Storage Migration Job +

+
+ {#if error}

{error}

{/if} @@ -113,7 +135,9 @@

{success}

{/if}
- + {#if canResetPassword} + + {/if}
diff --git a/web/src/lib/components/user-settings-page/partner-selection-modal.svelte b/web/src/lib/components/user-settings-page/partner-selection-modal.svelte index b957e2cdf6..4c420e5a40 100644 --- a/web/src/lib/components/user-settings-page/partner-selection-modal.svelte +++ b/web/src/lib/components/user-settings-page/partner-selection-modal.svelte @@ -6,6 +6,8 @@ import Button from '../elements/buttons/button.svelte'; import { createEventDispatcher, onMount } from 'svelte'; + export let user: UserResponseDto; + let availableUsers: UserResponseDto[] = []; let selectedUsers: UserResponseDto[] = []; @@ -15,8 +17,8 @@ // TODO: update endpoint to have a query param for deleted users let { data: users } = await api.userApi.getAllUsers(false); - // remove soft deleted users - users = users.filter((user) => !user.deletedAt); + // remove invalid users + users = users.filter((_user) => !(_user.deletedAt || _user.id === user.id)); // exclude partners from the list of users available for selection const { data: partners } = await api.partnerApi.getPartners('shared-by'); diff --git a/web/src/lib/components/user-settings-page/partner-settings.svelte b/web/src/lib/components/user-settings-page/partner-settings.svelte index d12830928c..5743529c81 100644 --- a/web/src/lib/components/user-settings-page/partner-settings.svelte +++ b/web/src/lib/components/user-settings-page/partner-settings.svelte @@ -9,6 +9,8 @@ import ConfirmDialogue from '../shared-components/confirm-dialogue.svelte'; import CircleIconButton from '../elements/buttons/circle-icon-button.svelte'; + export let user: UserResponseDto; + let partners: UserResponseDto[] = []; let createPartner = false; let removePartner: UserResponseDto | null = null; @@ -83,6 +85,7 @@ {#if createPartner} (createPartner = false)} on:add-users={(event) => handleCreatePartners(event.detail)} /> diff --git a/web/src/lib/components/user-settings-page/user-profile-settings.svelte b/web/src/lib/components/user-settings-page/user-profile-settings.svelte index a3bc3b8500..639bcd8825 100644 --- a/web/src/lib/components/user-settings-page/user-profile-settings.svelte +++ b/web/src/lib/components/user-settings-page/user-profile-settings.svelte @@ -65,6 +65,14 @@ required={true} /> + +
diff --git a/web/src/lib/components/user-settings-page/user-settings-list.svelte b/web/src/lib/components/user-settings-page/user-settings-list.svelte index da93fb8d94..6f069f664d 100644 --- a/web/src/lib/components/user-settings-page/user-settings-list.svelte +++ b/web/src/lib/components/user-settings-page/user-settings-list.svelte @@ -54,5 +54,5 @@ - + diff --git a/web/src/routes/admin/user-management/+page.svelte b/web/src/routes/admin/user-management/+page.svelte index 3223c0a6d2..9b33a49516 100644 --- a/web/src/routes/admin/user-management/+page.svelte +++ b/web/src/routes/admin/user-management/+page.svelte @@ -13,6 +13,9 @@ import { page } from '$app/stores'; import { locale } from '$lib/stores/preferences.store'; import Button from '$lib/components/elements/buttons/button.svelte'; + import type { PageData } from './$types'; + + export let data: PageData; let allUsers: UserResponseDto[] = []; let shouldShowEditUserForm = false; @@ -113,6 +116,7 @@ (shouldShowEditUserForm = false)}> @@ -195,12 +199,14 @@ > - + {#if user.id !== data.user.id} + + {/if} {/if} {#if isDeleted(user)}