فهرست منبع

feat(server,web): Delete and restore user from the admin portal (#935)

* delete and restore user from admin UI

* addressed review comments and fix e2e test

* added cron job to delete user, and some formatting changes

* addressed review comments

* adding missing queue registration
Zeeshan Khan 2 سال پیش
والد
کامیت
fe4b307fe6
30فایلهای تغییر یافته به همراه803 افزوده شده و 58 حذف شده
  1. 2 0
      mobile/openapi/README.md
  2. 96 0
      mobile/openapi/doc/UserApi.md
  3. 1 0
      mobile/openapi/doc/UserResponseDto.md
  4. 96 0
      mobile/openapi/lib/api/user_api.dart
  5. 15 3
      mobile/openapi/lib/model/user_response_dto.dart
  6. 2 0
      server/apps/immich/src/api-v1/user/response-dto/user-response.dto.ts
  7. 19 5
      server/apps/immich/src/api-v1/user/user-repository.ts
  8. 15 0
      server/apps/immich/src/api-v1/user/user.controller.ts
  9. 2 0
      server/apps/immich/src/api-v1/user/user.service.spec.ts
  10. 46 2
      server/apps/immich/src/api-v1/user/user.service.ts
  11. 2 32
      server/apps/immich/src/modules/background-task/background-task.processor.ts
  12. 10 1
      server/apps/immich/src/modules/schedule-tasks/schedule-tasks.module.ts
  13. 20 0
      server/apps/immich/src/modules/schedule-tasks/schedule-tasks.service.ts
  14. 2 0
      server/apps/immich/test/user.e2e-spec.ts
  15. 35 0
      server/apps/microservices/src/processors/user-deletion.processor.ts
  16. 0 0
      server/immich-openapi-specs.json
  17. 39 0
      server/libs/common/src/utils/asset-utils.ts
  18. 2 0
      server/libs/common/src/utils/index.ts
  19. 19 0
      server/libs/common/src/utils/user-utils.spec.ts
  20. 16 0
      server/libs/common/src/utils/user-utils.ts
  21. 4 1
      server/libs/database/src/entities/user.entity.ts
  22. 13 0
      server/libs/database/src/migrations/1667762360744-AddingDeletedAtColumnInUserEntity.ts
  23. 5 0
      server/libs/job/src/constants/job-name.constant.ts
  24. 1 0
      server/libs/job/src/constants/queue-name.constant.ts
  25. 8 0
      server/libs/job/src/interfaces/user-deletion.interface.ts
  26. 140 0
      web/src/api/open-api/api.ts
  27. 41 0
      web/src/lib/components/admin-page/delete-confirm-dialoge.svelte
  28. 40 0
      web/src/lib/components/admin-page/restore-dialoge.svelte
  29. 46 11
      web/src/lib/components/admin-page/user-management.svelte
  30. 66 3
      web/src/routes/admin/+page.svelte

+ 2 - 0
mobile/openapi/README.md

@@ -108,11 +108,13 @@ Class | Method | HTTP request | Description
 *ServerInfoApi* | [**pingServer**](doc//ServerInfoApi.md#pingserver) | **GET** /server-info/ping | 
 *UserApi* | [**createProfileImage**](doc//UserApi.md#createprofileimage) | **POST** /user/profile-image | 
 *UserApi* | [**createUser**](doc//UserApi.md#createuser) | **POST** /user | 
+*UserApi* | [**deleteUser**](doc//UserApi.md#deleteuser) | **DELETE** /user/{userId} | 
 *UserApi* | [**getAllUsers**](doc//UserApi.md#getallusers) | **GET** /user | 
 *UserApi* | [**getMyUserInfo**](doc//UserApi.md#getmyuserinfo) | **GET** /user/me | 
 *UserApi* | [**getProfileImage**](doc//UserApi.md#getprofileimage) | **GET** /user/profile-image/{userId} | 
 *UserApi* | [**getUserById**](doc//UserApi.md#getuserbyid) | **GET** /user/info/{userId} | 
 *UserApi* | [**getUserCount**](doc//UserApi.md#getusercount) | **GET** /user/count | 
+*UserApi* | [**restoreUser**](doc//UserApi.md#restoreuser) | **POST** /user/{userId}/restore | 
 *UserApi* | [**updateUser**](doc//UserApi.md#updateuser) | **PUT** /user | 
 
 

+ 96 - 0
mobile/openapi/doc/UserApi.md

@@ -11,11 +11,13 @@ Method | HTTP request | Description
 ------------- | ------------- | -------------
 [**createProfileImage**](UserApi.md#createprofileimage) | **POST** /user/profile-image | 
 [**createUser**](UserApi.md#createuser) | **POST** /user | 
+[**deleteUser**](UserApi.md#deleteuser) | **DELETE** /user/{userId} | 
 [**getAllUsers**](UserApi.md#getallusers) | **GET** /user | 
 [**getMyUserInfo**](UserApi.md#getmyuserinfo) | **GET** /user/me | 
 [**getProfileImage**](UserApi.md#getprofileimage) | **GET** /user/profile-image/{userId} | 
 [**getUserById**](UserApi.md#getuserbyid) | **GET** /user/info/{userId} | 
 [**getUserCount**](UserApi.md#getusercount) | **GET** /user/count | 
+[**restoreUser**](UserApi.md#restoreuser) | **POST** /user/{userId}/restore | 
 [**updateUser**](UserApi.md#updateuser) | **PUT** /user | 
 
 
@@ -113,6 +115,53 @@ Name | Type | Description  | Notes
 
 [[Back to top]](#) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to Model list]](../README.md#documentation-for-models) [[Back to README]](../README.md)
 
+# **deleteUser**
+> UserResponseDto deleteUser(userId)
+
+
+
+### Example
+```dart
+import 'package:openapi/api.dart';
+// TODO Configure HTTP Bearer authorization: bearer
+// Case 1. Use String Token
+//defaultApiClient.getAuthentication<HttpBearerAuth>('bearer').setAccessToken('YOUR_ACCESS_TOKEN');
+// Case 2. Use Function which generate token.
+// String yourTokenGeneratorFunction() { ... }
+//defaultApiClient.getAuthentication<HttpBearerAuth>('bearer').setAccessToken(yourTokenGeneratorFunction);
+
+final api_instance = UserApi();
+final userId = userId_example; // String | 
+
+try {
+    final result = api_instance.deleteUser(userId);
+    print(result);
+} catch (e) {
+    print('Exception when calling UserApi->deleteUser: $e\n');
+}
+```
+
+### Parameters
+
+Name | Type | Description  | Notes
+------------- | ------------- | ------------- | -------------
+ **userId** | **String**|  | 
+
+### Return type
+
+[**UserResponseDto**](UserResponseDto.md)
+
+### Authorization
+
+[bearer](../README.md#bearer)
+
+### HTTP request headers
+
+ - **Content-Type**: Not defined
+ - **Accept**: application/json
+
+[[Back to top]](#) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to Model list]](../README.md#documentation-for-models) [[Back to README]](../README.md)
+
 # **getAllUsers**
 > List<UserResponseDto> getAllUsers(isAll)
 
@@ -322,6 +371,53 @@ No authorization required
 
 [[Back to top]](#) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to Model list]](../README.md#documentation-for-models) [[Back to README]](../README.md)
 
+# **restoreUser**
+> UserResponseDto restoreUser(userId)
+
+
+
+### Example
+```dart
+import 'package:openapi/api.dart';
+// TODO Configure HTTP Bearer authorization: bearer
+// Case 1. Use String Token
+//defaultApiClient.getAuthentication<HttpBearerAuth>('bearer').setAccessToken('YOUR_ACCESS_TOKEN');
+// Case 2. Use Function which generate token.
+// String yourTokenGeneratorFunction() { ... }
+//defaultApiClient.getAuthentication<HttpBearerAuth>('bearer').setAccessToken(yourTokenGeneratorFunction);
+
+final api_instance = UserApi();
+final userId = userId_example; // String | 
+
+try {
+    final result = api_instance.restoreUser(userId);
+    print(result);
+} catch (e) {
+    print('Exception when calling UserApi->restoreUser: $e\n');
+}
+```
+
+### Parameters
+
+Name | Type | Description  | Notes
+------------- | ------------- | ------------- | -------------
+ **userId** | **String**|  | 
+
+### Return type
+
+[**UserResponseDto**](UserResponseDto.md)
+
+### Authorization
+
+[bearer](../README.md#bearer)
+
+### HTTP request headers
+
+ - **Content-Type**: Not defined
+ - **Accept**: application/json
+
+[[Back to top]](#) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to Model list]](../README.md#documentation-for-models) [[Back to README]](../README.md)
+
 # **updateUser**
 > UserResponseDto updateUser(updateUserDto)
 

+ 1 - 0
mobile/openapi/doc/UserResponseDto.md

@@ -16,6 +16,7 @@ Name | Type | Description | Notes
 **profileImagePath** | **String** |  | 
 **shouldChangePassword** | **bool** |  | 
 **isAdmin** | **bool** |  | 
+**deletedAt** | [**DateTime**](DateTime.md) |  | 
 
 [[Back to Model list]](../README.md#documentation-for-models) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to README]](../README.md)
 

+ 96 - 0
mobile/openapi/lib/api/user_api.dart

@@ -120,6 +120,54 @@ class UserApi {
     return null;
   }
 
+  /// Performs an HTTP 'DELETE /user/{userId}' operation and returns the [Response].
+  /// Parameters:
+  ///
+  /// * [String] userId (required):
+  Future<Response> deleteUserWithHttpInfo(String userId,) async {
+    // ignore: prefer_const_declarations
+    final path = r'/user/{userId}'
+      .replaceAll('{userId}', userId);
+
+    // ignore: prefer_final_locals
+    Object? postBody;
+
+    final queryParams = <QueryParam>[];
+    final headerParams = <String, String>{};
+    final formParams = <String, String>{};
+
+    const contentTypes = <String>[];
+
+
+    return apiClient.invokeAPI(
+      path,
+      'DELETE',
+      queryParams,
+      postBody,
+      headerParams,
+      formParams,
+      contentTypes.isEmpty ? null : contentTypes.first,
+    );
+  }
+
+  /// Parameters:
+  ///
+  /// * [String] userId (required):
+  Future<UserResponseDto?> deleteUser(String userId,) async {
+    final response = await deleteUserWithHttpInfo(userId,);
+    if (response.statusCode >= HttpStatus.badRequest) {
+      throw ApiException(response.statusCode, await _decodeBodyBytes(response));
+    }
+    // When a remote server returns no body with a status of 204, we shall not decode it.
+    // At the time of writing this, `dart:convert` will throw an "Unexpected end of input"
+    // FormatException when trying to decode an empty string.
+    if (response.body.isNotEmpty && response.statusCode != HttpStatus.noContent) {
+      return await apiClient.deserializeAsync(await _decodeBodyBytes(response), 'UserResponseDto',) as UserResponseDto;
+    
+    }
+    return null;
+  }
+
   /// Performs an HTTP 'GET /user' operation and returns the [Response].
   /// Parameters:
   ///
@@ -350,6 +398,54 @@ class UserApi {
     return null;
   }
 
+  /// Performs an HTTP 'POST /user/{userId}/restore' operation and returns the [Response].
+  /// Parameters:
+  ///
+  /// * [String] userId (required):
+  Future<Response> restoreUserWithHttpInfo(String userId,) async {
+    // ignore: prefer_const_declarations
+    final path = r'/user/{userId}/restore'
+      .replaceAll('{userId}', userId);
+
+    // ignore: prefer_final_locals
+    Object? postBody;
+
+    final queryParams = <QueryParam>[];
+    final headerParams = <String, String>{};
+    final formParams = <String, String>{};
+
+    const contentTypes = <String>[];
+
+
+    return apiClient.invokeAPI(
+      path,
+      'POST',
+      queryParams,
+      postBody,
+      headerParams,
+      formParams,
+      contentTypes.isEmpty ? null : contentTypes.first,
+    );
+  }
+
+  /// Parameters:
+  ///
+  /// * [String] userId (required):
+  Future<UserResponseDto?> restoreUser(String userId,) async {
+    final response = await restoreUserWithHttpInfo(userId,);
+    if (response.statusCode >= HttpStatus.badRequest) {
+      throw ApiException(response.statusCode, await _decodeBodyBytes(response));
+    }
+    // When a remote server returns no body with a status of 204, we shall not decode it.
+    // At the time of writing this, `dart:convert` will throw an "Unexpected end of input"
+    // FormatException when trying to decode an empty string.
+    if (response.body.isNotEmpty && response.statusCode != HttpStatus.noContent) {
+      return await apiClient.deserializeAsync(await _decodeBodyBytes(response), 'UserResponseDto',) as UserResponseDto;
+    
+    }
+    return null;
+  }
+
   /// Performs an HTTP 'PUT /user' operation and returns the [Response].
   /// Parameters:
   ///

+ 15 - 3
mobile/openapi/lib/model/user_response_dto.dart

@@ -21,6 +21,7 @@ class UserResponseDto {
     required this.profileImagePath,
     required this.shouldChangePassword,
     required this.isAdmin,
+    required this.deletedAt,
   });
 
   String id;
@@ -39,6 +40,8 @@ class UserResponseDto {
 
   bool isAdmin;
 
+  DateTime? deletedAt;
+
   @override
   bool operator ==(Object other) => identical(this, other) || other is UserResponseDto &&
      other.id == id &&
@@ -48,7 +51,8 @@ class UserResponseDto {
      other.createdAt == createdAt &&
      other.profileImagePath == profileImagePath &&
      other.shouldChangePassword == shouldChangePassword &&
-     other.isAdmin == isAdmin;
+     other.isAdmin == isAdmin &&
+     other.deletedAt == deletedAt;
 
   @override
   int get hashCode =>
@@ -60,10 +64,11 @@ class UserResponseDto {
     (createdAt.hashCode) +
     (profileImagePath.hashCode) +
     (shouldChangePassword.hashCode) +
-    (isAdmin.hashCode);
+    (isAdmin.hashCode) +
+    (deletedAt == null ? 0 : deletedAt!.hashCode);
 
   @override
-  String toString() => 'UserResponseDto[id=$id, email=$email, firstName=$firstName, lastName=$lastName, createdAt=$createdAt, profileImagePath=$profileImagePath, shouldChangePassword=$shouldChangePassword, isAdmin=$isAdmin]';
+  String toString() => 'UserResponseDto[id=$id, email=$email, firstName=$firstName, lastName=$lastName, createdAt=$createdAt, profileImagePath=$profileImagePath, shouldChangePassword=$shouldChangePassword, isAdmin=$isAdmin, deletedAt=$deletedAt]';
 
   Map<String, dynamic> toJson() {
     final _json = <String, dynamic>{};
@@ -75,6 +80,11 @@ class UserResponseDto {
       _json[r'profileImagePath'] = profileImagePath;
       _json[r'shouldChangePassword'] = shouldChangePassword;
       _json[r'isAdmin'] = isAdmin;
+    if (deletedAt != null) {
+      _json[r'deletedAt'] = deletedAt!.toUtc().toIso8601String();
+    } else {
+      _json[r'deletedAt'] = null;
+    }
     return _json;
   }
 
@@ -105,6 +115,7 @@ class UserResponseDto {
         profileImagePath: mapValueOfType<String>(json, r'profileImagePath')!,
         shouldChangePassword: mapValueOfType<bool>(json, r'shouldChangePassword')!,
         isAdmin: mapValueOfType<bool>(json, r'isAdmin')!,
+        deletedAt: mapDateTime(json, r'deletedAt', ''),
       );
     }
     return null;
@@ -162,6 +173,7 @@ class UserResponseDto {
     'profileImagePath',
     'shouldChangePassword',
     'isAdmin',
+    'deletedAt',
   };
 }
 

+ 2 - 0
server/apps/immich/src/api-v1/user/response-dto/user-response.dto.ts

@@ -9,6 +9,7 @@ export class UserResponseDto {
   profileImagePath!: string;
   shouldChangePassword!: boolean;
   isAdmin!: boolean;
+  deletedAt!: Date | null;
 }
 
 export function mapUser(entity: UserEntity): UserResponseDto {
@@ -21,5 +22,6 @@ export function mapUser(entity: UserEntity): UserResponseDto {
     profileImagePath: entity.profileImagePath,
     shouldChangePassword: entity.shouldChangePassword,
     isAdmin: entity.isAdmin,
+    deletedAt: entity.deletedAt || null,
   };
 }

+ 19 - 5
server/apps/immich/src/api-v1/user/user-repository.ts

@@ -7,12 +7,14 @@ import * as bcrypt from 'bcrypt';
 import { UpdateUserDto } from './dto/update-user.dto';
 
 export interface IUserRepository {
-  get(userId: string): Promise<UserEntity | null>;
+  get(userId: string, withDeleted?: boolean): Promise<UserEntity | null>;
   getByEmail(email: string): Promise<UserEntity | null>;
   getList(filter?: { excludeId?: string }): Promise<UserEntity[]>;
   create(createUserDto: CreateUserDto): Promise<UserEntity>;
   update(user: UserEntity, updateUserDto: UpdateUserDto): Promise<UserEntity>;
   createProfileImage(user: UserEntity, fileInfo: Express.Multer.File): Promise<UserEntity>;
+  delete(user: UserEntity): Promise<UserEntity>;
+  restore(user: UserEntity): Promise<UserEntity>;
 }
 
 export const USER_REPOSITORY = 'USER_REPOSITORY';
@@ -27,8 +29,8 @@ export class UserRepository implements IUserRepository {
     return bcrypt.hash(password, salt);
   }
 
-  async get(userId: string): Promise<UserEntity | null> {
-    return this.userRepository.findOne({ where: { id: userId } });
+  async get(userId: string, withDeleted?: boolean): Promise<UserEntity | null> {
+    return this.userRepository.findOne({ where: { id: userId }, withDeleted: withDeleted });
   }
 
   async getByEmail(email: string): Promise<UserEntity | null> {
@@ -40,9 +42,10 @@ export class UserRepository implements IUserRepository {
     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: true,
       order: {
         createdAt: 'DESC',
       },
@@ -88,6 +91,17 @@ export class UserRepository implements IUserRepository {
     return this.userRepository.save(user);
   }
 
+  async delete(user: UserEntity): Promise<UserEntity> {
+    if (user.isAdmin) {
+      throw new BadRequestException('Cannot delete admin user! stay sane!');
+    }
+    return this.userRepository.softRemove(user);
+  }
+
+  async restore(user: UserEntity): Promise<UserEntity> {
+    return this.userRepository.recover(user);
+  }
+
   async createProfileImage(user: UserEntity, fileInfo: Express.Multer.File): Promise<UserEntity> {
     user.profileImagePath = fileInfo.path;
     return this.userRepository.save(user);

+ 15 - 0
server/apps/immich/src/api-v1/user/user.controller.ts

@@ -2,6 +2,7 @@ import {
   Controller,
   Get,
   Post,
+  Delete,
   Body,
   Param,
   ValidationPipe,
@@ -67,6 +68,20 @@ export class UserController {
     return await this.userService.getUserCount();
   }
 
+  @Authenticated({ admin: true })
+  @ApiBearerAuth()
+  @Delete('/:userId')
+  async deleteUser(@GetAuthUser() authUser: AuthUserDto, @Param('userId') userId: string): Promise<UserResponseDto> {
+    return await this.userService.deleteUser(authUser, userId);
+  }
+
+  @Authenticated({ admin: true })
+  @ApiBearerAuth()
+  @Post('/:userId/restore')
+  async restoreUser(@GetAuthUser() authUser: AuthUserDto, @Param('userId') userId: string): Promise<UserResponseDto> {
+    return await this.userService.restoreUser(authUser, userId);
+  }
+
   @Authenticated()
   @ApiBearerAuth()
   @Put()

+ 2 - 0
server/apps/immich/src/api-v1/user/user.service.spec.ts

@@ -65,6 +65,8 @@ describe('UserService', () => {
       getByEmail: jest.fn(),
       getList: jest.fn(),
       update: jest.fn(),
+      delete: jest.fn(),
+      restore: jest.fn(),
     };
 
     sui = new UserService(userRepositoryMock);

+ 46 - 2
server/apps/immich/src/api-v1/user/user.service.ts

@@ -1,11 +1,13 @@
 import {
   BadRequestException,
+  ForbiddenException,
   Inject,
   Injectable,
   InternalServerErrorException,
   Logger,
   NotFoundException,
   StreamableFile,
+  UnauthorizedException,
 } from '@nestjs/common';
 import { AuthUserDto } from '../../decorators/auth-user.decorator';
 import { CreateUserDto } from './dto/create-user.dto';
@@ -38,8 +40,8 @@ export class UserService {
     return allUserExceptRequestedUser.map(mapUser);
   }
 
-  async getUserById(userId: string): Promise<UserResponseDto> {
-    const user = await this.userRepository.get(userId);
+  async getUserById(userId: string, withDeleted = false): Promise<UserResponseDto> {
+    const user = await this.userRepository.get(userId, withDeleted);
     if (!user) {
       throw new NotFoundException('User not found');
     }
@@ -105,6 +107,48 @@ export class UserService {
     }
   }
 
+  async deleteUser(authUser: AuthUserDto, userId: string): Promise<UserResponseDto> {
+    const requestor = await this.userRepository.get(authUser.id);
+    if (!requestor) {
+      throw new UnauthorizedException('Requestor not found');
+    }
+    if (!requestor.isAdmin) {
+      throw new ForbiddenException('Unauthorized');
+    }
+    const user = await this.userRepository.get(userId);
+    if (!user) {
+      throw new BadRequestException('User not found');
+    }
+    try {
+      const deletedUser = await this.userRepository.delete(user);
+      return mapUser(deletedUser);
+    } catch (e) {
+      Logger.error(e, 'Failed to delete user');
+      throw new InternalServerErrorException('Failed to delete user');
+    }
+  }
+
+  async restoreUser(authUser: AuthUserDto, userId: string): Promise<UserResponseDto> {
+    const requestor = await this.userRepository.get(authUser.id);
+    if (!requestor) {
+      throw new UnauthorizedException('Requestor not found');
+    }
+    if (!requestor.isAdmin) {
+      throw new ForbiddenException('Unauthorized');
+    }
+    const user = await this.userRepository.get(userId, true);
+    if (!user) {
+      throw new BadRequestException('User not found');
+    }
+    try {
+      const restoredUser = await this.userRepository.restore(user);
+      return mapUser(restoredUser);
+    } catch (e) {
+      Logger.error(e, 'Failed to restore deleted user');
+      throw new InternalServerErrorException('Failed to restore deleted user');
+    }
+  }
+
   async createProfileImage(
     authUser: AuthUserDto,
     fileInfo: Express.Multer.File,

+ 2 - 32
server/apps/immich/src/modules/background-task/background-task.processor.ts

@@ -2,10 +2,10 @@ import { Process, Processor } from '@nestjs/bull';
 import { InjectRepository } from '@nestjs/typeorm';
 import { Repository } from 'typeorm';
 import { AssetEntity } from '@app/database/entities/asset.entity';
-import fs from 'fs';
 import { SmartInfoEntity } from '@app/database/entities/smart-info.entity';
 import { Job } from 'bull';
 import { AssetResponseDto } from '../../api-v1/asset/response-dto/asset-response.dto';
+import { assetUtils } from '@app/common/utils';
 
 @Processor('background-task')
 export class BackgroundTaskProcessor {
@@ -23,37 +23,7 @@ export class BackgroundTaskProcessor {
     const { assets } = job.data;
 
     for (const asset of assets) {
-      fs.unlink(asset.originalPath, (err) => {
-        if (err) {
-          console.log('error deleting ', asset.originalPath);
-        }
-      });
-
-      // TODO: what if there is no asset.resizePath. Should fail the Job?
-      // => panoti report: Job not fail
-      if (asset.resizePath) {
-        fs.unlink(asset.resizePath, (err) => {
-          if (err) {
-            console.log('error deleting ', asset.resizePath);
-          }
-        });
-      }
-
-      if (asset.webpPath) {
-        fs.unlink(asset.webpPath, (err) => {
-          if (err) {
-            console.log('error deleting ', asset.webpPath);
-          }
-        });
-      }
-
-      if (asset.encodedVideoPath) {
-        fs.unlink(asset.encodedVideoPath, (err) => {
-          if (err) {
-            console.log('error deleting ', asset.encodedVideoPath);
-          }
-        });
-      }
+      assetUtils.deleteFiles(asset);
     }
   }
 }

+ 10 - 1
server/apps/immich/src/modules/schedule-tasks/schedule-tasks.module.ts

@@ -5,10 +5,19 @@ import { AssetEntity } from '@app/database/entities/asset.entity';
 import { ScheduleTasksService } from './schedule-tasks.service';
 import { QueueNameEnum } from '@app/job/constants/queue-name.constant';
 import { ExifEntity } from '@app/database/entities/exif.entity';
+import { UserEntity } from '@app/database/entities/user.entity';
 
 @Module({
   imports: [
-    TypeOrmModule.forFeature([AssetEntity, ExifEntity]),
+    TypeOrmModule.forFeature([AssetEntity, ExifEntity, UserEntity]),
+    BullModule.registerQueue({
+      name: QueueNameEnum.USER_DELETION,
+      defaultJobOptions: {
+        attempts: 3,
+        removeOnComplete: true,
+        removeOnFail: false,
+      },
+    }),
     BullModule.registerQueue({
       name: QueueNameEnum.VIDEO_CONVERSION,
       defaultJobOptions: {

+ 20 - 0
server/apps/immich/src/modules/schedule-tasks/schedule-tasks.service.ts

@@ -8,6 +8,7 @@ import { Queue } from 'bull';
 import { randomUUID } from 'crypto';
 import { ExifEntity } from '@app/database/entities/exif.entity';
 import {
+  userDeletionProcessorName,
   exifExtractionProcessorName,
   generateWEBPThumbnailProcessorName,
   IMetadataExtractionJob,
@@ -18,10 +19,16 @@ import {
   videoMetadataExtractionProcessorName,
 } from '@app/job';
 import { ConfigService } from '@nestjs/config';
+import { UserEntity } from '@app/database/entities/user.entity';
+import { IUserDeletionJob } from '@app/job/interfaces/user-deletion.interface';
+import { userUtils } from '@app/common';
 
 @Injectable()
 export class ScheduleTasksService {
   constructor(
+    @InjectRepository(UserEntity)
+    private userRepository: Repository<UserEntity>,
+
     @InjectRepository(AssetEntity)
     private assetRepository: Repository<AssetEntity>,
 
@@ -37,6 +44,9 @@ export class ScheduleTasksService {
     @InjectQueue(QueueNameEnum.METADATA_EXTRACTION)
     private metadataExtractionQueue: Queue<IMetadataExtractionJob>,
 
+    @InjectQueue(QueueNameEnum.USER_DELETION)
+    private userDeletionQueue: Queue<IUserDeletionJob>,
+
     private configService: ConfigService,
   ) {}
 
@@ -128,4 +138,14 @@ export class ScheduleTasksService {
       }
     }
   }
+
+  @Cron(CronExpression.EVERY_DAY_AT_11PM)
+  async deleteUserAndRelatedAssets() {
+    const usersToDelete = await this.userRepository.find({ withDeleted: true, where: { deletedAt: Not(IsNull()) } });
+    for (const user of usersToDelete) {
+      if (userUtils.isReadyForDeletion(user)) {
+        await this.userDeletionQueue.add(userDeletionProcessorName, { user: user }, { jobId: randomUUID() });
+      }
+    }
+  }
 }

+ 2 - 0
server/apps/immich/test/user.e2e-spec.ts

@@ -104,6 +104,7 @@ describe('User', () => {
               isAdmin: false,
               shouldChangePassword: true,
               profileImagePath: '',
+              deletedAt: null,
             },
             {
               email: userTwoEmail,
@@ -114,6 +115,7 @@ describe('User', () => {
               isAdmin: false,
               shouldChangePassword: true,
               profileImagePath: '',
+              deletedAt: null,
             },
           ]),
         );

+ 35 - 0
server/apps/microservices/src/processors/user-deletion.processor.ts

@@ -0,0 +1,35 @@
+import { APP_UPLOAD_LOCATION, userUtils } from '@app/common';
+import { AssetEntity } from '@app/database/entities/asset.entity';
+import { UserEntity } from '@app/database/entities/user.entity';
+import { QueueNameEnum, userDeletionProcessorName } from '@app/job';
+import { IUserDeletionJob } from '@app/job/interfaces/user-deletion.interface';
+import { Process, Processor } from '@nestjs/bull';
+import { InjectRepository } from '@nestjs/typeorm';
+import { Job } from 'bull';
+import { join } from 'path';
+import fs from 'fs';
+import { Repository } from 'typeorm';
+
+@Processor(QueueNameEnum.USER_DELETION)
+export class UserDeletionProcessor {
+  constructor(
+    @InjectRepository(UserEntity)
+    private userRepository: Repository<UserEntity>,
+
+    @InjectRepository(AssetEntity)
+    private assetRepository: Repository<AssetEntity>,
+  ) {}
+
+  @Process(userDeletionProcessorName)
+  async processUserDeletion(job: Job<IUserDeletionJob>) {
+    const { user } = job.data;
+    // just for extra protection here
+    if (userUtils.isReadyForDeletion(user)) {
+      const basePath = APP_UPLOAD_LOCATION;
+      const userAssetDir = join(basePath, user.id)
+      fs.rmSync(userAssetDir, { recursive: true, force: true })
+      await this.assetRepository.delete({ userId: user.id })
+      await this.userRepository.remove(user);
+    }
+  }
+}

تفاوت فایلی نمایش داده نمی شود زیرا این فایل بسیار بزرگ است
+ 0 - 0
server/immich-openapi-specs.json


+ 39 - 0
server/libs/common/src/utils/asset-utils.ts

@@ -0,0 +1,39 @@
+import { AssetEntity } from '@app/database/entities/asset.entity';
+import { AssetResponseDto } from 'apps/immich/src/api-v1/asset/response-dto/asset-response.dto';
+import fs from 'fs';
+
+const deleteFiles = (asset: AssetEntity | AssetResponseDto) => {
+  fs.unlink(asset.originalPath, (err) => {
+    if (err) {
+      console.log('error deleting ', asset.originalPath);
+    }
+  });
+
+  // TODO: what if there is no asset.resizePath. Should fail the Job?
+  // => panoti report: Job not fail
+  if (asset.resizePath) {
+    fs.unlink(asset.resizePath, (err) => {
+      if (err) {
+        console.log('error deleting ', asset.resizePath);
+      }
+    });
+  }
+
+  if (asset.webpPath) {
+    fs.unlink(asset.webpPath, (err) => {
+      if (err) {
+        console.log('error deleting ', asset.webpPath);
+      }
+    });
+  }
+
+  if (asset.encodedVideoPath) {
+    fs.unlink(asset.encodedVideoPath, (err) => {
+      if (err) {
+        console.log('error deleting ', asset.encodedVideoPath);
+      }
+    });
+  }
+};
+
+export const assetUtils = { deleteFiles };

+ 2 - 0
server/libs/common/src/utils/index.ts

@@ -1 +1,3 @@
 export * from './time-utils';
+export * from './asset-utils';
+export * from './user-utils';

+ 19 - 0
server/libs/common/src/utils/user-utils.spec.ts

@@ -0,0 +1,19 @@
+// create unit test for user utils
+
+import { UserEntity } from '@app/database/entities/user.entity';
+import { userUtils } from './user-utils';
+
+describe('User Utilities', () => {
+  describe('checkIsReadyForDeletion', () => {
+    it('check that user is not ready to be deleted', () => {
+      const result = userUtils.isReadyForDeletion({ deletedAt: new Date() } as UserEntity);
+      expect(result).toBeFalsy();
+    });
+
+    it('check that user is ready to be deleted', () => {
+      const aWeekAgo = new Date(new Date().getTime() - 8 * 86400000);
+      const result = userUtils.isReadyForDeletion({ deletedAt: aWeekAgo } as UserEntity);
+      expect(result).toBeTruthy();
+    });
+  });
+});

+ 16 - 0
server/libs/common/src/utils/user-utils.ts

@@ -0,0 +1,16 @@
+import { UserEntity } from '@app/database/entities/user.entity';
+
+function createUserUtils() {
+  const isReadyForDeletion = (user: UserEntity): boolean => {
+    if (user.deletedAt == null) return false;
+    const millisecondsInDay = 86400000;
+    // get this number (7 days) from some configuration perhaps ?
+    const millisecondsDeleteWait = millisecondsInDay * 7;
+
+    const millisecondsSinceDelete = new Date().getTime() - (user.deletedAt?.getTime() ?? 0);
+    return millisecondsSinceDelete >= millisecondsDeleteWait;
+  };
+  return { isReadyForDeletion };
+}
+
+export const userUtils = createUserUtils();

+ 4 - 1
server/libs/database/src/entities/user.entity.ts

@@ -1,4 +1,4 @@
-import { Column, CreateDateColumn, Entity, PrimaryGeneratedColumn } from 'typeorm';
+import { Column, CreateDateColumn, Entity, PrimaryGeneratedColumn, DeleteDateColumn } from 'typeorm';
 
 @Entity('users')
 export class UserEntity {
@@ -31,4 +31,7 @@ export class UserEntity {
 
   @CreateDateColumn()
   createdAt!: string;
+
+  @DeleteDateColumn()
+  deletedAt?: Date;
 }

+ 13 - 0
server/libs/database/src/migrations/1667762360744-AddingDeletedAtColumnInUserEntity.ts

@@ -0,0 +1,13 @@
+import { MigrationInterface, QueryRunner } from 'typeorm';
+
+export class AddingDeletedAtColumnInUserEntity1667762360744 implements MigrationInterface {
+  name = 'AddingDeletedAtColumnInUserEntity1667762360744';
+
+  public async up(queryRunner: QueryRunner): Promise<void> {
+    await queryRunner.query(`ALTER TABLE "users" ADD "deletedAt" TIMESTAMP`);
+  }
+
+  public async down(queryRunner: QueryRunner): Promise<void> {
+    await queryRunner.query(`ALTER TABLE "users" DROP COLUMN "deletedAt"`);
+  }
+}

+ 5 - 0
server/libs/job/src/constants/job-name.constant.ts

@@ -29,3 +29,8 @@ export enum MachineLearningJobNameEnum {
   OBJECT_DETECTION = 'detect-object',
   IMAGE_TAGGING = 'tag-image',
 }
+
+/**
+ * User deletion Queue Jobs
+ */
+export const userDeletionProcessorName = 'user-deletion';

+ 1 - 0
server/libs/job/src/constants/queue-name.constant.ts

@@ -5,4 +5,5 @@ export enum QueueNameEnum {
   CHECKSUM_GENERATION = 'generate-checksum-queue',
   ASSET_UPLOADED = 'asset-uploaded-queue',
   MACHINE_LEARNING = 'machine-learning-queue',
+  USER_DELETION = 'user-deletion-queue',
 }

+ 8 - 0
server/libs/job/src/interfaces/user-deletion.interface.ts

@@ -0,0 +1,8 @@
+import { UserEntity } from '@app/database/entities/user.entity';
+
+export interface IUserDeletionJob {
+  /**
+   * The user entity that was saved in the database
+   */
+  user: UserEntity;
+}

+ 140 - 0
web/src/api/open-api/api.ts

@@ -1575,6 +1575,12 @@ export interface UserResponseDto {
      * @memberof UserResponseDto
      */
     'isAdmin': boolean;
+    /**
+     * 
+     * @type {string}
+     * @memberof UserResponseDto
+     */
+    'deletedAt': string | null;
 }
 /**
  * 
@@ -4711,6 +4717,43 @@ export const UserApiAxiosParamCreator = function (configuration?: Configuration)
                 options: localVarRequestOptions,
             };
         },
+        /**
+         * 
+         * @param {string} userId 
+         * @param {*} [options] Override http request option.
+         * @throws {RequiredError}
+         */
+        deleteUser: async (userId: string, options: AxiosRequestConfig = {}): Promise<RequestArgs> => {
+            // verify required parameter 'userId' is not null or undefined
+            assertParamExists('deleteUser', 'userId', userId)
+            const localVarPath = `/user/{userId}`
+                .replace(`{${"userId"}}`, encodeURIComponent(String(userId)));
+            // use dummy base URL string because the URL constructor only accepts absolute URLs.
+            const localVarUrlObj = new URL(localVarPath, DUMMY_BASE_URL);
+            let baseOptions;
+            if (configuration) {
+                baseOptions = configuration.baseOptions;
+            }
+
+            const localVarRequestOptions = { method: 'DELETE', ...baseOptions, ...options};
+            const localVarHeaderParameter = {} as any;
+            const localVarQueryParameter = {} as any;
+
+            // authentication bearer required
+            // http bearer authentication required
+            await setBearerAuthToObject(localVarHeaderParameter, configuration)
+
+
+    
+            setSearchParams(localVarUrlObj, localVarQueryParameter);
+            let headersFromBaseOptions = baseOptions && baseOptions.headers ? baseOptions.headers : {};
+            localVarRequestOptions.headers = {...localVarHeaderParameter, ...headersFromBaseOptions, ...options.headers};
+
+            return {
+                url: toPathString(localVarUrlObj),
+                options: localVarRequestOptions,
+            };
+        },
         /**
          * 
          * @param {boolean} isAll 
@@ -4870,6 +4913,43 @@ export const UserApiAxiosParamCreator = function (configuration?: Configuration)
 
 
     
+            setSearchParams(localVarUrlObj, localVarQueryParameter);
+            let headersFromBaseOptions = baseOptions && baseOptions.headers ? baseOptions.headers : {};
+            localVarRequestOptions.headers = {...localVarHeaderParameter, ...headersFromBaseOptions, ...options.headers};
+
+            return {
+                url: toPathString(localVarUrlObj),
+                options: localVarRequestOptions,
+            };
+        },
+        /**
+         * 
+         * @param {string} userId 
+         * @param {*} [options] Override http request option.
+         * @throws {RequiredError}
+         */
+        restoreUser: async (userId: string, options: AxiosRequestConfig = {}): Promise<RequestArgs> => {
+            // verify required parameter 'userId' is not null or undefined
+            assertParamExists('restoreUser', 'userId', userId)
+            const localVarPath = `/user/{userId}/restore`
+                .replace(`{${"userId"}}`, encodeURIComponent(String(userId)));
+            // use dummy base URL string because the URL constructor only accepts absolute URLs.
+            const localVarUrlObj = new URL(localVarPath, DUMMY_BASE_URL);
+            let baseOptions;
+            if (configuration) {
+                baseOptions = configuration.baseOptions;
+            }
+
+            const localVarRequestOptions = { method: 'POST', ...baseOptions, ...options};
+            const localVarHeaderParameter = {} as any;
+            const localVarQueryParameter = {} as any;
+
+            // authentication bearer required
+            // http bearer authentication required
+            await setBearerAuthToObject(localVarHeaderParameter, configuration)
+
+
+    
             setSearchParams(localVarUrlObj, localVarQueryParameter);
             let headersFromBaseOptions = baseOptions && baseOptions.headers ? baseOptions.headers : {};
             localVarRequestOptions.headers = {...localVarHeaderParameter, ...headersFromBaseOptions, ...options.headers};
@@ -4948,6 +5028,16 @@ export const UserApiFp = function(configuration?: Configuration) {
             const localVarAxiosArgs = await localVarAxiosParamCreator.createUser(createUserDto, options);
             return createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration);
         },
+        /**
+         * 
+         * @param {string} userId 
+         * @param {*} [options] Override http request option.
+         * @throws {RequiredError}
+         */
+        async deleteUser(userId: string, options?: AxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise<UserResponseDto>> {
+            const localVarAxiosArgs = await localVarAxiosParamCreator.deleteUser(userId, options);
+            return createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration);
+        },
         /**
          * 
          * @param {boolean} isAll 
@@ -4996,6 +5086,16 @@ export const UserApiFp = function(configuration?: Configuration) {
             const localVarAxiosArgs = await localVarAxiosParamCreator.getUserCount(options);
             return createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration);
         },
+        /**
+         * 
+         * @param {string} userId 
+         * @param {*} [options] Override http request option.
+         * @throws {RequiredError}
+         */
+        async restoreUser(userId: string, options?: AxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise<UserResponseDto>> {
+            const localVarAxiosArgs = await localVarAxiosParamCreator.restoreUser(userId, options);
+            return createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration);
+        },
         /**
          * 
          * @param {UpdateUserDto} updateUserDto 
@@ -5034,6 +5134,15 @@ export const UserApiFactory = function (configuration?: Configuration, basePath?
         createUser(createUserDto: CreateUserDto, options?: any): AxiosPromise<UserResponseDto> {
             return localVarFp.createUser(createUserDto, options).then((request) => request(axios, basePath));
         },
+        /**
+         * 
+         * @param {string} userId 
+         * @param {*} [options] Override http request option.
+         * @throws {RequiredError}
+         */
+        deleteUser(userId: string, options?: any): AxiosPromise<UserResponseDto> {
+            return localVarFp.deleteUser(userId, options).then((request) => request(axios, basePath));
+        },
         /**
          * 
          * @param {boolean} isAll 
@@ -5077,6 +5186,15 @@ export const UserApiFactory = function (configuration?: Configuration, basePath?
         getUserCount(options?: any): AxiosPromise<UserCountResponseDto> {
             return localVarFp.getUserCount(options).then((request) => request(axios, basePath));
         },
+        /**
+         * 
+         * @param {string} userId 
+         * @param {*} [options] Override http request option.
+         * @throws {RequiredError}
+         */
+        restoreUser(userId: string, options?: any): AxiosPromise<UserResponseDto> {
+            return localVarFp.restoreUser(userId, options).then((request) => request(axios, basePath));
+        },
         /**
          * 
          * @param {UpdateUserDto} updateUserDto 
@@ -5118,6 +5236,17 @@ export class UserApi extends BaseAPI {
         return UserApiFp(this.configuration).createUser(createUserDto, options).then((request) => request(this.axios, this.basePath));
     }
 
+    /**
+     * 
+     * @param {string} userId 
+     * @param {*} [options] Override http request option.
+     * @throws {RequiredError}
+     * @memberof UserApi
+     */
+    public deleteUser(userId: string, options?: AxiosRequestConfig) {
+        return UserApiFp(this.configuration).deleteUser(userId, options).then((request) => request(this.axios, this.basePath));
+    }
+
     /**
      * 
      * @param {boolean} isAll 
@@ -5171,6 +5300,17 @@ export class UserApi extends BaseAPI {
         return UserApiFp(this.configuration).getUserCount(options).then((request) => request(this.axios, this.basePath));
     }
 
+    /**
+     * 
+     * @param {string} userId 
+     * @param {*} [options] Override http request option.
+     * @throws {RequiredError}
+     * @memberof UserApi
+     */
+    public restoreUser(userId: string, options?: AxiosRequestConfig) {
+        return UserApiFp(this.configuration).restoreUser(userId, options).then((request) => request(this.axios, this.basePath));
+    }
+
     /**
      * 
      * @param {UpdateUserDto} updateUserDto 

+ 41 - 0
web/src/lib/components/admin-page/delete-confirm-dialoge.svelte

@@ -0,0 +1,41 @@
+<script lang="ts">
+	import { api, UserResponseDto } from '@api';
+	import { createEventDispatcher } from 'svelte';
+
+	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');
+	};
+</script>
+
+<div
+	class="border bg-immich-bg dark:bg-immich-dark-gray dark:border-immich-dark-gray p-4 shadow-sm w-[500px] rounded-3xl py-8 dark:text-immich-dark-fg"
+>
+	<div
+		class="flex flex-col place-items-center place-content-center gap-4 px-4 text-immich-primary dark:text-immich-dark-primary"
+	>
+		<h1 class="text-2xl text-immich-primary dark:text-immich-dark-primary font-medium">
+			Confirm User Deletion
+		</h1>
+	</div>
+	<div>
+		<p class="ml-4 text-md py-5 text-center">
+			{user.firstName}
+			{user.lastName} account and assets along will be marked to delete completely after 7 days. are
+			you sure you want to proceed ?
+		</p>
+
+		<div class="flex w-full px-4 gap-4 mt-8">
+			<button
+				on:click={deleteUser}
+				class="flex-1 transition-colors bg-red-500 hover:bg-red-400 px-6 py-3 text-white rounded-full w-full font-medium"
+				>Confirm
+			</button>
+		</div>
+	</div>
+</div>

+ 40 - 0
web/src/lib/components/admin-page/restore-dialoge.svelte

@@ -0,0 +1,40 @@
+<script lang="ts">
+	import { api, UserResponseDto } from '@api';
+	import { createEventDispatcher } from 'svelte';
+
+	export let user: UserResponseDto;
+
+	const dispatch = createEventDispatcher();
+
+	const restoreUser = async () => {
+		const restoredUser = await api.userApi.restoreUser(user.id);
+		if (restoredUser.data.deletedAt == null) dispatch('user-restore-success');
+		else dispatch('user-restore-fail');
+	};
+</script>
+
+<div
+	class="border bg-immich-bg dark:bg-immich-dark-gray dark:border-immich-dark-gray p-4 shadow-sm w-[500px] rounded-3xl py-8 dark:text-immich-dark-fg"
+>
+	<div
+		class="flex flex-col place-items-center place-content-center gap-4 px-4 text-immich-primary dark:text-immich-dark-primary"
+	>
+		<h1 class="text-2xl text-immich-primary dark:text-immich-dark-primary font-medium">
+			Restore User
+		</h1>
+	</div>
+	<div>
+		<p class="ml-4 text-md py-5 text-center">
+			{user.firstName}
+			{user.lastName} account will restored
+		</p>
+
+		<div class="flex w-full px-4 gap-4 mt-8">
+			<button
+				on:click={restoreUser}
+				class="flex-1 transition-colors bg-lime-600 hover:bg-lime-500 px-6 py-3 text-white rounded-full w-full font-medium"
+				>Confirm
+			</button>
+		</div>
+	</div>
+</div>

+ 46 - 11
web/src/lib/components/admin-page/user-management.svelte

@@ -3,9 +3,21 @@
 
 	import { createEventDispatcher } from 'svelte';
 	import PencilOutline from 'svelte-material-icons/PencilOutline.svelte';
+	import TrashCanOutline from 'svelte-material-icons/TrashCanOutline.svelte';
+	import DeleteRestore from 'svelte-material-icons/DeleteRestore.svelte';
+	import moment from 'moment';
+
 	export let allUsers: Array<UserResponseDto>;
 
 	const dispatch = createEventDispatcher();
+
+	const isDeleted = (user: UserResponseDto): boolean => {
+		return user.deletedAt != null;
+	};
+
+	const getDeleteDate = (user: UserResponseDto): string => {
+		return moment(user.deletedAt).add(7, 'days').format('LL');
+	};
 </script>
 
 <table class="text-left w-full my-5">
@@ -16,7 +28,7 @@
 			<th class="text-center w-1/4 font-medium text-sm">Email</th>
 			<th class="text-center w-1/4 font-medium text-sm">First name</th>
 			<th class="text-center w-1/4 font-medium text-sm">Last name</th>
-			<th class="text-center w-1/4 font-medium text-sm">Edit</th>
+			<th class="text-center w-1/4 font-medium text-sm">Action</th>
 		</tr>
 	</thead>
 	<tbody
@@ -25,21 +37,44 @@
 		{#each allUsers as user, i}
 			<tr
 				class={`text-center flex place-items-center w-full h-[80px] dark:text-immich-dark-bg ${
-					i % 2 == 0 ? 'bg-immich-gray dark:bg-[#e5e5e5]' : 'bg-immich-bg dark:bg-[#eeeeee]'
+					isDeleted(user)
+						? 'bg-red-50'
+						: i % 2 == 0
+						? 'bg-immich-gray dark:bg-[#e5e5e5]'
+						: 'bg-immich-bg dark:bg-[#eeeeee]'
 				}`}
 			>
 				<td class="text-sm px-4 w-1/4 text-ellipsis">{user.email}</td>
 				<td class="text-sm px-4 w-1/4 text-ellipsis">{user.firstName}</td>
 				<td class="text-sm px-4 w-1/4 text-ellipsis">{user.lastName}</td>
-				<td class="text-sm px-4 w-1/4 text-ellipsis"
-					><button
-						on:click={() => {
-							dispatch('edit-user', { 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"
-						><PencilOutline size="20" /></button
-					></td
-				>
+				<td class="text-sm px-4 w-1/4 text-ellipsis">
+					{#if !isDeleted(user)}
+						<button
+							on:click={() => {
+								dispatch('edit-user', { 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"
+							><PencilOutline size="16" /></button
+						>
+						<button
+							on:click={() => {
+								dispatch('delete-user', { 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 isDeleted(user)}
+						<button
+							on:click={() => {
+								dispatch('restore-user', { 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"
+							title={`scheduled removal on ${getDeleteDate(user)}`}
+							><DeleteRestore size="16" /></button
+						>
+					{/if}
+				</td>
 			</tr>
 		{/each}
 	</tbody>

+ 66 - 3
web/src/routes/admin/+page.svelte

@@ -11,21 +11,25 @@
 	import FullScreenModal from '$lib/components/shared-components/full-screen-modal.svelte';
 	import CreateUserForm from '$lib/components/forms/create-user-form.svelte';
 	import EditUserForm from '$lib/components/forms/edit-user-form.svelte';
+	import DeleteConfirmDialog from '$lib/components/admin-page/delete-confirm-dialoge.svelte';
 	import StatusBox from '$lib/components/shared-components/status-box.svelte';
 	import type { PageData } from './$types';
 	import { api, ServerStatsResponseDto, UserResponseDto } from '@api';
 	import JobsPanel from '$lib/components/admin-page/jobs/jobs-panel.svelte';
 	import ServerStatsPanel from '$lib/components/admin-page/server-stats/server-stats-panel.svelte';
+	import RestoreDialoge from '$lib/components/admin-page/restore-dialoge.svelte';
 
 	let selectedAction: AdminSideBarSelection = AdminSideBarSelection.USER_MANAGEMENT;
 
 	export let data: PageData;
 
-	let editUser: UserResponseDto;
+	let selectedUser: UserResponseDto;
 
 	let shouldShowEditUserForm = false;
 	let shouldShowCreateUserForm = false;
 	let shouldShowInfoPanel = false;
+	let shouldShowDeleteConfirmDialog = false;
+	let shouldShowRestoreDialog = false;
 	let serverStat: ServerStatsResponseDto;
 
 	const onButtonClicked = (buttonType: CustomEvent) => {
@@ -45,7 +49,7 @@
 
 	const editUserHandler = async (event: CustomEvent) => {
 		const { user } = event.detail;
-		editUser = user;
+		selectedUser = user;
 		shouldShowEditUserForm = true;
 	};
 
@@ -62,6 +66,43 @@
 		shouldShowInfoPanel = true;
 	};
 
+	const deleteUserHandler = async (event: CustomEvent) => {
+		const { user } = event.detail;
+		selectedUser = user;
+		shouldShowDeleteConfirmDialog = true;
+	};
+
+	const onUserDeleteSuccess = async () => {
+		const getAllUsersRes = await api.userApi.getAllUsers(false);
+		data.allUsers = getAllUsersRes.data;
+		shouldShowDeleteConfirmDialog = false;
+	};
+
+	const onUserDeleteFail = async () => {
+		const getAllUsersRes = await api.userApi.getAllUsers(false);
+		data.allUsers = getAllUsersRes.data;
+		shouldShowDeleteConfirmDialog = false;
+	};
+
+	const restoreUserHandler = async (event: CustomEvent) => {
+		const { user } = event.detail;
+		selectedUser = user;
+		shouldShowRestoreDialog = true;
+	};
+
+	const onUserRestoreSuccess = async () => {
+		const getAllUsersRes = await api.userApi.getAllUsers(false);
+		data.allUsers = getAllUsersRes.data;
+		shouldShowRestoreDialog = false;
+	};
+
+	const onUserRestoreFail = async () => {
+		// show fail dialog
+		const getAllUsersRes = await api.userApi.getAllUsers(false);
+		data.allUsers = getAllUsersRes.data;
+		shouldShowRestoreDialog = false;
+	};
+
 	const getServerStats = async () => {
 		try {
 			const res = await api.serverInfoApi.getStats();
@@ -87,13 +128,33 @@
 {#if shouldShowEditUserForm}
 	<FullScreenModal on:clickOutside={() => (shouldShowEditUserForm = false)}>
 		<EditUserForm
-			user={editUser}
+			user={selectedUser}
 			on:edit-success={onEditUserSuccess}
 			on:reset-password-success={onEditPasswordSuccess}
 		/>
 	</FullScreenModal>
 {/if}
 
+{#if shouldShowDeleteConfirmDialog}
+	<FullScreenModal on:clickOutside={() => (shouldShowDeleteConfirmDialog = false)}>
+		<DeleteConfirmDialog
+			user={selectedUser}
+			on:user-delete-success={onUserDeleteSuccess}
+			on:user-delete-fail={onUserDeleteFail}
+		/>
+	</FullScreenModal>
+{/if}
+
+{#if shouldShowRestoreDialog}
+	<FullScreenModal on:clickOutside={() => (shouldShowRestoreDialog = false)}>
+		<RestoreDialoge
+			user={selectedUser}
+			on:user-restore-success={onUserRestoreSuccess}
+			on:user-restore-fail={onUserRestoreFail}
+		/>
+	</FullScreenModal>
+{/if}
+
 {#if shouldShowInfoPanel}
 	<FullScreenModal on:clickOutside={() => (shouldShowInfoPanel = false)}>
 		<div class="border bg-white shadow-sm w-[500px] rounded-3xl p-8 text-sm">
@@ -160,6 +221,8 @@
 						allUsers={data.allUsers}
 						on:create-user={() => (shouldShowCreateUserForm = true)}
 						on:edit-user={editUserHandler}
+						on:delete-user={deleteUserHandler}
+						on:restore-user={restoreUserHandler}
 					/>
 				{/if}
 				{#if selectedAction === AdminSideBarSelection.JOBS}

برخی فایل ها در این مقایسه diff نمایش داده نمی شوند زیرا تعداد فایل ها بسیار زیاد است