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 452 additions and 137 deletions

View File

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

View File

@ -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]

View File

@ -12,6 +12,7 @@ Name | Type | Description | Notes
**email** | **String** | |
**firstName** | **String** | |
**lastName** | **String** | |
**storageLabel** | **String** | |
**createdAt** | **String** | |
**profileImagePath** | **String** | |
**shouldChangePassword** | **bool** | |

View File

@ -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<String, dynamic> toJson() {
final json = <String, dynamic>{};
@ -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<String>(json, r'password')!,
firstName: mapValueOfType<String>(json, r'firstName')!,
lastName: mapValueOfType<String>(json, r'lastName')!,
storageLabel: mapValueOfType<String>(json, r'storageLabel'),
);
}
return null;

View File

@ -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<String, dynamic> toJson() {
final json = <String, dynamic>{};
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<String>(json, r'id')!,
email: mapValueOfType<String>(json, r'email'),
password: mapValueOfType<String>(json, r'password'),
firstName: mapValueOfType<String>(json, r'firstName'),
lastName: mapValueOfType<String>(json, r'lastName'),
id: mapValueOfType<String>(json, r'id')!,
storageLabel: mapValueOfType<String>(json, r'storageLabel'),
isAdmin: mapValueOfType<bool>(json, r'isAdmin'),
shouldChangePassword: mapValueOfType<bool>(json, r'shouldChangePassword'),
);

View File

@ -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<String, dynamic> toJson() {
final json = <String, dynamic>{};
@ -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<String>(json, r'email')!,
firstName: mapValueOfType<String>(json, r'firstName')!,
lastName: mapValueOfType<String>(json, r'lastName')!,
storageLabel: mapValueOfType<String>(json, r'storageLabel'),
createdAt: mapValueOfType<String>(json, r'createdAt')!,
profileImagePath: mapValueOfType<String>(json, r'profileImagePath')!,
shouldChangePassword: mapValueOfType<bool>(json, r'shouldChangePassword')!,
@ -197,6 +208,7 @@ class UserResponseDto {
'email',
'firstName',
'lastName',
'storageLabel',
'createdAt',
'profileImagePath',
'shouldChangePassword',

View File

@ -36,6 +36,11 @@ void main() {
// TODO
});
// String storageLabel
test('to test the property `storageLabel`', () async {
// TODO
});
});

View File

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

View File

@ -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

View File

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

View File

@ -27,6 +27,7 @@ describe('TagService', () => {
tags: [],
assets: [],
oauthId: 'oauth-id-1',
storageLabel: null,
});
// 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') {
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, ''));

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');
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 () => {

View File

@ -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"
},

View File

@ -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',

View File

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

View File

@ -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,
},
};

View File

@ -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<string> {
public async getTemplatePath(asset: AssetEntity, metadata: MoveAssetMetadata): Promise<string> {
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}`;

View File

@ -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<IAssetRepository>;
let configMock: jest.Mocked<ISystemConfigRepository>;
let storageMock: jest.Mocked<IStorageRepository>;
let userMock: jest.Mocked<IUserRepository>;
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();
});

View File

@ -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<string, AssetEntity> = {};
@ -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;

View File

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

View File

@ -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 {

View File

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

View File

@ -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,

View File

@ -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<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 {
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<UserEntity> {
// 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<UserEntity> {
// 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');
}

View File

@ -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<UserEntity | null>;
getAdmin(): Promise<UserEntity | null>;
getByEmail(email: string, withPassword?: boolean): Promise<UserEntity | null>;
getByStorageLabel(storageLabel: string): Promise<UserEntity | null>;
getByOAuthId(oauthId: string): Promise<UserEntity | null>;
getDeletedUsers(): Promise<UserEntity[]>;
getList(filter?: UserListFilter): Promise<UserEntity[]>;

View File

@ -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;

View File

@ -44,13 +44,8 @@ export class UserService {
}
async getAllUsers(authUser: AuthUserDto, isAll: boolean): Promise<UserResponseDto[]> {
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<UserResponseDto> {
@ -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),

View File

@ -43,7 +43,7 @@ export const authStub = {
isAllowUpload: true,
}),
user1: Object.freeze<AuthUserDto>({
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<UserEntity>({
...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',

View File

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

View File

@ -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;

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

View File

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

View File

@ -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');
}
};
</script>

View File

@ -171,14 +171,14 @@
</p>
<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
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"
>UPLOAD_LOCATION/{user.id}</span
>UPLOAD_LOCATION/{user.storageLabel || user.id}</span
>/{parsedTemplate()}.jpg
</p>

View File

@ -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) => {

View File

@ -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 @@
/>
</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}
<p class="text-red-400 ml-4 text-sm">{error}</p>
{/if}
@ -113,7 +135,9 @@
<p class="text-immich-primary ml-4 text-sm">{success}</p>
{/if}
<div class="flex w-full px-4 gap-4 mt-8">
<Button color="light-red" fullwidth on:click={resetPassword}>Reset password</Button>
{#if canResetPassword}
<Button color="light-red" fullwidth on:click={resetPassword}>Reset password</Button>
{/if}
<Button type="submit" fullwidth>Confirm</Button>
</div>
</form>

View File

@ -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');

View File

@ -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}
<PartnerSelectionModal
{user}
on:close={() => (createPartner = false)}
on:add-users={(event) => handleCreatePartners(event.detail)}
/>

View File

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

View File

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

View File

@ -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 @@
<FullScreenModal on:clickOutside={() => (shouldShowEditUserForm = false)}>
<EditUserForm
user={selectedUser}
canResetPassword={selectedUser?.id !== data.user.id}
on:edit-success={onEditUserSuccess}
on:reset-password-success={onEditPasswordSuccess}
/>
@ -195,12 +199,14 @@
>
<PencilOutline size="16" />
</button>
<button
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"
>
<TrashCanOutline size="16" />
</button>
{#if user.id !== data.user.id}
<button
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"
>
<TrashCanOutline size="16" />
</button>
{/if}
{/if}
{#if isDeleted(user)}
<button