1
0
mirror of https://github.com/immich-app/immich.git synced 2024-12-26 10:50:29 +02:00

feat(web,server): user storage label (#2418)

* feat: user storage label

* chore: open api

* fix: checks

* fix: api update validation and tests

* feat: default admin storage label

* fix: linting

* fix: user create/update dto

* fix: delete library with custom label
This commit is contained in:
Jason Rasmussen 2023-05-21 23:18:10 -04:00 committed by GitHub
parent 0ccb73cf2b
commit 74353193f8
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
43 changed files with 380 additions and 123 deletions

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

View File

@ -39,6 +39,7 @@ describe('Album service', () => {
oauthId: '', oauthId: '',
tags: [], tags: [],
assets: [], assets: [],
storageLabel: null,
}); });
const albumId = 'f19ab956-4761-41ea-a5d6-bae948308d58'; const albumId = 'f19ab956-4761-41ea-a5d6-bae948308d58';
const sharedAlbumOwnerId = '2222'; const sharedAlbumOwnerId = '2222';

View File

@ -27,6 +27,7 @@ describe('TagService', () => {
tags: [], tags: [],
assets: [], assets: [],
oauthId: 'oauth-id-1', oauthId: 'oauth-id-1',
storageLabel: null,
}); });
// const user2: UserEntity = Object.freeze({ // const user2: UserEntity = Object.freeze({

View File

@ -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') { if (value == 'true') {
return true; return true;
} else if (value == 'false') { } else if (value == 'false') {
@ -6,3 +12,7 @@ export const toBoolean = ({ value }: { value: string }) => {
} }
return value; return value;
}; };
export const toEmail = ({ value }: IValue) => value?.toLowerCase();
export const toSanitized = ({ value }: IValue) => sanitize((value || '').replace(/\./g, ''));

View File

