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:
parent
948ff5530c
commit
fe4b307fe6
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@ -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,
|
||||
};
|
||||
}
|
||||
|
@ -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);
|
||||
|
@ -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()
|
||||
|
@ -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);
|
||||
|
@ -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,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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -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: {
|
||||
|
@ -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() });
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -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,
|
||||
},
|
||||
]),
|
||||
);
|
||||
|
@ -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
39
server/libs/common/src/utils/asset-utils.ts
Normal file
39
server/libs/common/src/utils/asset-utils.ts
Normal 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 };
|
@ -1 +1,3 @@
|
||||
export * from './time-utils';
|
||||
export * from './asset-utils';
|
||||
export * from './user-utils';
|
||||
|
19
server/libs/common/src/utils/user-utils.spec.ts
Normal file
19
server/libs/common/src/utils/user-utils.spec.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
});
|
16
server/libs/common/src/utils/user-utils.ts
Normal file
16
server/libs/common/src/utils/user-utils.ts
Normal 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();
|
@ -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;
|
||||
}
|
||||
|
@ -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"`);
|
||||
}
|
||||
}
|
@ -29,3 +29,8 @@ export enum MachineLearningJobNameEnum {
|
||||
OBJECT_DETECTION = 'detect-object',
|
||||
IMAGE_TAGGING = 'tag-image',
|
||||
}
|
||||
|
||||
/**
|
||||
* User deletion Queue Jobs
|
||||
*/
|
||||
export const userDeletionProcessorName = 'user-deletion';
|
||||
|
@ -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',
|
||||
}
|
||||
|
@ -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;
|
||||
}
|
@ -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
|
||||
|
@ -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
web/src/lib/components/admin-page/restore-dialoge.svelte
Normal file
40
web/src/lib/components/admin-page/restore-dialoge.svelte
Normal 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>
|
@ -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>
|
||||
|
@ -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}
|
||||
|
Loading…
Reference in New Issue
Block a user