1
0
mirror of https://github.com/immich-app/immich.git synced 2024-12-25 10:43:13 +02:00

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
This commit is contained in:
Zeeshan Khan 2022-11-07 16:53:47 -05:00 committed by GitHub
parent 948ff5530c
commit fe4b307fe6
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
30 changed files with 594 additions and 56 deletions

Binary file not shown.

Binary file not shown.

Binary file not shown.

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

File diff suppressed because one or more lines are too long

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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