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)}