@ -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'); const { status, body } = await request(app.getHttpServer()).get('/user?isAll=false');
expect(status).toEqual(200); expect(status).toEqual(200);
expect(body).toHaveLength(2); expect(body).toHaveLength(3);
expect(body).toEqual( expect(body).toEqual(
expect.arrayContaining([ expect.arrayContaining([
{ {
@ -105,6 +105,7 @@ describe('User', () => {
deletedAt: null, deletedAt: null,
updatedAt: expect.anything(), updatedAt: expect.anything(),
oauthId: '', oauthId: '',
storageLabel: null,
}, },
{ {
email: userTwoEmail, email: userTwoEmail,
@ -118,10 +119,24 @@ describe('User', () => {
deletedAt: null, deletedAt: null,
updatedAt: expect.anything(), updatedAt: expect.anything(),
oauthId: '', 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 () => { it('disallows admin user from creating a second admin account', async () => {

View File

@ -4122,6 +4122,10 @@
"lastName": { "lastName": {
"type": "string" "type": "string"
}, },
"storageLabel": {
"type": "string",
"nullable": true
},
"createdAt": { "createdAt": {
"type": "string" "type": "string"
}, },
@ -4150,6 +4154,7 @@
"email", "email",
"firstName", "firstName",
"lastName", "lastName",
"storageLabel",
"createdAt", "createdAt",
"profileImagePath", "profileImagePath",
"shouldChangePassword", "shouldChangePassword",
@ -5529,20 +5534,20 @@
"type": "object", "type": "object",
"properties": { "properties": {
"email": { "email": {
"type": "string", "type": "string"
"example": "testuser@email.com"
}, },
"password": { "password": {
"type": "string", "type": "string"
"example": "password"
}, },
"firstName": { "firstName": {
"type": "string", "type": "string"
"example": "John"
}, },
"lastName": { "lastName": {
"type": "string"
},
"storageLabel": {
"type": "string", "type": "string",
"example": "Doe" "nullable": true
} }
}, },
"required": [ "required": [
@ -5566,26 +5571,25 @@
"UpdateUserDto": { "UpdateUserDto": {
"type": "object", "type": "object",
"properties": { "properties": {
"email": {
"type": "string",
"example": "testuser@email.com"
},
"password": {
"type": "string",
"example": "password"
},
"firstName": {
"type": "string",
"example": "John"
},
"lastName": {
"type": "string",
"example": "Doe"
},
"id": { "id": {
"type": "string", "type": "string",
"format": "uuid" "format": "uuid"
}, },
"email": {
"type": "string"
},
"password": {
"type": "string"
},
"firstName": {
"type": "string"
},
"lastName": {
"type": "string"
},
"storageLabel": {
"type": "string"
},
"isAdmin": { "isAdmin": {
"type": "boolean" "type": "boolean"
}, },

View File

@ -306,7 +306,7 @@ describe('AuthService', () => {
expect(userTokenMock.save.mock.calls[0][0]).toMatchObject({ expect(userTokenMock.save.mock.calls[0][0]).toMatchObject({
id: 'not_active', id: 'not_active',
token: 'auth_token', token: 'auth_token',
userId: 'immich_id', userId: 'user-id',
createdAt: new Date('2021-01-01'), createdAt: new Date('2021-01-01'),
updatedAt: expect.any(Date), updatedAt: expect.any(Date),
deviceOS: 'Android', deviceOS: 'Android',

View File

@ -122,6 +122,7 @@ export class AuthService {
firstName: dto.firstName, firstName: dto.firstName,
lastName: dto.lastName, lastName: dto.lastName,
password: dto.password, password: dto.password,
storageLabel: 'admin',
}); });
return mapAdminSignupResponse(admin); return mapAdminSignupResponse(admin);

View File

@ -17,19 +17,21 @@ const responseDto = {
profileImagePath: '', profileImagePath: '',
shouldChangePassword: false, shouldChangePassword: false,
updatedAt: '2021-01-01', updatedAt: '2021-01-01',
storageLabel: 'admin',
}, },
user1: { user1: {
createdAt: '2021-01-01', createdAt: '2021-01-01',
deletedAt: undefined, deletedAt: undefined,
email: 'immich@test.com', email: 'immich@test.com',
firstName: 'immich_first_name', firstName: 'immich_first_name',
id: 'immich_id', id: 'user-id',
isAdmin: false, isAdmin: false,
lastName: 'immich_last_name', lastName: 'immich_last_name',
oauthId: '', oauthId: '',
profileImagePath: '', profileImagePath: '',
shouldChangePassword: false, shouldChangePassword: false,
updatedAt: '2021-01-01', updatedAt: '2021-01-01',
storageLabel: null,
}, },
}; };

View File

@ -4,7 +4,7 @@ import handlebar from 'handlebars';
import * as luxon from 'luxon'; import * as luxon from 'luxon';
import path from 'node:path'; import path from 'node:path';
import sanitize from 'sanitize-filename'; import sanitize from 'sanitize-filename';
import { IStorageRepository, StorageCore, StorageFolder } from '../storage'; import { IStorageRepository, StorageCore } from '../storage';
import { import {
ISystemConfigRepository, ISystemConfigRepository,
supportedDayTokens, supportedDayTokens,
@ -15,6 +15,7 @@ import {
supportedYearTokens, supportedYearTokens,
} from '../system-config'; } from '../system-config';
import { SystemConfigCore } from '../system-config/system-config.core'; import { SystemConfigCore } from '../system-config/system-config.core';
import { MoveAssetMetadata } from './storage-template.service';
export class StorageTemplateCore { export class StorageTemplateCore {
private logger = new Logger(StorageTemplateCore.name); private logger = new Logger(StorageTemplateCore.name);
@ -33,12 +34,14 @@ export class StorageTemplateCore {
this.configCore.config$.subscribe((config) => this.onConfig(config)); this.configCore.config$.subscribe((config) => this.onConfig(config));
} }
public async getTemplatePath(asset: AssetEntity, filename: string): Promise<string> { public async getTemplatePath(asset: AssetEntity, metadata: MoveAssetMetadata): Promise<string> {
const { storageLabel, filename } = metadata;
try { try {
const source = asset.originalPath; const source = asset.originalPath;
const ext = path.extname(source).split('.').pop() as string; const ext = path.extname(source).split('.').pop() as string;
const sanitized = sanitize(path.basename(filename, `.${ext}`)); 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 storagePath = this.render(this.storageTemplate, asset, sanitized, ext);
const fullPath = path.normalize(path.join(rootPath, storagePath)); const fullPath = path.normalize(path.join(rootPath, storagePath));
let destination = `${fullPath}.${ext}`; let destination = `${fullPath}.${ext}`;

View File

@ -4,18 +4,22 @@ import {
newAssetRepositoryMock, newAssetRepositoryMock,
newStorageRepositoryMock, newStorageRepositoryMock,
newSystemConfigRepositoryMock, newSystemConfigRepositoryMock,
newUserRepositoryMock,
systemConfigStub, systemConfigStub,
userEntityStub,
} from '../../test'; } from '../../test';
import { IAssetRepository } from '../asset'; import { IAssetRepository } from '../asset';
import { StorageTemplateService } from '../storage-template'; import { StorageTemplateService } from '../storage-template';
import { IStorageRepository } from '../storage/storage.repository'; import { IStorageRepository } from '../storage/storage.repository';
import { ISystemConfigRepository } from '../system-config'; import { ISystemConfigRepository } from '../system-config';
import { IUserRepository } from '../user';
describe(StorageTemplateService.name, () => { describe(StorageTemplateService.name, () => {
let sut: StorageTemplateService; let sut: StorageTemplateService;
let assetMock: jest.Mocked<IAssetRepository>; let assetMock: jest.Mocked<IAssetRepository>;
let configMock: jest.Mocked<ISystemConfigRepository>; let configMock: jest.Mocked<ISystemConfigRepository>;
let storageMock: jest.Mocked<IStorageRepository>; let storageMock: jest.Mocked<IStorageRepository>;
let userMock: jest.Mocked<IUserRepository>;
it('should work', () => { it('should work', () => {
expect(sut).toBeDefined(); expect(sut).toBeDefined();
@ -25,12 +29,15 @@ describe(StorageTemplateService.name, () => {
assetMock = newAssetRepositoryMock(); assetMock = newAssetRepositoryMock();
configMock = newSystemConfigRepositoryMock(); configMock = newSystemConfigRepositoryMock();
storageMock = newStorageRepositoryMock(); 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', () => { describe('handle template migration', () => {
it('should handle no assets', async () => { it('should handle no assets', async () => {
assetMock.getAll.mockResolvedValue([]); assetMock.getAll.mockResolvedValue([]);
userMock.getList.mockResolvedValue([]);
await sut.handleTemplateMigration(); await sut.handleTemplateMigration();
@ -40,6 +47,7 @@ describe(StorageTemplateService.name, () => {
it('should handle an asset with a duplicate destination', async () => { it('should handle an asset with a duplicate destination', async () => {
assetMock.getAll.mockResolvedValue([assetEntityStub.image]); assetMock.getAll.mockResolvedValue([assetEntityStub.image]);
assetMock.save.mockResolvedValue(assetEntityStub.image); assetMock.save.mockResolvedValue(assetEntityStub.image);
userMock.getList.mockResolvedValue([userEntityStub.user1]);
when(storageMock.checkFileExists) when(storageMock.checkFileExists)
.calledWith('upload/library/user-id/2023/2023-02-23/asset-id.ext') .calledWith('upload/library/user-id/2023/2023-02-23/asset-id.ext')
@ -57,6 +65,7 @@ describe(StorageTemplateService.name, () => {
id: assetEntityStub.image.id, id: assetEntityStub.image.id,
originalPath: 'upload/library/user-id/2023/2023-02-23/asset-id+1.ext', 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 () => { 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', originalPath: 'upload/library/user-id/2023/2023-02-23/asset-id.ext',
}, },
]); ]);
userMock.getList.mockResolvedValue([userEntityStub.user1]);
await sut.handleTemplateMigration(); await sut.handleTemplateMigration();
@ -82,6 +92,7 @@ describe(StorageTemplateService.name, () => {
originalPath: 'upload/library/user-id/2023/2023-02-23/asset-id+1.ext', originalPath: 'upload/library/user-id/2023/2023-02-23/asset-id+1.ext',
}, },
]); ]);
userMock.getList.mockResolvedValue([userEntityStub.user1]);
await sut.handleTemplateMigration(); await sut.handleTemplateMigration();
@ -94,6 +105,7 @@ describe(StorageTemplateService.name, () => {
it('should move an asset', async () => { it('should move an asset', async () => {
assetMock.getAll.mockResolvedValue([assetEntityStub.image]); assetMock.getAll.mockResolvedValue([assetEntityStub.image]);
assetMock.save.mockResolvedValue(assetEntityStub.image); assetMock.save.mockResolvedValue(assetEntityStub.image);
userMock.getList.mockResolvedValue([userEntityStub.user1]);
await sut.handleTemplateMigration(); 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 () => { it('should not update the database if the move fails', async () => {
assetMock.getAll.mockResolvedValue([assetEntityStub.image]); assetMock.getAll.mockResolvedValue([assetEntityStub.image]);
storageMock.moveFile.mockRejectedValue(new Error('Read only system')); storageMock.moveFile.mockRejectedValue(new Error('Read only system'));
userMock.getList.mockResolvedValue([userEntityStub.user1]);
await sut.handleTemplateMigration(); await sut.handleTemplateMigration();
@ -125,6 +156,7 @@ describe(StorageTemplateService.name, () => {
it('should move the asset back if the database fails', async () => { it('should move the asset back if the database fails', async () => {
assetMock.getAll.mockResolvedValue([assetEntityStub.image]); assetMock.getAll.mockResolvedValue([assetEntityStub.image]);
assetMock.save.mockRejectedValue('Connection Error!'); assetMock.save.mockRejectedValue('Connection Error!');
userMock.getList.mockResolvedValue([userEntityStub.user1]);
await sut.handleTemplateMigration(); await sut.handleTemplateMigration();
@ -143,6 +175,7 @@ describe(StorageTemplateService.name, () => {
it('should handle an error', async () => { it('should handle an error', async () => {
assetMock.getAll.mockResolvedValue([]); assetMock.getAll.mockResolvedValue([]);
storageMock.removeEmptyDirs.mockRejectedValue(new Error('Read only filesystem')); storageMock.removeEmptyDirs.mockRejectedValue(new Error('Read only filesystem'));
userMock.getList.mockResolvedValue([]);
await sut.handleTemplateMigration(); await sut.handleTemplateMigration();
}); });

View File

@ -6,8 +6,14 @@ import { getLivePhotoMotionFilename } from '../domain.util';
import { IAssetJob } from '../job'; import { IAssetJob } from '../job';
import { IStorageRepository } from '../storage/storage.repository'; import { IStorageRepository } from '../storage/storage.repository';
import { INITIAL_SYSTEM_CONFIG, ISystemConfigRepository } from '../system-config'; import { INITIAL_SYSTEM_CONFIG, ISystemConfigRepository } from '../system-config';
import { IUserRepository } from '../user/user.repository';
import { StorageTemplateCore } from './storage-template.core'; import { StorageTemplateCore } from './storage-template.core';
export interface MoveAssetMetadata {
storageLabel: string | null;
filename: string;
}
@Injectable() @Injectable()
export class StorageTemplateService { export class StorageTemplateService {
private logger = new Logger(StorageTemplateService.name); private logger = new Logger(StorageTemplateService.name);
@ -18,6 +24,7 @@ export class StorageTemplateService {
@Inject(ISystemConfigRepository) configRepository: ISystemConfigRepository, @Inject(ISystemConfigRepository) configRepository: ISystemConfigRepository,
@Inject(INITIAL_SYSTEM_CONFIG) config: SystemConfig, @Inject(INITIAL_SYSTEM_CONFIG) config: SystemConfig,
@Inject(IStorageRepository) private storageRepository: IStorageRepository, @Inject(IStorageRepository) private storageRepository: IStorageRepository,
@Inject(IUserRepository) private userRepository: IUserRepository,
) { ) {
this.core = new StorageTemplateCore(configRepository, config, storageRepository); this.core = new StorageTemplateCore(configRepository, config, storageRepository);
} }
@ -26,14 +33,16 @@ export class StorageTemplateService {
const { asset } = data; const { asset } = data;
try { try {
const user = await this.userRepository.get(asset.ownerId);
const storageLabel = user?.storageLabel || null;
const filename = asset.originalFileName || asset.id; const filename = asset.originalFileName || asset.id;
await this.moveAsset(asset, filename); await this.moveAsset(asset, { storageLabel, filename });
// move motion part of live photo // move motion part of live photo
if (asset.livePhotoVideoId) { if (asset.livePhotoVideoId) {
const [livePhotoVideo] = await this.assetRepository.getByIds([asset.livePhotoVideoId]); const [livePhotoVideo] = await this.assetRepository.getByIds([asset.livePhotoVideoId]);
const motionFilename = getLivePhotoMotionFilename(filename, livePhotoVideo.originalPath); const motionFilename = getLivePhotoMotionFilename(filename, livePhotoVideo.originalPath);
await this.moveAsset(livePhotoVideo, motionFilename); await this.moveAsset(livePhotoVideo, { storageLabel, filename: motionFilename });
} }
} catch (error: any) { } catch (error: any) {
this.logger.error('Error running single template migration', error); this.logger.error('Error running single template migration', error);
@ -44,6 +53,7 @@ export class StorageTemplateService {
try { try {
console.time('migrating-time'); console.time('migrating-time');
const assets = await this.assetRepository.getAll(); const assets = await this.assetRepository.getAll();
const users = await this.userRepository.getList();
const livePhotoMap: Record<string, AssetEntity> = {}; const livePhotoMap: Record<string, AssetEntity> = {};
@ -56,8 +66,10 @@ export class StorageTemplateService {
for (const asset of assets) { for (const asset of assets) {
const livePhotoParentAsset = livePhotoMap[asset.id]; const livePhotoParentAsset = livePhotoMap[asset.id];
// TODO: remove livePhoto specific stuff once upload is fixed // 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; 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...'); this.logger.debug('Cleaning up empty directories...');
@ -70,8 +82,8 @@ export class StorageTemplateService {
} }
// TODO: use asset core (once in domain) // TODO: use asset core (once in domain)
async moveAsset(asset: AssetEntity, originalName: string) { async moveAsset(asset: AssetEntity, metadata: MoveAssetMetadata) {
const destination = await this.core.getTemplatePath(asset, originalName); const destination = await this.core.getTemplatePath(asset, metadata);
if (asset.originalPath !== destination) { if (asset.originalPath !== destination) {
const source = asset.originalPath; const source = asset.originalPath;

View File

@ -10,7 +10,14 @@ export enum StorageFolder {
} }
export class StorageCore { 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); 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);
}
} }

View File

@ -1,24 +1,28 @@
import { ApiProperty } from '@nestjs/swagger';
import { Transform } from 'class-transformer'; 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 { export class CreateUserDto {
@IsEmail() @IsEmail()
@Transform(({ value }) => value?.toLowerCase()) @Transform(toEmail)
@ApiProperty({ example: 'testuser@email.com' })
email!: string; email!: string;
@IsNotEmpty() @IsNotEmpty()
@ApiProperty({ example: 'password' }) @IsString()
password!: string; password!: string;
@IsNotEmpty() @IsNotEmpty()
@ApiProperty({ example: 'John' }) @IsString()
firstName!: string; firstName!: string;
@IsNotEmpty() @IsNotEmpty()
@ApiProperty({ example: 'Doe' }) @IsString()
lastName!: string; lastName!: string;
@IsOptional()
@IsString()
@Transform(toSanitized)
storageLabel?: string | null;
} }
export class CreateAdminDto { export class CreateAdminDto {

View File

@ -1,8 +1,34 @@
import { IsBoolean, IsNotEmpty, IsOptional, IsUUID } from 'class-validator'; import { ApiProperty } from '@nestjs/swagger';
import { CreateUserDto } from './create-user.dto'; import { Transform } from 'class-transformer';
import { ApiProperty, PartialType } from '@nestjs/swagger'; 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() @IsNotEmpty()
@IsUUID('4') @IsUUID('4')
@ApiProperty({ format: 'uuid' }) @ApiProperty({ format: 'uuid' })

View File

@ -5,6 +5,7 @@ export class UserResponseDto {
email!: string; email!: string;
firstName!: string; firstName!: string;
lastName!: string; lastName!: string;
storageLabel!: string | null;
createdAt!: string; createdAt!: string;
profileImagePath!: string; profileImagePath!: string;
shouldChangePassword!: boolean; shouldChangePassword!: boolean;
@ -20,6 +21,7 @@ export function mapUser(entity: UserEntity): UserResponseDto {
email: entity.email, email: entity.email,
firstName: entity.firstName, firstName: entity.firstName,
lastName: entity.lastName, lastName: entity.lastName,
storageLabel: entity.storageLabel,
createdAt: entity.createdAt, createdAt: entity.createdAt,
profileImagePath: entity.profileImagePath, profileImagePath: entity.profileImagePath,
shouldChangePassword: entity.shouldChangePassword, shouldChangePassword: entity.shouldChangePassword,

View File

@ -5,7 +5,6 @@ import {
InternalServerErrorException, InternalServerErrorException,
Logger, Logger,
NotFoundException, NotFoundException,
UnauthorizedException,
} from '@nestjs/common'; } from '@nestjs/common';
import { hash } from 'bcrypt'; import { hash } from 'bcrypt';
import { constants, createReadStream, ReadStream } from 'fs'; import { constants, createReadStream, ReadStream } from 'fs';
@ -28,6 +27,7 @@ export class UserCore {
if (!authUser.isAdmin) { if (!authUser.isAdmin) {
// Users can never update the isAdmin property. // Users can never update the isAdmin property.
delete dto.isAdmin; delete dto.isAdmin;
delete dto.storageLabel;
} else if (dto.isAdmin && authUser.id !== id) { } else if (dto.isAdmin && authUser.id !== id) {
// Admin cannot create another admin. // Admin cannot create another admin.
throw new BadRequestException('The server already has an admin'); throw new BadRequestException('The server already has an admin');
@ -36,7 +36,14 @@ export class UserCore {
if (dto.email) { if (dto.email) {
const duplicate = await this.userRepository.getByEmail(dto.email); const duplicate = await this.userRepository.getByEmail(dto.email);
if (duplicate && duplicate.id !== id) { 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); dto.password = await this.cryptoRepository.hashBcrypt(dto.password, SALT_ROUNDS);
} }
if (dto.storageLabel === '') {
dto.storageLabel = null;
}
return this.userRepository.update(id, dto); return this.userRepository.update(id, dto);
} catch (e) { } catch (e) {
Logger.error(e, 'Failed to update user info'); Logger.error(e, 'Failed to update user info');
@ -106,14 +117,8 @@ export class UserCore {
} }
async createProfileImage(authUser: AuthUserDto, filePath: string): Promise<UserEntity> { async createProfileImage(authUser: AuthUserDto, filePath: string): Promise<UserEntity> {
// 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 { try {
return this.userRepository.update(user.id, { profileImagePath: filePath }); return this.userRepository.update(authUser.id, { profileImagePath: filePath });
} catch (e) { } catch (e) {
Logger.error(e, 'Create User Profile Image'); Logger.error(e, 'Create User Profile Image');
throw new InternalServerErrorException('Failed to create new 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<UserEntity> { async restoreUser(authUser: AuthUserDto, userToRestore: UserEntity): Promise<UserEntity> {
// TODO: do we need to do this? Maybe we can trust the authUser if (!authUser.isAdmin) {
const requestor = await this.userRepository.get(authUser.id);
if (!requestor) {
throw new UnauthorizedException('Requestor not found');
}
if (!requestor.isAdmin) {
throw new ForbiddenException('Unauthorized'); throw new ForbiddenException('Unauthorized');
} }
try { try {
@ -138,12 +138,7 @@ export class UserCore {
} }
async deleteUser(authUser: AuthUserDto, userToDelete: UserEntity): Promise<UserEntity> { async deleteUser(authUser: AuthUserDto, userToDelete: UserEntity): Promise<UserEntity> {
// TODO: do we need to do this? Maybe we can trust the authUser if (!authUser.isAdmin) {
const requestor = await this.userRepository.get(authUser.id);
if (!requestor) {
throw new UnauthorizedException('Requestor not found');
}
if (!requestor.isAdmin) {
throw new ForbiddenException('Unauthorized'); throw new ForbiddenException('Unauthorized');
} }

View File

@ -1,7 +1,7 @@
import { UserEntity } from '@app/infra/entities'; import { UserEntity } from '@app/infra/entities';
export interface UserListFilter { export interface UserListFilter {
excludeId?: string; withDeleted?: boolean;
} }
export interface UserStatsQueryResponse { export interface UserStatsQueryResponse {
@ -19,6 +19,7 @@ export interface IUserRepository {
get(id: string, withDeleted?: boolean): Promise<UserEntity | null>; get(id: string, withDeleted?: boolean): Promise<UserEntity | null>;
getAdmin(): Promise<UserEntity | null>; getAdmin(): Promise<UserEntity | null>;
getByEmail(email: string, withPassword?: boolean): Promise<UserEntity | null>; getByEmail(email: string, withPassword?: boolean): Promise<UserEntity | null>;
getByStorageLabel(storageLabel: string): Promise<UserEntity | null>;
getByOAuthId(oauthId: string): Promise<UserEntity | null>; getByOAuthId(oauthId: string): Promise<UserEntity | null>;
getDeletedUsers(): Promise<UserEntity[]>; getDeletedUsers(): Promise<UserEntity[]>;
getList(filter?: UserListFilter): Promise<UserEntity[]>; getList(filter?: UserListFilter): Promise<UserEntity[]>;

View File

@ -36,7 +36,7 @@ const adminUserAuth: AuthUserDto = Object.freeze({
}); });
const immichUserAuth: AuthUserDto = Object.freeze({ const immichUserAuth: AuthUserDto = Object.freeze({
id: 'immich_id', id: 'user-id',
email: 'immich@test.com', email: 'immich@test.com',
isAdmin: false, isAdmin: false,
}); });
@ -55,6 +55,7 @@ const adminUser: UserEntity = Object.freeze({
updatedAt: '2021-01-01', updatedAt: '2021-01-01',
tags: [], tags: [],
assets: [], assets: [],
storageLabel: 'admin',
}); });
const immichUser: UserEntity = Object.freeze({ const immichUser: UserEntity = Object.freeze({
@ -71,6 +72,7 @@ const immichUser: UserEntity = Object.freeze({
updatedAt: '2021-01-01', updatedAt: '2021-01-01',
tags: [], tags: [],
assets: [], assets: [],
storageLabel: null,
}); });
const updatedImmichUser: UserEntity = Object.freeze({ const updatedImmichUser: UserEntity = Object.freeze({
@ -87,6 +89,7 @@ const updatedImmichUser: UserEntity = Object.freeze({
updatedAt: '2021-01-01', updatedAt: '2021-01-01',
tags: [], tags: [],
assets: [], assets: [],
storageLabel: null,
}); });
const adminUserResponse = Object.freeze({ const adminUserResponse = Object.freeze({
@ -101,6 +104,7 @@ const adminUserResponse = Object.freeze({
profileImagePath: '', profileImagePath: '',
createdAt: '2021-01-01', createdAt: '2021-01-01',
updatedAt: '2021-01-01', updatedAt: '2021-01-01',
storageLabel: 'admin',
}); });
describe(UserService.name, () => { describe(UserService.name, () => {
@ -150,7 +154,7 @@ describe(UserService.name, () => {
const response = await sut.getAllUsers(adminUserAuth, false); const response = await sut.getAllUsers(adminUserAuth, false);
expect(userRepositoryMock.getList).toHaveBeenCalledWith({ excludeId: adminUser.id }); expect(userRepositoryMock.getList).toHaveBeenCalledWith({ withDeleted: true });
expect(response).toEqual([ expect(response).toEqual([
{ {
id: adminUserAuth.id, id: adminUserAuth.id,
@ -164,6 +168,7 @@ describe(UserService.name, () => {
profileImagePath: '', profileImagePath: '',
createdAt: '2021-01-01', createdAt: '2021-01-01',
updatedAt: '2021-01-01', updatedAt: '2021-01-01',
storageLabel: 'admin',
}, },
]); ]);
}); });
@ -231,6 +236,22 @@ describe(UserService.name, () => {
expect(updatedUser.shouldChangePassword).toEqual(true); 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 () => { it('user can only update its information', async () => {
when(userRepositoryMock.get) when(userRepositoryMock.get)
.calledWith('not_immich_auth_user_id', undefined) .calledWith('not_immich_auth_user_id', undefined)
@ -255,7 +276,7 @@ describe(UserService.name, () => {
await sut.updateUser(immichUser, dto); await sut.updateUser(immichUser, dto);
expect(userRepositoryMock.update).toHaveBeenCalledWith(immichUser.id, { expect(userRepositoryMock.update).toHaveBeenCalledWith(immichUser.id, {
id: 'immich_id', id: 'user-id',
email: 'updated@test.com', email: 'updated@test.com',
}); });
}); });
@ -271,6 +292,17 @@ describe(UserService.name, () => {
expect(userRepositoryMock.update).not.toHaveBeenCalled(); 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 () => { it('admin can update any user information', async () => {
const update: UpdateUserDto = { const update: UpdateUserDto = {
id: immichUser.id, id: immichUser.id,
@ -481,6 +513,16 @@ describe(UserService.name, () => {
expect(userRepositoryMock.delete).toHaveBeenCalledWith(user, true); 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 () => { it('should handle an error', async () => {
const user = { id: 'deleted-user', deletedAt: makeDeletedAt(10) } as UserEntity; const user = { id: 'deleted-user', deletedAt: makeDeletedAt(10) } as UserEntity;

View File

@ -44,13 +44,8 @@ export class UserService {
} }
async getAllUsers(authUser: AuthUserDto, isAll: boolean): Promise<UserResponseDto[]> { async getAllUsers(authUser: AuthUserDto, isAll: boolean): Promise<UserResponseDto[]> {
if (isAll) { const users = await this.userCore.getList({ withDeleted: !isAll });
const allUsers = await this.userCore.getList(); return users.map(mapUser);
return allUsers.map(mapUser);
}
const allUserExceptRequestedUser = await this.userCore.getList({ excludeId: authUser.id });
return allUserExceptRequestedUser.map(mapUser);
} }
async getUserById(userId: string, withDeleted = false): Promise<UserResponseDto> { async getUserById(userId: string, withDeleted = false): Promise<UserResponseDto> {
@ -165,7 +160,7 @@ export class UserService {
try { try {
const folders = [ const folders = [
this.storageCore.getFolderLocation(StorageFolder.LIBRARY, user.id), this.storageCore.getLibraryFolder(user),
this.storageCore.getFolderLocation(StorageFolder.UPLOAD, user.id), this.storageCore.getFolderLocation(StorageFolder.UPLOAD, user.id),
this.storageCore.getFolderLocation(StorageFolder.PROFILE, user.id), this.storageCore.getFolderLocation(StorageFolder.PROFILE, user.id),
this.storageCore.getFolderLocation(StorageFolder.THUMBNAILS, user.id), this.storageCore.getFolderLocation(StorageFolder.THUMBNAILS, user.id),

View File

@ -43,7 +43,7 @@ export const authStub = {
isAllowUpload: true, isAllowUpload: true,
}), }),
user1: Object.freeze<AuthUserDto>({ user1: Object.freeze<AuthUserDto>({
id: 'immich_id', id: 'user-id',
email: 'immich@test.com', email: 'immich@test.com',
isAdmin: false, isAdmin: false,
isPublicUser: false, isPublicUser: false,
@ -81,6 +81,7 @@ export const userEntityStub = {
password: 'admin_password', password: 'admin_password',
firstName: 'admin_first_name', firstName: 'admin_first_name',
lastName: 'admin_last_name', lastName: 'admin_last_name',
storageLabel: 'admin',
oauthId: '', oauthId: '',
shouldChangePassword: false, shouldChangePassword: false,
profileImagePath: '', profileImagePath: '',
@ -94,6 +95,21 @@ export const userEntityStub = {
password: 'immich_password', password: 'immich_password',
firstName: 'immich_first_name', firstName: 'immich_first_name',
lastName: 'immich_last_name', lastName: 'immich_last_name',
storageLabel: null,
oauthId: '',
shouldChangePassword: false,
profileImagePath: '',
createdAt: '2021-01-01',
updatedAt: '2021-01-01',
tags: [],
assets: [],
}),
storageLabel: Object.freeze<UserEntity>({
...authStub.user1,
password: 'immich_password',
firstName: 'immich_first_name',
lastName: 'immich_last_name',
storageLabel: 'label-1',
oauthId: '', oauthId: '',
shouldChangePassword: false, shouldChangePassword: false,
profileImagePath: '', profileImagePath: '',
@ -536,7 +552,7 @@ export const loginResponseStub = {
user1oauth: { user1oauth: {
response: { response: {
accessToken: 'cmFuZG9tLWJ5dGVz', accessToken: 'cmFuZG9tLWJ5dGVz',
userId: 'immich_id', userId: 'user-id',
userEmail: 'immich@test.com', userEmail: 'immich@test.com',
firstName: 'immich_first_name', firstName: 'immich_first_name',
lastName: 'immich_last_name', lastName: 'immich_last_name',
@ -552,7 +568,7 @@ export const loginResponseStub = {
user1password: { user1password: {
response: { response: {
accessToken: 'cmFuZG9tLWJ5dGVz', accessToken: 'cmFuZG9tLWJ5dGVz',
userId: 'immich_id', userId: 'user-id',
userEmail: 'immich@test.com', userEmail: 'immich@test.com',
firstName: 'immich_first_name', firstName: 'immich_first_name',
lastName: 'immich_last_name', lastName: 'immich_last_name',
@ -568,7 +584,7 @@ export const loginResponseStub = {
user1insecure: { user1insecure: {
response: { response: {
accessToken: 'cmFuZG9tLWJ5dGVz', accessToken: 'cmFuZG9tLWJ5dGVz',
userId: 'immich_id', userId: 'user-id',
userEmail: 'immich@test.com', userEmail: 'immich@test.com',
firstName: 'immich_first_name', firstName: 'immich_first_name',
lastName: 'immich_last_name', lastName: 'immich_last_name',

View File

@ -5,6 +5,7 @@ export const newUserRepositoryMock = (): jest.Mocked<IUserRepository> => {
get: jest.fn(), get: jest.fn(),
getAdmin: jest.fn(), getAdmin: jest.fn(),
getByEmail: jest.fn(), getByEmail: jest.fn(),
getByStorageLabel: jest.fn(),
getByOAuthId: jest.fn(), getByOAuthId: jest.fn(),
getUserStats: jest.fn(), getUserStats: jest.fn(),
getList: jest.fn(), getList: jest.fn(),

View File

@ -27,6 +27,9 @@ export class UserEntity {
@Column({ unique: true }) @Column({ unique: true })
email!: string; email!: string;
@Column({ type: 'varchar', unique: true, default: null })
storageLabel!: string | null;
@Column({ default: '', select: false }) @Column({ default: '', select: false })
password?: string; password?: string;

View File

@ -0,0 +1,16 @@
import { MigrationInterface, QueryRunner } from "typeorm";
export class AddStorageLabel1684410565398 implements MigrationInterface {
name = 'AddStorageLabel1684410565398'
public async up(queryRunner: QueryRunner): Promise<void> {
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<void> {
await queryRunner.query(`ALTER TABLE "users" DROP CONSTRAINT "UQ_b309cf34fa58137c416b32cea3a"`);
await queryRunner.query(`ALTER TABLE "users" DROP COLUMN "storageLabel"`);
}
}

View File

@ -6,10 +6,7 @@ import { UserEntity } from '../entities';
@Injectable() @Injectable()
export class UserRepository implements IUserRepository { export class UserRepository implements IUserRepository {
constructor( constructor(@InjectRepository(UserEntity) private userRepository: Repository<UserEntity>) {}
@InjectRepository(UserEntity)
private userRepository: Repository<UserEntity>,
) {}
async get(userId: string, withDeleted?: boolean): Promise<UserEntity | null> { async get(userId: string, withDeleted?: boolean): Promise<UserEntity | null> {
return this.userRepository.findOne({ where: { id: userId }, withDeleted: withDeleted }); return this.userRepository.findOne({ where: { id: userId }, withDeleted: withDeleted });
@ -29,6 +26,10 @@ export class UserRepository implements IUserRepository {
return builder.getOne(); return builder.getOne();
} }
async getByStorageLabel(storageLabel: string): Promise<UserEntity | null> {
return this.userRepository.findOne({ where: { storageLabel } });
}
async getByOAuthId(oauthId: string): Promise<UserEntity | null> { async getByOAuthId(oauthId: string): Promise<UserEntity | null> {
return this.userRepository.findOne({ where: { oauthId } }); 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()) } }); return this.userRepository.find({ withDeleted: true, where: { deletedAt: Not(IsNull()) } });
} }
async getList({ excludeId }: UserListFilter = {}): Promise<UserEntity[]> { async getList({ withDeleted }: UserListFilter = {}): Promise<UserEntity[]> {
if (!excludeId) {
return this.userRepository.find(); // TODO: this should also be ordered the same as below
}
return this.userRepository.find({ return this.userRepository.find({
where: { id: Not(excludeId) }, withDeleted,
withDeleted: true,
order: { order: {
createdAt: 'DESC', createdAt: 'DESC',
}, },

View File

@ -910,6 +910,12 @@ export interface CreateUserDto {
* @memberof CreateUserDto * @memberof CreateUserDto
*/ */
'lastName': string; 'lastName': string;
/**
*
* @type {string}
* @memberof CreateUserDto
*/
'storageLabel'?: string | null;
} }
/** /**
* *
@ -2450,6 +2456,12 @@ export interface UpdateTagDto {
* @interface UpdateUserDto * @interface UpdateUserDto
*/ */
export interface UpdateUserDto { export interface UpdateUserDto {
/**
*
* @type {string}
* @memberof UpdateUserDto
*/
'id': string;
/** /**
* *
* @type {string} * @type {string}
@ -2479,7 +2491,7 @@ export interface UpdateUserDto {
* @type {string} * @type {string}
* @memberof UpdateUserDto * @memberof UpdateUserDto
*/ */
'id': string; 'storageLabel'?: string;
/** /**
* *
* @type {boolean} * @type {boolean}
@ -2579,6 +2591,12 @@ export interface UserResponseDto {
* @memberof UserResponseDto * @memberof UserResponseDto
*/ */
'lastName': string; 'lastName': string;
/**
*
* @type {string}
* @memberof UserResponseDto
*/
'storageLabel': string | null;
/** /**
* *
* @type {string} * @type {string}

View File

@ -2,15 +2,24 @@
import { api, UserResponseDto } from '@api'; import { api, UserResponseDto } from '@api';
import { createEventDispatcher } from 'svelte'; import { createEventDispatcher } from 'svelte';
import Button from '../elements/buttons/button.svelte'; import Button from '../elements/buttons/button.svelte';
import { handleError } from '../../utils/handle-error';
export let user: UserResponseDto; export let user: UserResponseDto;
const dispatch = createEventDispatcher(); const dispatch = createEventDispatcher();
const deleteUser = async () => { const deleteUser = async () => {
try {
const deletedUser = await api.userApi.deleteUser(user.id); const deletedUser = await api.userApi.deleteUser(user.id);
if (deletedUser.data.deletedAt != null) dispatch('user-delete-success'); if (deletedUser.data.deletedAt != null) {
else dispatch('user-delete-fail'); dispatch('user-delete-success');
} else {
dispatch('user-delete-fail');
}
} catch (error) {
handleError(error, 'Unable to delete user');
dispatch('user-delete-fail');
}
}; };
</script> </script>

View File

@ -171,14 +171,14 @@
</p> </p>
<p class="text-xs"> <p class="text-xs">
{user.id} is the user's ID <code>{user.storageLabel || user.id}</code> is the user's Storage Label
</p> </p>
<p <p
class="text-xs p-4 bg-gray-200 dark:bg-gray-700 dark:text-immich-dark-fg py-2 rounded-lg mt-2" class="text-xs p-4 bg-gray-200 dark:bg-gray-700 dark:text-immich-dark-fg py-2 rounded-lg mt-2"
> >
<span class="text-immich-fg/25 dark:text-immich-dark-fg/50" <span class="text-immich-fg/25 dark:text-immich-dark-fg/50"
>UPLOAD_LOCATION/{user.id}</span >UPLOAD_LOCATION/{user.storageLabel || user.id}</span
>/{parsedTemplate()}.jpg >/{parsedTemplate()}.jpg
</p> </p>

View File

@ -21,8 +21,8 @@
await getSharedLinks(); await getSharedLinks();
const { data } = await api.userApi.getAllUsers(false); const { data } = await api.userApi.getAllUsers(false);
// remove soft deleted users // remove invalid users
users = data.filter((user) => !user.deletedAt); users = data.filter((user) => !(user.deletedAt || user.id === album.ownerId));
// Remove the existed shared users from the album // Remove the existed shared users from the album
sharedUsersInAlbum.forEach((sharedUser) => { sharedUsersInAlbum.forEach((sharedUser) => {

View File

@ -7,8 +7,10 @@
NotificationType NotificationType
} from '../shared-components/notification/notification'; } from '../shared-components/notification/notification';
import Button from '../elements/buttons/button.svelte'; import Button from '../elements/buttons/button.svelte';
import { handleError } from '../../utils/handle-error';
export let user: UserResponseDto; export let user: UserResponseDto;
export let canResetPassword = true;
let error: string; let error: string;
let success: string; let success: string;
@ -17,18 +19,20 @@
const editUser = async () => { const editUser = async () => {
try { try {
const { id, email, firstName, lastName } = user; const { id, email, firstName, lastName, storageLabel } = user;
const { status } = await api.userApi.updateUser({ id, email, firstName, lastName }); const { status } = await api.userApi.updateUser({
id,
email,
firstName,
lastName,
storageLabel: storageLabel || ''
});
if (status === 200) { if (status === 200) {
dispatch('edit-success'); dispatch('edit-success');
} }
} catch (e) { } catch (error) {
console.error('Error updating user ', e); handleError(error, 'Unable to update user');
notificationController.show({
message: 'Error updating user, check console for more details',
type: NotificationType.Error
});
} }
}; };
@ -105,6 +109,24 @@
/> />
</div> </div>
<div class="m-4 flex flex-col gap-2">
<label class="immich-form-label" for="storage-label">Storage Label</label>
<input
class="immich-form-input"
id="storage-label"
name="storage-label"
type="text"
bind:value={user.storageLabel}
/>
<p>
Note: To apply the Storage Label to previously uploaded assets, run the <a
href="/admin/jobs-status"
class="text-immich-primary dark:text-immich-dark-primary">Storage Migration Job</a
>
</p>
</div>
{#if error} {#if error}
<p class="text-red-400 ml-4 text-sm">{error}</p> <p class="text-red-400 ml-4 text-sm">{error}</p>
{/if} {/if}
@ -113,7 +135,9 @@
<p class="text-immich-primary ml-4 text-sm">{success}</p> <p class="text-immich-primary ml-4 text-sm">{success}</p>
{/if} {/if}
<div class="flex w-full px-4 gap-4 mt-8"> <div class="flex w-full px-4 gap-4 mt-8">
{#if canResetPassword}
<Button color="light-red" fullwidth on:click={resetPassword}>Reset password</Button> <Button color="light-red" fullwidth on:click={resetPassword}>Reset password</Button>
{/if}
<Button type="submit" fullwidth>Confirm</Button> <Button type="submit" fullwidth>Confirm</Button>
</div> </div>
</form> </form>

View File

@ -6,6 +6,8 @@
import Button from '../elements/buttons/button.svelte'; import Button from '../elements/buttons/button.svelte';
import { createEventDispatcher, onMount } from 'svelte'; import { createEventDispatcher, onMount } from 'svelte';
export let user: UserResponseDto;
let availableUsers: UserResponseDto[] = []; let availableUsers: UserResponseDto[] = [];
let selectedUsers: UserResponseDto[] = []; let selectedUsers: UserResponseDto[] = [];
@ -15,8 +17,8 @@
// TODO: update endpoint to have a query param for deleted users // TODO: update endpoint to have a query param for deleted users
let { data: users } = await api.userApi.getAllUsers(false); let { data: users } = await api.userApi.getAllUsers(false);
// remove soft deleted users // remove invalid users
users = users.filter((user) => !user.deletedAt); users = users.filter((_user) => !(_user.deletedAt || _user.id === user.id));
// exclude partners from the list of users available for selection // exclude partners from the list of users available for selection
const { data: partners } = await api.partnerApi.getPartners('shared-by'); const { data: partners } = await api.partnerApi.getPartners('shared-by');

View File

@ -9,6 +9,8 @@
import ConfirmDialogue from '../shared-components/confirm-dialogue.svelte'; import ConfirmDialogue from '../shared-components/confirm-dialogue.svelte';
import CircleIconButton from '../elements/buttons/circle-icon-button.svelte'; import CircleIconButton from '../elements/buttons/circle-icon-button.svelte';
export let user: UserResponseDto;
let partners: UserResponseDto[] = []; let partners: UserResponseDto[] = [];
let createPartner = false; let createPartner = false;
let removePartner: UserResponseDto | null = null; let removePartner: UserResponseDto | null = null;
@ -83,6 +85,7 @@
{#if createPartner} {#if createPartner}
<PartnerSelectionModal <PartnerSelectionModal
{user}
on:close={() => (createPartner = false)} on:close={() => (createPartner = false)}
on:add-users={(event) => handleCreatePartners(event.detail)} on:add-users={(event) => handleCreatePartners(event.detail)}
/> />

View File

@ -65,6 +65,14 @@
required={true} required={true}
/> />
<SettingInputField
inputType={SettingInputFieldType.TEXT}
label="STORAGE LABEL"
disabled={true}
value={user.storageLabel || ''}
required={false}
/>
<div class="flex justify-end"> <div class="flex justify-end">
<Button type="submit" size="sm" on:click={() => handleSaveProfile()}>Save</Button> <Button type="submit" size="sm" on:click={() => handleSaveProfile()}>Save</Button>
</div> </div>

View File

@ -54,5 +54,5 @@
</SettingAccordion> </SettingAccordion>
<SettingAccordion title="Sharing" subtitle="Manage sharing with partners"> <SettingAccordion title="Sharing" subtitle="Manage sharing with partners">
<PartnerSettings /> <PartnerSettings {user} />
</SettingAccordion> </SettingAccordion>

View File

@ -13,6 +13,9 @@
import { page } from '$app/stores'; import { page } from '$app/stores';
import { locale } from '$lib/stores/preferences.store'; import { locale } from '$lib/stores/preferences.store';
import Button from '$lib/components/elements/buttons/button.svelte'; import Button from '$lib/components/elements/buttons/button.svelte';
import type { PageData } from './$types';
export let data: PageData;
let allUsers: UserResponseDto[] = []; let allUsers: UserResponseDto[] = [];
let shouldShowEditUserForm = false; let shouldShowEditUserForm = false;
@ -113,6 +116,7 @@
<FullScreenModal on:clickOutside={() => (shouldShowEditUserForm = false)}> <FullScreenModal on:clickOutside={() => (shouldShowEditUserForm = false)}>
<EditUserForm <EditUserForm
user={selectedUser} user={selectedUser}
canResetPassword={selectedUser?.id !== data.user.id}
on:edit-success={onEditUserSuccess} on:edit-success={onEditUserSuccess}
on:reset-password-success={onEditPasswordSuccess} on:reset-password-success={onEditPasswordSuccess}
/> />
@ -195,6 +199,7 @@
> >
<PencilOutline size="16" /> <PencilOutline size="16" />
</button> </button>
{#if user.id !== data.user.id}
<button <button
on:click={() => deleteUserHandler(user)} on:click={() => deleteUserHandler(user)}
class="bg-immich-primary dark:bg-immich-dark-primary text-gray-100 dark:text-gray-700 rounded-full p-3 transition-all duration-150 hover:bg-immich-primary/75" class="bg-immich-primary dark:bg-immich-dark-primary text-gray-100 dark:text-gray-700 rounded-full p-3 transition-all duration-150 hover:bg-immich-primary/75"
@ -202,6 +207,7 @@
<TrashCanOutline size="16" /> <TrashCanOutline size="16" />
</button> </button>
{/if} {/if}
{/if}
{#if isDeleted(user)} {#if isDeleted(user)}
<button <button
on:click={() => restoreUserHandler(user)} on:click={() => restoreUserHandler(user)}