mirror of
https://github.com/immich-app/immich
synced 2025-11-14 17:36:12 +00:00
refactor(server): user endpoints (#9730)
* refactor(server): user endpoints * fix repos * fix unit tests --------- Co-authored-by: Daniel Dietzler <mail@ddietzler.dev> Co-authored-by: Alex <alex.tran1502@gmail.com>
This commit is contained in:
parent
e7c8501930
commit
75830a4878
80 changed files with 2453 additions and 1914 deletions
|
|
@ -1,9 +1,9 @@
|
|||
import { Command, CommandRunner, InquirerService, Question, QuestionSet } from 'nest-commander';
|
||||
import { UserResponseDto } from 'src/dtos/user.dto';
|
||||
import { UserAdminResponseDto } from 'src/dtos/user.dto';
|
||||
import { CliService } from 'src/services/cli.service';
|
||||
|
||||
const prompt = (inquirer: InquirerService) => {
|
||||
return function ask(admin: UserResponseDto) {
|
||||
return function ask(admin: UserAdminResponseDto) {
|
||||
const { id, oauthId, email, name } = admin;
|
||||
console.log(`Found Admin:
|
||||
- ID=${id}
|
||||
|
|
|
|||
|
|
@ -12,7 +12,7 @@ import {
|
|||
SignUpDto,
|
||||
ValidateAccessTokenResponseDto,
|
||||
} from 'src/dtos/auth.dto';
|
||||
import { UserResponseDto, mapUser } from 'src/dtos/user.dto';
|
||||
import { UserAdminResponseDto } from 'src/dtos/user.dto';
|
||||
import { Auth, Authenticated, GetLoginDetails } from 'src/middleware/auth.guard';
|
||||
import { AuthService, LoginDetails } from 'src/services/auth.service';
|
||||
import { respondWithCookie, respondWithoutCookie } from 'src/utils/response';
|
||||
|
|
@ -40,7 +40,7 @@ export class AuthController {
|
|||
}
|
||||
|
||||
@Post('admin-sign-up')
|
||||
signUpAdmin(@Body() dto: SignUpDto): Promise<UserResponseDto> {
|
||||
signUpAdmin(@Body() dto: SignUpDto): Promise<UserAdminResponseDto> {
|
||||
return this.service.adminSignUp(dto);
|
||||
}
|
||||
|
||||
|
|
@ -54,8 +54,8 @@ export class AuthController {
|
|||
@Post('change-password')
|
||||
@HttpCode(HttpStatus.OK)
|
||||
@Authenticated()
|
||||
changePassword(@Auth() auth: AuthDto, @Body() dto: ChangePasswordDto): Promise<UserResponseDto> {
|
||||
return this.service.changePassword(auth, dto).then(mapUser);
|
||||
changePassword(@Auth() auth: AuthDto, @Body() dto: ChangePasswordDto): Promise<UserAdminResponseDto> {
|
||||
return this.service.changePassword(auth, dto);
|
||||
}
|
||||
|
||||
@Post('logout')
|
||||
|
|
|
|||
|
|
@ -27,6 +27,7 @@ import { SystemMetadataController } from 'src/controllers/system-metadata.contro
|
|||
import { TagController } from 'src/controllers/tag.controller';
|
||||
import { TimelineController } from 'src/controllers/timeline.controller';
|
||||
import { TrashController } from 'src/controllers/trash.controller';
|
||||
import { UserAdminController } from 'src/controllers/user-admin.controller';
|
||||
import { UserController } from 'src/controllers/user.controller';
|
||||
|
||||
export const controllers = [
|
||||
|
|
@ -59,5 +60,6 @@ export const controllers = [
|
|||
TagController,
|
||||
TimelineController,
|
||||
TrashController,
|
||||
UserAdminController,
|
||||
UserController,
|
||||
];
|
||||
|
|
|
|||
|
|
@ -10,7 +10,7 @@ import {
|
|||
OAuthCallbackDto,
|
||||
OAuthConfigDto,
|
||||
} from 'src/dtos/auth.dto';
|
||||
import { UserResponseDto } from 'src/dtos/user.dto';
|
||||
import { UserAdminResponseDto } from 'src/dtos/user.dto';
|
||||
import { Auth, Authenticated, GetLoginDetails } from 'src/middleware/auth.guard';
|
||||
import { AuthService, LoginDetails } from 'src/services/auth.service';
|
||||
import { respondWithCookie } from 'src/utils/response';
|
||||
|
|
@ -53,13 +53,13 @@ export class OAuthController {
|
|||
|
||||
@Post('link')
|
||||
@Authenticated()
|
||||
linkOAuthAccount(@Auth() auth: AuthDto, @Body() dto: OAuthCallbackDto): Promise<UserResponseDto> {
|
||||
linkOAuthAccount(@Auth() auth: AuthDto, @Body() dto: OAuthCallbackDto): Promise<UserAdminResponseDto> {
|
||||
return this.service.link(auth, dto);
|
||||
}
|
||||
|
||||
@Post('unlink')
|
||||
@Authenticated()
|
||||
unlinkOAuthAccount(@Auth() auth: AuthDto): Promise<UserResponseDto> {
|
||||
unlinkOAuthAccount(@Auth() auth: AuthDto): Promise<UserAdminResponseDto> {
|
||||
return this.service.unlink(auth);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
63
server/src/controllers/user-admin.controller.ts
Normal file
63
server/src/controllers/user-admin.controller.ts
Normal file
|
|
@ -0,0 +1,63 @@
|
|||
import { Body, Controller, Delete, Get, Param, Post, Put, Query } from '@nestjs/common';
|
||||
import { ApiTags } from '@nestjs/swagger';
|
||||
import { AuthDto } from 'src/dtos/auth.dto';
|
||||
import {
|
||||
UserAdminCreateDto,
|
||||
UserAdminDeleteDto,
|
||||
UserAdminResponseDto,
|
||||
UserAdminSearchDto,
|
||||
UserAdminUpdateDto,
|
||||
} from 'src/dtos/user.dto';
|
||||
import { Auth, Authenticated } from 'src/middleware/auth.guard';
|
||||
import { UserAdminService } from 'src/services/user-admin.service';
|
||||
import { UUIDParamDto } from 'src/validation';
|
||||
|
||||
@ApiTags('User')
|
||||
@Controller('admin/users')
|
||||
export class UserAdminController {
|
||||
constructor(private service: UserAdminService) {}
|
||||
|
||||
@Get()
|
||||
@Authenticated({ admin: true })
|
||||
searchUsersAdmin(@Auth() auth: AuthDto, @Query() dto: UserAdminSearchDto): Promise<UserAdminResponseDto[]> {
|
||||
return this.service.search(auth, dto);
|
||||
}
|
||||
|
||||
@Post()
|
||||
@Authenticated({ admin: true })
|
||||
createUserAdmin(@Body() createUserDto: UserAdminCreateDto): Promise<UserAdminResponseDto> {
|
||||
return this.service.create(createUserDto);
|
||||
}
|
||||
|
||||
@Get(':id')
|
||||
@Authenticated({ admin: true })
|
||||
getUserAdmin(@Auth() auth: AuthDto, @Param() { id }: UUIDParamDto): Promise<UserAdminResponseDto> {
|
||||
return this.service.get(auth, id);
|
||||
}
|
||||
|
||||
@Put(':id')
|
||||
@Authenticated({ admin: true })
|
||||
updateUserAdmin(
|
||||
@Auth() auth: AuthDto,
|
||||
@Param() { id }: UUIDParamDto,
|
||||
@Body() dto: UserAdminUpdateDto,
|
||||
): Promise<UserAdminResponseDto> {
|
||||
return this.service.update(auth, id, dto);
|
||||
}
|
||||
|
||||
@Delete(':id')
|
||||
@Authenticated({ admin: true })
|
||||
deleteUserAdmin(
|
||||
@Auth() auth: AuthDto,
|
||||
@Param() { id }: UUIDParamDto,
|
||||
@Body() dto: UserAdminDeleteDto,
|
||||
): Promise<UserAdminResponseDto> {
|
||||
return this.service.delete(auth, id, dto);
|
||||
}
|
||||
|
||||
@Post(':id/restore')
|
||||
@Authenticated({ admin: true })
|
||||
restoreUserAdmin(@Auth() auth: AuthDto, @Param() { id }: UUIDParamDto): Promise<UserAdminResponseDto> {
|
||||
return this.service.restore(auth, id);
|
||||
}
|
||||
}
|
||||
|
|
@ -10,7 +10,6 @@ import {
|
|||
Param,
|
||||
Post,
|
||||
Put,
|
||||
Query,
|
||||
Res,
|
||||
UploadedFile,
|
||||
UseInterceptors,
|
||||
|
|
@ -19,7 +18,7 @@ import { ApiBody, ApiConsumes, ApiTags } from '@nestjs/swagger';
|
|||
import { NextFunction, Response } from 'express';
|
||||
import { AuthDto } from 'src/dtos/auth.dto';
|
||||
import { CreateProfileImageDto, CreateProfileImageResponseDto } from 'src/dtos/user-profile.dto';
|
||||
import { CreateUserDto, DeleteUserDto, UpdateUserDto, UserResponseDto } from 'src/dtos/user.dto';
|
||||
import { UserAdminResponseDto, UserResponseDto, UserUpdateMeDto } from 'src/dtos/user.dto';
|
||||
import { ILoggerRepository } from 'src/interfaces/logger.interface';
|
||||
import { Auth, Authenticated, FileResponse } from 'src/middleware/auth.guard';
|
||||
import { FileUploadInterceptor, Route } from 'src/middleware/file-upload.interceptor';
|
||||
|
|
@ -37,58 +36,28 @@ export class UserController {
|
|||
|
||||
@Get()
|
||||
@Authenticated()
|
||||
getAllUsers(@Auth() auth: AuthDto, @Query('isAll') isAll: boolean): Promise<UserResponseDto[]> {
|
||||
return this.service.getAll(auth, isAll);
|
||||
}
|
||||
|
||||
@Post()
|
||||
@Authenticated({ admin: true })
|
||||
createUser(@Body() createUserDto: CreateUserDto): Promise<UserResponseDto> {
|
||||
return this.service.create(createUserDto);
|
||||
searchUsers(): Promise<UserResponseDto[]> {
|
||||
return this.service.search();
|
||||
}
|
||||
|
||||
@Get('me')
|
||||
@Authenticated()
|
||||
getMyUserInfo(@Auth() auth: AuthDto): Promise<UserResponseDto> {
|
||||
getMyUser(@Auth() auth: AuthDto): UserAdminResponseDto {
|
||||
return this.service.getMe(auth);
|
||||
}
|
||||
|
||||
@Put('me')
|
||||
@Authenticated()
|
||||
updateMyUser(@Auth() auth: AuthDto, @Body() dto: UserUpdateMeDto): Promise<UserAdminResponseDto> {
|
||||
return this.service.updateMe(auth, dto);
|
||||
}
|
||||
|
||||
@Get(':id')
|
||||
@Authenticated()
|
||||
getUserById(@Param() { id }: UUIDParamDto): Promise<UserResponseDto> {
|
||||
getUser(@Param() { id }: UUIDParamDto): Promise<UserResponseDto> {
|
||||
return this.service.get(id);
|
||||
}
|
||||
|
||||
@Delete('profile-image')
|
||||
@HttpCode(HttpStatus.NO_CONTENT)
|
||||
@Authenticated()
|
||||
deleteProfileImage(@Auth() auth: AuthDto): Promise<void> {
|
||||
return this.service.deleteProfileImage(auth);
|
||||
}
|
||||
|
||||
@Delete(':id')
|
||||
@Authenticated({ admin: true })
|
||||
deleteUser(
|
||||
@Auth() auth: AuthDto,
|
||||
@Param() { id }: UUIDParamDto,
|
||||
@Body() dto: DeleteUserDto,
|
||||
): Promise<UserResponseDto> {
|
||||
return this.service.delete(auth, id, dto);
|
||||
}
|
||||
|
||||
@Post(':id/restore')
|
||||
@Authenticated({ admin: true })
|
||||
restoreUser(@Auth() auth: AuthDto, @Param() { id }: UUIDParamDto): Promise<UserResponseDto> {
|
||||
return this.service.restore(auth, id);
|
||||
}
|
||||
|
||||
// TODO: replace with @Put(':id')
|
||||
@Put()
|
||||
@Authenticated()
|
||||
updateUser(@Auth() auth: AuthDto, @Body() updateUserDto: UpdateUserDto): Promise<UserResponseDto> {
|
||||
return this.service.update(auth, updateUserDto);
|
||||
}
|
||||
|
||||
@UseInterceptors(FileUploadInterceptor)
|
||||
@ApiConsumes('multipart/form-data')
|
||||
@ApiBody({ description: 'A new avatar for the user', type: CreateProfileImageDto })
|
||||
|
|
@ -101,6 +70,13 @@ export class UserController {
|
|||
return this.service.createProfileImage(auth, fileInfo);
|
||||
}
|
||||
|
||||
@Delete('profile-image')
|
||||
@HttpCode(HttpStatus.NO_CONTENT)
|
||||
@Authenticated()
|
||||
deleteProfileImage(@Auth() auth: AuthDto): Promise<void> {
|
||||
return this.service.deleteProfileImage(auth);
|
||||
}
|
||||
|
||||
@Get(':id/profile-image')
|
||||
@FileResponse()
|
||||
@Authenticated()
|
||||
|
|
|
|||
|
|
@ -1,7 +1,6 @@
|
|||
import { BadRequestException, ForbiddenException } from '@nestjs/common';
|
||||
import { BadRequestException } from '@nestjs/common';
|
||||
import sanitize from 'sanitize-filename';
|
||||
import { SALT_ROUNDS } from 'src/constants';
|
||||
import { UserResponseDto } from 'src/dtos/user.dto';
|
||||
import { UserEntity } from 'src/entities/user.entity';
|
||||
import { ICryptoRepository } from 'src/interfaces/crypto.interface';
|
||||
import { IUserRepository } from 'src/interfaces/user.interface';
|
||||
|
|
@ -26,46 +25,6 @@ export class UserCore {
|
|||
instance = null;
|
||||
}
|
||||
|
||||
// TODO: move auth related checks to the service layer
|
||||
async updateUser(user: UserEntity | UserResponseDto, id: string, dto: Partial<UserEntity>): Promise<UserEntity> {
|
||||
if (!user.isAdmin && user.id !== id) {
|
||||
throw new ForbiddenException('You are not allowed to update this user');
|
||||
}
|
||||
|
||||
if (!user.isAdmin) {
|
||||
// Users can never update the isAdmin property.
|
||||
delete dto.isAdmin;
|
||||
delete dto.storageLabel;
|
||||
} else if (dto.isAdmin && user.id !== id) {
|
||||
// Admin cannot create another admin.
|
||||
throw new BadRequestException('The server already has an admin');
|
||||
}
|
||||
|
||||
if (dto.email) {
|
||||
const duplicate = await this.userRepository.getByEmail(dto.email);
|
||||
if (duplicate && duplicate.id !== id) {
|
||||
throw new BadRequestException('Email already in use by another account');
|
||||
}
|
||||
}
|
||||
|
||||
if (dto.storageLabel) {
|
||||
const duplicate = await this.userRepository.getByStorageLabel(dto.storageLabel);
|
||||
if (duplicate && duplicate.id !== id) {
|
||||
throw new BadRequestException('Storage label already in use by another account');
|
||||
}
|
||||
}
|
||||
|
||||
if (dto.password) {
|
||||
dto.password = await this.cryptoRepository.hashBcrypt(dto.password, SALT_ROUNDS);
|
||||
}
|
||||
|
||||
if (dto.storageLabel === '') {
|
||||
dto.storageLabel = null;
|
||||
}
|
||||
|
||||
return this.userRepository.update(id, { ...dto, updatedAt: new Date() });
|
||||
}
|
||||
|
||||
async createUser(dto: Partial<UserEntity> & { email: string }): Promise<UserEntity> {
|
||||
const user = await this.userRepository.getByEmail(dto.email);
|
||||
if (user) {
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
import { ApiProperty } from '@nestjs/swagger';
|
||||
import { IsEnum, IsNotEmpty, IsString, ValidateIf } from 'class-validator';
|
||||
import { UserDto, mapSimpleUser } from 'src/dtos/user.dto';
|
||||
import { UserResponseDto, mapUser } from 'src/dtos/user.dto';
|
||||
import { ActivityEntity } from 'src/entities/activity.entity';
|
||||
import { Optional, ValidateUUID } from 'src/validation';
|
||||
|
||||
|
|
@ -20,7 +20,7 @@ export class ActivityResponseDto {
|
|||
id!: string;
|
||||
createdAt!: Date;
|
||||
type!: ReactionType;
|
||||
user!: UserDto;
|
||||
user!: UserResponseDto;
|
||||
assetId!: string | null;
|
||||
comment?: string | null;
|
||||
}
|
||||
|
|
@ -73,6 +73,6 @@ export function mapActivity(activity: ActivityEntity): ActivityResponseDto {
|
|||
createdAt: activity.createdAt,
|
||||
comment: activity.comment,
|
||||
type: activity.isLiked ? ReactionType.LIKE : ReactionType.COMMENT,
|
||||
user: mapSimpleUser(activity.user),
|
||||
user: mapUser(activity.user),
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,12 +1,12 @@
|
|||
import { plainToInstance } from 'class-transformer';
|
||||
import { validate } from 'class-validator';
|
||||
import { CreateUserDto, CreateUserOAuthDto, UpdateUserDto } from 'src/dtos/user.dto';
|
||||
import { UserAdminCreateDto, UserUpdateMeDto } from 'src/dtos/user.dto';
|
||||
|
||||
describe('update user DTO', () => {
|
||||
it('should allow emails without a tld', async () => {
|
||||
const someEmail = 'test@test';
|
||||
|
||||
const dto = plainToInstance(UpdateUserDto, {
|
||||
const dto = plainToInstance(UserUpdateMeDto, {
|
||||
email: someEmail,
|
||||
id: '3fe388e4-2078-44d7-b36c-39d9dee3a657',
|
||||
});
|
||||
|
|
@ -18,22 +18,22 @@ describe('update user DTO', () => {
|
|||
|
||||
describe('create user DTO', () => {
|
||||
it('validates the email', async () => {
|
||||
const params: Partial<CreateUserDto> = {
|
||||
const params: Partial<UserAdminCreateDto> = {
|
||||
email: undefined,
|
||||
password: 'password',
|
||||
name: 'name',
|
||||
};
|
||||
let dto: CreateUserDto = plainToInstance(CreateUserDto, params);
|
||||
let dto: UserAdminCreateDto = plainToInstance(UserAdminCreateDto, params);
|
||||
let errors = await validate(dto);
|
||||
expect(errors).toHaveLength(1);
|
||||
|
||||
params.email = 'invalid email';
|
||||
dto = plainToInstance(CreateUserDto, params);
|
||||
dto = plainToInstance(UserAdminCreateDto, params);
|
||||
errors = await validate(dto);
|
||||
expect(errors).toHaveLength(1);
|
||||
|
||||
params.email = 'valid@email.com';
|
||||
dto = plainToInstance(CreateUserDto, params);
|
||||
dto = plainToInstance(UserAdminCreateDto, params);
|
||||
errors = await validate(dto);
|
||||
expect(errors).toHaveLength(0);
|
||||
});
|
||||
|
|
@ -41,7 +41,7 @@ describe('create user DTO', () => {
|
|||
it('should allow emails without a tld', async () => {
|
||||
const someEmail = 'test@test';
|
||||
|
||||
const dto = plainToInstance(CreateUserDto, {
|
||||
const dto = plainToInstance(UserAdminCreateDto, {
|
||||
email: someEmail,
|
||||
password: 'some password',
|
||||
name: 'some name',
|
||||
|
|
@ -51,18 +51,3 @@ describe('create user DTO', () => {
|
|||
expect(dto.email).toEqual(someEmail);
|
||||
});
|
||||
});
|
||||
|
||||
describe('create user oauth DTO', () => {
|
||||
it('should allow emails without a tld', async () => {
|
||||
const someEmail = 'test@test';
|
||||
|
||||
const dto = plainToInstance(CreateUserOAuthDto, {
|
||||
email: someEmail,
|
||||
oauthId: 'some oauth id',
|
||||
name: 'some name',
|
||||
});
|
||||
const errors = await validate(dto);
|
||||
expect(errors).toHaveLength(0);
|
||||
expect(dto.email).toEqual(someEmail);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,12 +1,63 @@
|
|||
import { ApiProperty } from '@nestjs/swagger';
|
||||
import { Transform } from 'class-transformer';
|
||||
import { IsBoolean, IsEmail, IsEnum, IsNotEmpty, IsNumber, IsPositive, IsString, IsUUID } from 'class-validator';
|
||||
import { IsBoolean, IsEmail, IsEnum, IsNotEmpty, IsNumber, IsPositive, IsString } from 'class-validator';
|
||||
import { UserAvatarColor } from 'src/entities/user-metadata.entity';
|
||||
import { UserEntity, UserStatus } from 'src/entities/user.entity';
|
||||
import { getPreferences } from 'src/utils/preferences';
|
||||
import { Optional, ValidateBoolean, toEmail, toSanitized } from 'src/validation';
|
||||
|
||||
export class CreateUserDto {
|
||||
export class UserUpdateMeDto {
|
||||
@Optional()
|
||||
@IsEmail({ require_tld: false })
|
||||
@Transform(toEmail)
|
||||
email?: string;
|
||||
|
||||
// TODO: migrate to the other change password endpoint
|
||||
@Optional()
|
||||
@IsNotEmpty()
|
||||
@IsString()
|
||||
password?: string;
|
||||
|
||||
@Optional()
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
name?: string;
|
||||
|
||||
@ValidateBoolean({ optional: true })
|
||||
memoriesEnabled?: boolean;
|
||||
|
||||
@Optional()
|
||||
@IsEnum(UserAvatarColor)
|
||||
@ApiProperty({ enumName: 'UserAvatarColor', enum: UserAvatarColor })
|
||||
avatarColor?: UserAvatarColor;
|
||||
}
|
||||
|
||||
export class UserResponseDto {
|
||||
id!: string;
|
||||
name!: string;
|
||||
email!: string;
|
||||
profileImagePath!: string;
|
||||
@IsEnum(UserAvatarColor)
|
||||
@ApiProperty({ enumName: 'UserAvatarColor', enum: UserAvatarColor })
|
||||
avatarColor!: UserAvatarColor;
|
||||
}
|
||||
|
||||
export const mapUser = (entity: UserEntity): UserResponseDto => {
|
||||
return {
|
||||
id: entity.id,
|
||||
email: entity.email,
|
||||
name: entity.name,
|
||||
profileImagePath: entity.profileImagePath,
|
||||
avatarColor: getPreferences(entity).avatar.color,
|
||||
};
|
||||
};
|
||||
|
||||
export class UserAdminSearchDto {
|
||||
@ValidateBoolean({ optional: true })
|
||||
withDeleted?: boolean;
|
||||
}
|
||||
|
||||
export class UserAdminCreateDto {
|
||||
@IsEmail({ require_tld: false })
|
||||
@Transform(toEmail)
|
||||
email!: string;
|
||||
|
|
@ -41,23 +92,7 @@ export class CreateUserDto {
|
|||
notify?: boolean;
|
||||
}
|
||||
|
||||
export class CreateUserOAuthDto {
|
||||
@IsEmail({ require_tld: false })
|
||||
@Transform(({ value }) => value?.toLowerCase())
|
||||
email!: string;
|
||||
|
||||
@IsNotEmpty()
|
||||
oauthId!: string;
|
||||
|
||||
name?: string;
|
||||
}
|
||||
|
||||
export class DeleteUserDto {
|
||||
@ValidateBoolean({ optional: true })
|
||||
force?: boolean;
|
||||
}
|
||||
|
||||
export class UpdateUserDto {
|
||||
export class UserAdminUpdateDto {
|
||||
@Optional()
|
||||
@IsEmail({ require_tld: false })
|
||||
@Transform(toEmail)
|
||||
|
|
@ -73,18 +108,10 @@ export class UpdateUserDto {
|
|||
@IsNotEmpty()
|
||||
name?: string;
|
||||
|
||||
@Optional()
|
||||
@Optional({ nullable: true })
|
||||
@IsString()
|
||||
@Transform(toSanitized)
|
||||
storageLabel?: string;
|
||||
|
||||
@IsNotEmpty()
|
||||
@IsUUID('4')
|
||||
@ApiProperty({ format: 'uuid' })
|
||||
id!: string;
|
||||
|
||||
@ValidateBoolean({ optional: true })
|
||||
isAdmin?: boolean;
|
||||
storageLabel?: string | null;
|
||||
|
||||
@ValidateBoolean({ optional: true })
|
||||
shouldChangePassword?: boolean;
|
||||
|
|
@ -104,17 +131,12 @@ export class UpdateUserDto {
|
|||
quotaSizeInBytes?: number | null;
|
||||
}
|
||||
|
||||
export class UserDto {
|
||||
id!: string;
|
||||
name!: string;
|
||||
email!: string;
|
||||
profileImagePath!: string;
|
||||
@IsEnum(UserAvatarColor)
|
||||
@ApiProperty({ enumName: 'UserAvatarColor', enum: UserAvatarColor })
|
||||
avatarColor!: UserAvatarColor;
|
||||
export class UserAdminDeleteDto {
|
||||
@ValidateBoolean({ optional: true })
|
||||
force?: boolean;
|
||||
}
|
||||
|
||||
export class UserResponseDto extends UserDto {
|
||||
export class UserAdminResponseDto extends UserResponseDto {
|
||||
storageLabel!: string | null;
|
||||
shouldChangePassword!: boolean;
|
||||
isAdmin!: boolean;
|
||||
|
|
@ -131,19 +153,9 @@ export class UserResponseDto extends UserDto {
|
|||
status!: string;
|
||||
}
|
||||
|
||||
export const mapSimpleUser = (entity: UserEntity): UserDto => {
|
||||
export function mapUserAdmin(entity: UserEntity): UserAdminResponseDto {
|
||||
return {
|
||||
id: entity.id,
|
||||
email: entity.email,
|
||||
name: entity.name,
|
||||
profileImagePath: entity.profileImagePath,
|
||||
avatarColor: getPreferences(entity).avatar.color,
|
||||
};
|
||||
};
|
||||
|
||||
export function mapUser(entity: UserEntity): UserResponseDto {
|
||||
return {
|
||||
...mapSimpleUser(entity),
|
||||
...mapUser(entity),
|
||||
storageLabel: entity.storageLabel,
|
||||
shouldChangePassword: entity.shouldChangePassword,
|
||||
isAdmin: entity.isAdmin,
|
||||
|
|
|
|||
|
|
@ -22,13 +22,17 @@ FROM
|
|||
"APIKeyEntity__APIKeyEntity_user"."status" AS "APIKeyEntity__APIKeyEntity_user_status",
|
||||
"APIKeyEntity__APIKeyEntity_user"."updatedAt" AS "APIKeyEntity__APIKeyEntity_user_updatedAt",
|
||||
"APIKeyEntity__APIKeyEntity_user"."quotaSizeInBytes" AS "APIKeyEntity__APIKeyEntity_user_quotaSizeInBytes",
|
||||
"APIKeyEntity__APIKeyEntity_user"."quotaUsageInBytes" AS "APIKeyEntity__APIKeyEntity_user_quotaUsageInBytes"
|
||||
"APIKeyEntity__APIKeyEntity_user"."quotaUsageInBytes" AS "APIKeyEntity__APIKeyEntity_user_quotaUsageInBytes",
|
||||
"7f5f7a38bf327bfbbf826778460704c9a50fe6f4"."userId" AS "7f5f7a38bf327bfbbf826778460704c9a50fe6f4_userId",
|
||||
"7f5f7a38bf327bfbbf826778460704c9a50fe6f4"."key" AS "7f5f7a38bf327bfbbf826778460704c9a50fe6f4_key",
|
||||
"7f5f7a38bf327bfbbf826778460704c9a50fe6f4"."value" AS "7f5f7a38bf327bfbbf826778460704c9a50fe6f4_value"
|
||||
FROM
|
||||
"api_keys" "APIKeyEntity"
|
||||
LEFT JOIN "users" "APIKeyEntity__APIKeyEntity_user" ON "APIKeyEntity__APIKeyEntity_user"."id" = "APIKeyEntity"."userId"
|
||||
AND (
|
||||
"APIKeyEntity__APIKeyEntity_user"."deletedAt" IS NULL
|
||||
)
|
||||
LEFT JOIN "user_metadata" "7f5f7a38bf327bfbbf826778460704c9a50fe6f4" ON "7f5f7a38bf327bfbbf826778460704c9a50fe6f4"."userId" = "APIKeyEntity__APIKeyEntity_user"."id"
|
||||
WHERE
|
||||
(("APIKeyEntity"."key" = $1))
|
||||
) "distinctAlias"
|
||||
|
|
|
|||
|
|
@ -38,13 +38,17 @@ FROM
|
|||
"SessionEntity__SessionEntity_user"."status" AS "SessionEntity__SessionEntity_user_status",
|
||||
"SessionEntity__SessionEntity_user"."updatedAt" AS "SessionEntity__SessionEntity_user_updatedAt",
|
||||
"SessionEntity__SessionEntity_user"."quotaSizeInBytes" AS "SessionEntity__SessionEntity_user_quotaSizeInBytes",
|
||||
"SessionEntity__SessionEntity_user"."quotaUsageInBytes" AS "SessionEntity__SessionEntity_user_quotaUsageInBytes"
|
||||
"SessionEntity__SessionEntity_user"."quotaUsageInBytes" AS "SessionEntity__SessionEntity_user_quotaUsageInBytes",
|
||||
"469e6aa7ff79eff78f8441f91ba15bb07d3634dd"."userId" AS "469e6aa7ff79eff78f8441f91ba15bb07d3634dd_userId",
|
||||
"469e6aa7ff79eff78f8441f91ba15bb07d3634dd"."key" AS "469e6aa7ff79eff78f8441f91ba15bb07d3634dd_key",
|
||||
"469e6aa7ff79eff78f8441f91ba15bb07d3634dd"."value" AS "469e6aa7ff79eff78f8441f91ba15bb07d3634dd_value"
|
||||
FROM
|
||||
"sessions" "SessionEntity"
|
||||
LEFT JOIN "users" "SessionEntity__SessionEntity_user" ON "SessionEntity__SessionEntity_user"."id" = "SessionEntity"."userId"
|
||||
AND (
|
||||
"SessionEntity__SessionEntity_user"."deletedAt" IS NULL
|
||||
)
|
||||
LEFT JOIN "user_metadata" "469e6aa7ff79eff78f8441f91ba15bb07d3634dd" ON "469e6aa7ff79eff78f8441f91ba15bb07d3634dd"."userId" = "SessionEntity__SessionEntity_user"."id"
|
||||
WHERE
|
||||
(("SessionEntity"."token" = $1))
|
||||
) "distinctAlias"
|
||||
|
|
|
|||
|
|
@ -34,7 +34,9 @@ export class ApiKeyRepository implements IKeyRepository {
|
|||
},
|
||||
where: { key: hashedToken },
|
||||
relations: {
|
||||
user: true,
|
||||
user: {
|
||||
metadata: true,
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -18,7 +18,14 @@ export class SessionRepository implements ISessionRepository {
|
|||
|
||||
@GenerateSql({ params: [DummyValue.STRING] })
|
||||
getByToken(token: string): Promise<SessionEntity | null> {
|
||||
return this.repository.findOne({ where: { token }, relations: { user: true } });
|
||||
return this.repository.findOne({
|
||||
where: { token },
|
||||
relations: {
|
||||
user: {
|
||||
metadata: true,
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
getByUserId(userId: string): Promise<SessionEntity[]> {
|
||||
|
|
|
|||
|
|
@ -138,6 +138,7 @@ describe('AuthService', () => {
|
|||
email: 'test@immich.com',
|
||||
password: 'hash-password',
|
||||
} as UserEntity);
|
||||
userMock.update.mockResolvedValue(userStub.user1);
|
||||
|
||||
await sut.changePassword(auth, dto);
|
||||
|
||||
|
|
|
|||
|
|
@ -11,7 +11,7 @@ import { DateTime } from 'luxon';
|
|||
import { IncomingHttpHeaders } from 'node:http';
|
||||
import { ClientMetadata, Issuer, UserinfoResponse, custom, generators } from 'openid-client';
|
||||
import { SystemConfig } from 'src/config';
|
||||
import { AuthType, LOGIN_URL, MOBILE_REDIRECT } from 'src/constants';
|
||||
import { AuthType, LOGIN_URL, MOBILE_REDIRECT, SALT_ROUNDS } from 'src/constants';
|
||||
import { SystemConfigCore } from 'src/cores/system-config.core';
|
||||
import { UserCore } from 'src/cores/user.core';
|
||||
import {
|
||||
|
|
@ -27,7 +27,7 @@ import {
|
|||
SignUpDto,
|
||||
mapLoginResponse,
|
||||
} from 'src/dtos/auth.dto';
|
||||
import { UserResponseDto, mapUser } from 'src/dtos/user.dto';
|
||||
import { UserAdminResponseDto, mapUserAdmin } from 'src/dtos/user.dto';
|
||||
import { UserEntity } from 'src/entities/user.entity';
|
||||
import { IKeyRepository } from 'src/interfaces/api-key.interface';
|
||||
import { ICryptoRepository } from 'src/interfaces/crypto.interface';
|
||||
|
|
@ -109,7 +109,7 @@ export class AuthService {
|
|||
};
|
||||
}
|
||||
|
||||
async changePassword(auth: AuthDto, dto: ChangePasswordDto) {
|
||||
async changePassword(auth: AuthDto, dto: ChangePasswordDto): Promise<UserAdminResponseDto> {
|
||||
const { password, newPassword } = dto;
|
||||
const user = await this.userRepository.getByEmail(auth.user.email, true);
|
||||
if (!user) {
|
||||
|
|
@ -121,10 +121,14 @@ export class AuthService {
|
|||
throw new BadRequestException('Wrong password');
|
||||
}
|
||||
|
||||
return this.userCore.updateUser(auth.user, auth.user.id, { password: newPassword });
|
||||
const hashedPassword = await this.cryptoRepository.hashBcrypt(newPassword, SALT_ROUNDS);
|
||||
|
||||
const updatedUser = await this.userRepository.update(user.id, { password: hashedPassword });
|
||||
|
||||
return mapUserAdmin(updatedUser);
|
||||
}
|
||||
|
||||
async adminSignUp(dto: SignUpDto): Promise<UserResponseDto> {
|
||||
async adminSignUp(dto: SignUpDto): Promise<UserAdminResponseDto> {
|
||||
const adminUser = await this.userRepository.getAdmin();
|
||||
if (adminUser) {
|
||||
throw new BadRequestException('The server already has an admin');
|
||||
|
|
@ -138,7 +142,7 @@ export class AuthService {
|
|||
storageLabel: 'admin',
|
||||
});
|
||||
|
||||
return mapUser(admin);
|
||||
return mapUserAdmin(admin);
|
||||
}
|
||||
|
||||
async validate(headers: IncomingHttpHeaders, params: Record<string, string>): Promise<AuthDto> {
|
||||
|
|
@ -237,7 +241,7 @@ export class AuthService {
|
|||
return this.createLoginResponse(user, loginDetails);
|
||||
}
|
||||
|
||||
async link(auth: AuthDto, dto: OAuthCallbackDto): Promise<UserResponseDto> {
|
||||
async link(auth: AuthDto, dto: OAuthCallbackDto): Promise<UserAdminResponseDto> {
|
||||
const config = await this.configCore.getConfig();
|
||||
const { sub: oauthId } = await this.getOAuthProfile(config, dto.url);
|
||||
const duplicate = await this.userRepository.getByOAuthId(oauthId);
|
||||
|
|
@ -245,11 +249,14 @@ export class AuthService {
|
|||
this.logger.warn(`OAuth link account failed: sub is already linked to another user (${duplicate.email}).`);
|
||||
throw new BadRequestException('This OAuth account has already been linked to another user.');
|
||||
}
|
||||
return mapUser(await this.userRepository.update(auth.user.id, { oauthId }));
|
||||
|
||||
const user = await this.userRepository.update(auth.user.id, { oauthId });
|
||||
return mapUserAdmin(user);
|
||||
}
|
||||
|
||||
async unlink(auth: AuthDto): Promise<UserResponseDto> {
|
||||
return mapUser(await this.userRepository.update(auth.user.id, { oauthId: '' }));
|
||||
async unlink(auth: AuthDto): Promise<UserAdminResponseDto> {
|
||||
const user = await this.userRepository.update(auth.user.id, { oauthId: '' });
|
||||
return mapUserAdmin(user);
|
||||
}
|
||||
|
||||
private async getLogoutEndpoint(authType: AuthType): Promise<string> {
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
import { Inject, Injectable } from '@nestjs/common';
|
||||
import { SALT_ROUNDS } from 'src/constants';
|
||||
import { SystemConfigCore } from 'src/cores/system-config.core';
|
||||
import { UserCore } from 'src/cores/user.core';
|
||||
import { UserResponseDto, mapUser } from 'src/dtos/user.dto';
|
||||
import { UserAdminResponseDto, mapUserAdmin } from 'src/dtos/user.dto';
|
||||
import { ICryptoRepository } from 'src/interfaces/crypto.interface';
|
||||
import { ILoggerRepository } from 'src/interfaces/logger.interface';
|
||||
import { ISystemMetadataRepository } from 'src/interfaces/system-metadata.interface';
|
||||
|
|
@ -10,7 +10,6 @@ import { IUserRepository } from 'src/interfaces/user.interface';
|
|||
@Injectable()
|
||||
export class CliService {
|
||||
private configCore: SystemConfigCore;
|
||||
private userCore: UserCore;
|
||||
|
||||
constructor(
|
||||
@Inject(ICryptoRepository) private cryptoRepository: ICryptoRepository,
|
||||
|
|
@ -18,26 +17,26 @@ export class CliService {
|
|||
@Inject(IUserRepository) private userRepository: IUserRepository,
|
||||
@Inject(ILoggerRepository) private logger: ILoggerRepository,
|
||||
) {
|
||||
this.userCore = UserCore.create(cryptoRepository, userRepository);
|
||||
this.logger.setContext(CliService.name);
|
||||
this.configCore = SystemConfigCore.create(systemMetadataRepository, this.logger);
|
||||
}
|
||||
|
||||
async listUsers(): Promise<UserResponseDto[]> {
|
||||
async listUsers(): Promise<UserAdminResponseDto[]> {
|
||||
const users = await this.userRepository.getList({ withDeleted: true });
|
||||
return users.map((user) => mapUser(user));
|
||||
return users.map((user) => mapUserAdmin(user));
|
||||
}
|
||||
|
||||
async resetAdminPassword(ask: (admin: UserResponseDto) => Promise<string | undefined>) {
|
||||
async resetAdminPassword(ask: (admin: UserAdminResponseDto) => Promise<string | undefined>) {
|
||||
const admin = await this.userRepository.getAdmin();
|
||||
if (!admin) {
|
||||
throw new Error('Admin account does not exist');
|
||||
}
|
||||
|
||||
const providedPassword = await ask(mapUser(admin));
|
||||
const providedPassword = await ask(mapUserAdmin(admin));
|
||||
const password = providedPassword || this.cryptoRepository.newPassword(24);
|
||||
const hashedPassword = await this.cryptoRepository.hashBcrypt(password, SALT_ROUNDS);
|
||||
|
||||
await this.userCore.updateUser(admin, admin.id, { password });
|
||||
await this.userRepository.update(admin.id, { password: hashedPassword });
|
||||
|
||||
return { admin, password, provided: !!providedPassword };
|
||||
}
|
||||
|
|
|
|||
|
|
@ -33,6 +33,7 @@ import { SystemMetadataService } from 'src/services/system-metadata.service';
|
|||
import { TagService } from 'src/services/tag.service';
|
||||
import { TimelineService } from 'src/services/timeline.service';
|
||||
import { TrashService } from 'src/services/trash.service';
|
||||
import { UserAdminService } from 'src/services/user-admin.service';
|
||||
import { UserService } from 'src/services/user.service';
|
||||
import { VersionService } from 'src/services/version.service';
|
||||
|
||||
|
|
@ -73,5 +74,6 @@ export const services = [
|
|||
TimelineService,
|
||||
TrashService,
|
||||
UserService,
|
||||
UserAdminService,
|
||||
VersionService,
|
||||
];
|
||||
|
|
|
|||
|
|
@ -1,6 +1,4 @@
|
|||
import { BadRequestException } from '@nestjs/common';
|
||||
import { PartnerResponseDto } from 'src/dtos/partner.dto';
|
||||
import { UserAvatarColor } from 'src/entities/user-metadata.entity';
|
||||
import { IAccessRepository } from 'src/interfaces/access.interface';
|
||||
import { IPartnerRepository, PartnerDirection } from 'src/interfaces/partner.interface';
|
||||
import { PartnerService } from 'src/services/partner.service';
|
||||
|
|
@ -9,45 +7,6 @@ import { partnerStub } from 'test/fixtures/partner.stub';
|
|||
import { newPartnerRepositoryMock } from 'test/repositories/partner.repository.mock';
|
||||
import { Mocked } from 'vitest';
|
||||
|
||||
const responseDto = {
|
||||
admin: <PartnerResponseDto>{
|
||||
email: 'admin@test.com',
|
||||
name: 'admin_name',
|
||||
id: 'admin_id',
|
||||
isAdmin: true,
|
||||
oauthId: '',
|
||||
profileImagePath: '',
|
||||
shouldChangePassword: false,
|
||||
storageLabel: 'admin',
|
||||
createdAt: new Date('2021-01-01'),
|
||||
deletedAt: null,
|
||||
updatedAt: new Date('2021-01-01'),
|
||||
memoriesEnabled: true,
|
||||
avatarColor: UserAvatarColor.GRAY,
|
||||
quotaSizeInBytes: null,
|
||||
inTimeline: true,
|
||||
quotaUsageInBytes: 0,
|
||||
},
|
||||
user1: <PartnerResponseDto>{
|
||||
email: 'immich@test.com',
|
||||
name: 'immich_name',
|
||||
id: 'user-id',
|
||||
isAdmin: false,
|
||||
oauthId: '',
|
||||
profileImagePath: '',
|
||||
shouldChangePassword: false,
|
||||
storageLabel: null,
|
||||
createdAt: new Date('2021-01-01'),
|
||||
deletedAt: null,
|
||||
updatedAt: new Date('2021-01-01'),
|
||||
memoriesEnabled: true,
|
||||
avatarColor: UserAvatarColor.PRIMARY,
|
||||
inTimeline: true,
|
||||
quotaSizeInBytes: null,
|
||||
quotaUsageInBytes: 0,
|
||||
},
|
||||
};
|
||||
|
||||
describe(PartnerService.name, () => {
|
||||
let sut: PartnerService;
|
||||
let partnerMock: Mocked<IPartnerRepository>;
|
||||
|
|
@ -65,13 +24,13 @@ describe(PartnerService.name, () => {
|
|||
describe('getAll', () => {
|
||||
it("should return a list of partners with whom I've shared my library", async () => {
|
||||
partnerMock.getAll.mockResolvedValue([partnerStub.adminToUser1, partnerStub.user1ToAdmin1]);
|
||||
await expect(sut.getAll(authStub.user1, PartnerDirection.SharedBy)).resolves.toEqual([responseDto.admin]);
|
||||
await expect(sut.getAll(authStub.user1, PartnerDirection.SharedBy)).resolves.toBeDefined();
|
||||
expect(partnerMock.getAll).toHaveBeenCalledWith(authStub.user1.user.id);
|
||||
});
|
||||
|
||||
it('should return a list of partners who have shared their libraries with me', async () => {
|
||||
partnerMock.getAll.mockResolvedValue([partnerStub.adminToUser1, partnerStub.user1ToAdmin1]);
|
||||
await expect(sut.getAll(authStub.user1, PartnerDirection.SharedWith)).resolves.toEqual([responseDto.admin]);
|
||||
await expect(sut.getAll(authStub.user1, PartnerDirection.SharedWith)).resolves.toBeDefined();
|
||||
expect(partnerMock.getAll).toHaveBeenCalledWith(authStub.user1.user.id);
|
||||
});
|
||||
});
|
||||
|
|
@ -81,7 +40,7 @@ describe(PartnerService.name, () => {
|
|||
partnerMock.get.mockResolvedValue(null);
|
||||
partnerMock.create.mockResolvedValue(partnerStub.adminToUser1);
|
||||
|
||||
await expect(sut.create(authStub.admin, authStub.user1.user.id)).resolves.toEqual(responseDto.user1);
|
||||
await expect(sut.create(authStub.admin, authStub.user1.user.id)).resolves.toBeDefined();
|
||||
|
||||
expect(partnerMock.create).toHaveBeenCalledWith({
|
||||
sharedById: authStub.admin.user.id,
|
||||
|
|
|
|||
|
|
@ -25,7 +25,7 @@ export class PartnerService {
|
|||
}
|
||||
|
||||
const partner = await this.repository.create(partnerId);
|
||||
return this.mapToPartnerEntity(partner, PartnerDirection.SharedBy);
|
||||
return this.mapPartner(partner, PartnerDirection.SharedBy);
|
||||
}
|
||||
|
||||
async remove(auth: AuthDto, sharedWithId: string): Promise<void> {
|
||||
|
|
@ -44,7 +44,7 @@ export class PartnerService {
|
|||
return partners
|
||||
.filter((partner) => partner.sharedBy && partner.sharedWith) // Filter out soft deleted users
|
||||
.filter((partner) => partner[key] === auth.user.id)
|
||||
.map((partner) => this.mapToPartnerEntity(partner, direction));
|
||||
.map((partner) => this.mapPartner(partner, direction));
|
||||
}
|
||||
|
||||
async update(auth: AuthDto, sharedById: string, dto: UpdatePartnerDto): Promise<PartnerResponseDto> {
|
||||
|
|
@ -52,10 +52,10 @@ export class PartnerService {
|
|||
const partnerId: PartnerIds = { sharedById, sharedWithId: auth.user.id };
|
||||
|
||||
const entity = await this.repository.update({ ...partnerId, inTimeline: dto.inTimeline });
|
||||
return this.mapToPartnerEntity(entity, PartnerDirection.SharedWith);
|
||||
return this.mapPartner(entity, PartnerDirection.SharedWith);
|
||||
}
|
||||
|
||||
private mapToPartnerEntity(partner: PartnerEntity, direction: PartnerDirection): PartnerResponseDto {
|
||||
private mapPartner(partner: PartnerEntity, direction: PartnerDirection): PartnerResponseDto {
|
||||
// this is opposite to return the non-me user of the "partner"
|
||||
const user = mapUser(
|
||||
direction === PartnerDirection.SharedBy ? partner.sharedWith : partner.sharedBy,
|
||||
|
|
|
|||
197
server/src/services/user-admin.service.spec.ts
Normal file
197
server/src/services/user-admin.service.spec.ts
Normal file
|
|
@ -0,0 +1,197 @@
|
|||
import { BadRequestException, ForbiddenException } from '@nestjs/common';
|
||||
import { mapUserAdmin } from 'src/dtos/user.dto';
|
||||
import { UserStatus } from 'src/entities/user.entity';
|
||||
import { IAlbumRepository } from 'src/interfaces/album.interface';
|
||||
import { ICryptoRepository } from 'src/interfaces/crypto.interface';
|
||||
import { IJobRepository, JobName } from 'src/interfaces/job.interface';
|
||||
import { ILoggerRepository } from 'src/interfaces/logger.interface';
|
||||
import { IUserRepository } from 'src/interfaces/user.interface';
|
||||
import { UserAdminService } from 'src/services/user-admin.service';
|
||||
import { authStub } from 'test/fixtures/auth.stub';
|
||||
import { userStub } from 'test/fixtures/user.stub';
|
||||
import { newAlbumRepositoryMock } from 'test/repositories/album.repository.mock';
|
||||
import { newCryptoRepositoryMock } from 'test/repositories/crypto.repository.mock';
|
||||
import { newJobRepositoryMock } from 'test/repositories/job.repository.mock';
|
||||
import { newLoggerRepositoryMock } from 'test/repositories/logger.repository.mock';
|
||||
import { newUserRepositoryMock } from 'test/repositories/user.repository.mock';
|
||||
import { Mocked, describe } from 'vitest';
|
||||
|
||||
describe(UserAdminService.name, () => {
|
||||
let sut: UserAdminService;
|
||||
let userMock: Mocked<IUserRepository>;
|
||||
let cryptoRepositoryMock: Mocked<ICryptoRepository>;
|
||||
|
||||
let albumMock: Mocked<IAlbumRepository>;
|
||||
let jobMock: Mocked<IJobRepository>;
|
||||
let loggerMock: Mocked<ILoggerRepository>;
|
||||
|
||||
beforeEach(() => {
|
||||
albumMock = newAlbumRepositoryMock();
|
||||
cryptoRepositoryMock = newCryptoRepositoryMock();
|
||||
jobMock = newJobRepositoryMock();
|
||||
userMock = newUserRepositoryMock();
|
||||
loggerMock = newLoggerRepositoryMock();
|
||||
|
||||
sut = new UserAdminService(albumMock, cryptoRepositoryMock, jobMock, userMock, loggerMock);
|
||||
|
||||
userMock.get.mockImplementation((userId) =>
|
||||
Promise.resolve([userStub.admin, userStub.user1].find((user) => user.id === userId) ?? null),
|
||||
);
|
||||
});
|
||||
|
||||
describe('create', () => {
|
||||
it('should not create a user if there is no local admin account', async () => {
|
||||
userMock.getAdmin.mockResolvedValueOnce(null);
|
||||
|
||||
await expect(
|
||||
sut.create({
|
||||
email: 'john_smith@email.com',
|
||||
name: 'John Smith',
|
||||
password: 'password',
|
||||
}),
|
||||
).rejects.toBeInstanceOf(BadRequestException);
|
||||
});
|
||||
|
||||
it('should create user', async () => {
|
||||
userMock.getAdmin.mockResolvedValue(userStub.admin);
|
||||
userMock.create.mockResolvedValue(userStub.user1);
|
||||
|
||||
await expect(
|
||||
sut.create({
|
||||
email: userStub.user1.email,
|
||||
name: userStub.user1.name,
|
||||
password: 'password',
|
||||
storageLabel: 'label',
|
||||
}),
|
||||
).resolves.toEqual(mapUserAdmin(userStub.user1));
|
||||
|
||||
expect(userMock.getAdmin).toBeCalled();
|
||||
expect(userMock.create).toBeCalledWith({
|
||||
email: userStub.user1.email,
|
||||
name: userStub.user1.name,
|
||||
storageLabel: 'label',
|
||||
password: expect.anything(),
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('update', () => {
|
||||
it('should update the user', async () => {
|
||||
const update = {
|
||||
shouldChangePassword: true,
|
||||
email: 'immich@test.com',
|
||||
storageLabel: 'storage_label',
|
||||
};
|
||||
userMock.getByEmail.mockResolvedValue(null);
|
||||
userMock.getByStorageLabel.mockResolvedValue(null);
|
||||
userMock.update.mockResolvedValue(userStub.user1);
|
||||
|
||||
await sut.update(authStub.user1, userStub.user1.id, update);
|
||||
|
||||
expect(userMock.getByEmail).toHaveBeenCalledWith(update.email);
|
||||
expect(userMock.getByStorageLabel).toHaveBeenCalledWith(update.storageLabel);
|
||||
});
|
||||
|
||||
it('should not set an empty string for storage label', async () => {
|
||||
userMock.update.mockResolvedValue(userStub.user1);
|
||||
await sut.update(authStub.admin, userStub.user1.id, { storageLabel: '' });
|
||||
expect(userMock.update).toHaveBeenCalledWith(userStub.user1.id, {
|
||||
storageLabel: null,
|
||||
updatedAt: expect.any(Date),
|
||||
});
|
||||
});
|
||||
|
||||
it('should not change an email to one already in use', async () => {
|
||||
const dto = { id: userStub.user1.id, email: 'updated@test.com' };
|
||||
|
||||
userMock.get.mockResolvedValue(userStub.user1);
|
||||
userMock.getByEmail.mockResolvedValue(userStub.admin);
|
||||
|
||||
await expect(sut.update(authStub.admin, userStub.user1.id, dto)).rejects.toBeInstanceOf(BadRequestException);
|
||||
|
||||
expect(userMock.update).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should not let the admin change the storage label to one already in use', async () => {
|
||||
const dto = { id: userStub.user1.id, storageLabel: 'admin' };
|
||||
|
||||
userMock.get.mockResolvedValue(userStub.user1);
|
||||
userMock.getByStorageLabel.mockResolvedValue(userStub.admin);
|
||||
|
||||
await expect(sut.update(authStub.admin, userStub.user1.id, dto)).rejects.toBeInstanceOf(BadRequestException);
|
||||
|
||||
expect(userMock.update).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('update user information should throw error if user not found', async () => {
|
||||
userMock.get.mockResolvedValueOnce(null);
|
||||
|
||||
await expect(
|
||||
sut.update(authStub.admin, userStub.user1.id, { shouldChangePassword: true }),
|
||||
).rejects.toBeInstanceOf(BadRequestException);
|
||||
});
|
||||
});
|
||||
|
||||
describe('delete', () => {
|
||||
it('should throw error if user could not be found', async () => {
|
||||
userMock.get.mockResolvedValue(null);
|
||||
|
||||
await expect(sut.delete(authStub.admin, userStub.admin.id, {})).rejects.toThrowError(BadRequestException);
|
||||
expect(userMock.delete).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('cannot delete admin user', async () => {
|
||||
await expect(sut.delete(authStub.admin, userStub.admin.id, {})).rejects.toBeInstanceOf(ForbiddenException);
|
||||
});
|
||||
|
||||
it('should require the auth user be an admin', async () => {
|
||||
await expect(sut.delete(authStub.user1, authStub.admin.user.id, {})).rejects.toBeInstanceOf(ForbiddenException);
|
||||
|
||||
expect(userMock.delete).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should delete user', async () => {
|
||||
userMock.get.mockResolvedValue(userStub.user1);
|
||||
userMock.update.mockResolvedValue(userStub.user1);
|
||||
|
||||
await expect(sut.delete(authStub.admin, userStub.user1.id, {})).resolves.toEqual(mapUserAdmin(userStub.user1));
|
||||
expect(userMock.update).toHaveBeenCalledWith(userStub.user1.id, {
|
||||
status: UserStatus.DELETED,
|
||||
deletedAt: expect.any(Date),
|
||||
});
|
||||
});
|
||||
|
||||
it('should force delete user', async () => {
|
||||
userMock.get.mockResolvedValue(userStub.user1);
|
||||
userMock.update.mockResolvedValue(userStub.user1);
|
||||
|
||||
await expect(sut.delete(authStub.admin, userStub.user1.id, { force: true })).resolves.toEqual(
|
||||
mapUserAdmin(userStub.user1),
|
||||
);
|
||||
|
||||
expect(userMock.update).toHaveBeenCalledWith(userStub.user1.id, {
|
||||
status: UserStatus.REMOVING,
|
||||
deletedAt: expect.any(Date),
|
||||
});
|
||||
expect(jobMock.queue).toHaveBeenCalledWith({
|
||||
name: JobName.USER_DELETION,
|
||||
data: { id: userStub.user1.id, force: true },
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('restore', () => {
|
||||
it('should throw error if user could not be found', async () => {
|
||||
userMock.get.mockResolvedValue(null);
|
||||
await expect(sut.restore(authStub.admin, userStub.admin.id)).rejects.toThrowError(BadRequestException);
|
||||
expect(userMock.update).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should restore an user', async () => {
|
||||
userMock.get.mockResolvedValue(userStub.user1);
|
||||
userMock.update.mockResolvedValue(userStub.user1);
|
||||
await expect(sut.restore(authStub.admin, userStub.user1.id)).resolves.toEqual(mapUserAdmin(userStub.user1));
|
||||
expect(userMock.update).toHaveBeenCalledWith(userStub.user1.id, { status: UserStatus.ACTIVE, deletedAt: null });
|
||||
});
|
||||
});
|
||||
});
|
||||
154
server/src/services/user-admin.service.ts
Normal file
154
server/src/services/user-admin.service.ts
Normal file
|
|
@ -0,0 +1,154 @@
|
|||
import { BadRequestException, ForbiddenException, Inject, Injectable } from '@nestjs/common';
|
||||
import { SALT_ROUNDS } from 'src/constants';
|
||||
import { UserCore } from 'src/cores/user.core';
|
||||
import { AuthDto } from 'src/dtos/auth.dto';
|
||||
import {
|
||||
UserAdminCreateDto,
|
||||
UserAdminDeleteDto,
|
||||
UserAdminResponseDto,
|
||||
UserAdminSearchDto,
|
||||
UserAdminUpdateDto,
|
||||
mapUserAdmin,
|
||||
} from 'src/dtos/user.dto';
|
||||
import { UserMetadataKey } from 'src/entities/user-metadata.entity';
|
||||
import { UserStatus } from 'src/entities/user.entity';
|
||||
import { IAlbumRepository } from 'src/interfaces/album.interface';
|
||||
import { ICryptoRepository } from 'src/interfaces/crypto.interface';
|
||||
import { IJobRepository, JobName } from 'src/interfaces/job.interface';
|
||||
import { ILoggerRepository } from 'src/interfaces/logger.interface';
|
||||
import { IUserRepository, UserFindOptions } from 'src/interfaces/user.interface';
|
||||
import { getPreferences, getPreferencesPartial } from 'src/utils/preferences';
|
||||
|
||||
@Injectable()
|
||||
export class UserAdminService {
|
||||
private userCore: UserCore;
|
||||
|
||||
constructor(
|
||||
@Inject(IAlbumRepository) private albumRepository: IAlbumRepository,
|
||||
@Inject(ICryptoRepository) private cryptoRepository: ICryptoRepository,
|
||||
@Inject(IJobRepository) private jobRepository: IJobRepository,
|
||||
@Inject(IUserRepository) private userRepository: IUserRepository,
|
||||
@Inject(ILoggerRepository) private logger: ILoggerRepository,
|
||||
) {
|
||||
this.userCore = UserCore.create(cryptoRepository, userRepository);
|
||||
this.logger.setContext(UserAdminService.name);
|
||||
}
|
||||
|
||||
async search(auth: AuthDto, dto: UserAdminSearchDto): Promise<UserAdminResponseDto[]> {
|
||||
const users = await this.userRepository.getList({ withDeleted: dto.withDeleted });
|
||||
return users.map((user) => mapUserAdmin(user));
|
||||
}
|
||||
|
||||
async create(dto: UserAdminCreateDto): Promise<UserAdminResponseDto> {
|
||||
const { memoriesEnabled, notify, ...rest } = dto;
|
||||
let user = await this.userCore.createUser(rest);
|
||||
|
||||
// TODO remove and replace with entire dto.preferences config
|
||||
if (memoriesEnabled === false) {
|
||||
await this.userRepository.upsertMetadata(user.id, {
|
||||
key: UserMetadataKey.PREFERENCES,
|
||||
value: { memories: { enabled: false } },
|
||||
});
|
||||
|
||||
user = await this.findOrFail(user.id, {});
|
||||
}
|
||||
|
||||
const tempPassword = user.shouldChangePassword ? rest.password : undefined;
|
||||
if (notify) {
|
||||
await this.jobRepository.queue({ name: JobName.NOTIFY_SIGNUP, data: { id: user.id, tempPassword } });
|
||||
}
|
||||
return mapUserAdmin(user);
|
||||
}
|
||||
|
||||
async get(auth: AuthDto, id: string): Promise<UserAdminResponseDto> {
|
||||
const user = await this.findOrFail(id, { withDeleted: true });
|
||||
return mapUserAdmin(user);
|
||||
}
|
||||
|
||||
async update(auth: AuthDto, id: string, dto: UserAdminUpdateDto): Promise<UserAdminResponseDto> {
|
||||
const user = await this.findOrFail(id, {});
|
||||
|
||||
if (dto.quotaSizeInBytes && user.quotaSizeInBytes !== dto.quotaSizeInBytes) {
|
||||
await this.userRepository.syncUsage(id);
|
||||
}
|
||||
|
||||
// TODO replace with entire preferences object
|
||||
if (dto.memoriesEnabled !== undefined || dto.avatarColor) {
|
||||
const newPreferences = getPreferences(user);
|
||||
if (dto.memoriesEnabled !== undefined) {
|
||||
newPreferences.memories.enabled = dto.memoriesEnabled;
|
||||
delete dto.memoriesEnabled;
|
||||
}
|
||||
|
||||
if (dto.avatarColor) {
|
||||
newPreferences.avatar.color = dto.avatarColor;
|
||||
delete dto.avatarColor;
|
||||
}
|
||||
|
||||
await this.userRepository.upsertMetadata(id, {
|
||||
key: UserMetadataKey.PREFERENCES,
|
||||
value: getPreferencesPartial(user, newPreferences),
|
||||
});
|
||||
}
|
||||
|
||||
if (dto.email) {
|
||||
const duplicate = await this.userRepository.getByEmail(dto.email);
|
||||
if (duplicate && duplicate.id !== id) {
|
||||
throw new BadRequestException('Email already in use by another account');
|
||||
}
|
||||
}
|
||||
|
||||
if (dto.storageLabel) {
|
||||
const duplicate = await this.userRepository.getByStorageLabel(dto.storageLabel);
|
||||
if (duplicate && duplicate.id !== id) {
|
||||
throw new BadRequestException('Storage label already in use by another account');
|
||||
}
|
||||
}
|
||||
|
||||
if (dto.password) {
|
||||
dto.password = await this.cryptoRepository.hashBcrypt(dto.password, SALT_ROUNDS);
|
||||
}
|
||||
|
||||
if (dto.storageLabel === '') {
|
||||
dto.storageLabel = null;
|
||||
}
|
||||
|
||||
const updatedUser = await this.userRepository.update(id, { ...dto, updatedAt: new Date() });
|
||||
|
||||
return mapUserAdmin(updatedUser);
|
||||
}
|
||||
|
||||
async delete(auth: AuthDto, id: string, dto: UserAdminDeleteDto): Promise<UserAdminResponseDto> {
|
||||
const { force } = dto;
|
||||
const { isAdmin } = await this.findOrFail(id, {});
|
||||
if (isAdmin) {
|
||||
throw new ForbiddenException('Cannot delete admin user');
|
||||
}
|
||||
|
||||
await this.albumRepository.softDeleteAll(id);
|
||||
|
||||
const status = force ? UserStatus.REMOVING : UserStatus.DELETED;
|
||||
const user = await this.userRepository.update(id, { status, deletedAt: new Date() });
|
||||
|
||||
if (force) {
|
||||
await this.jobRepository.queue({ name: JobName.USER_DELETION, data: { id: user.id, force } });
|
||||
}
|
||||
|
||||
return mapUserAdmin(user);
|
||||
}
|
||||
|
||||
async restore(auth: AuthDto, id: string): Promise<UserAdminResponseDto> {
|
||||
await this.findOrFail(id, { withDeleted: true });
|
||||
await this.albumRepository.restoreAll(id);
|
||||
const user = await this.userRepository.update(id, { deletedAt: null, status: UserStatus.ACTIVE });
|
||||
return mapUserAdmin(user);
|
||||
}
|
||||
|
||||
private async findOrFail(id: string, options: UserFindOptions) {
|
||||
const user = await this.userRepository.get(id, options);
|
||||
if (!user) {
|
||||
throw new BadRequestException('User not found');
|
||||
}
|
||||
return user;
|
||||
}
|
||||
}
|
||||
|
|
@ -1,11 +1,5 @@
|
|||
import {
|
||||
BadRequestException,
|
||||
ForbiddenException,
|
||||
InternalServerErrorException,
|
||||
NotFoundException,
|
||||
} from '@nestjs/common';
|
||||
import { UpdateUserDto, mapUser } from 'src/dtos/user.dto';
|
||||
import { UserEntity, UserStatus } from 'src/entities/user.entity';
|
||||
import { BadRequestException, InternalServerErrorException, NotFoundException } from '@nestjs/common';
|
||||
import { UserEntity } from 'src/entities/user.entity';
|
||||
import { IAlbumRepository } from 'src/interfaces/album.interface';
|
||||
import { ICryptoRepository } from 'src/interfaces/crypto.interface';
|
||||
import { IJobRepository, JobName } from 'src/interfaces/job.interface';
|
||||
|
|
@ -63,13 +57,13 @@ describe(UserService.name, () => {
|
|||
describe('getAll', () => {
|
||||
it('should get all users', async () => {
|
||||
userMock.getList.mockResolvedValue([userStub.admin]);
|
||||
await expect(sut.getAll(authStub.admin, false)).resolves.toEqual([
|
||||
await expect(sut.search()).resolves.toEqual([
|
||||
expect.objectContaining({
|
||||
id: authStub.admin.user.id,
|
||||
email: authStub.admin.user.email,
|
||||
}),
|
||||
]);
|
||||
expect(userMock.getList).toHaveBeenCalledWith({ withDeleted: true });
|
||||
expect(userMock.getList).toHaveBeenCalledWith({ withDeleted: false });
|
||||
});
|
||||
});
|
||||
|
||||
|
|
@ -82,255 +76,17 @@ describe(UserService.name, () => {
|
|||
|
||||
it('should throw an error if a user is not found', async () => {
|
||||
userMock.get.mockResolvedValue(null);
|
||||
await expect(sut.get(authStub.admin.user.id)).rejects.toBeInstanceOf(NotFoundException);
|
||||
await expect(sut.get(authStub.admin.user.id)).rejects.toBeInstanceOf(BadRequestException);
|
||||
expect(userMock.get).toHaveBeenCalledWith(authStub.admin.user.id, { withDeleted: false });
|
||||
});
|
||||
});
|
||||
|
||||
describe('getMe', () => {
|
||||
it("should get the auth user's info", async () => {
|
||||
userMock.get.mockResolvedValue(userStub.admin);
|
||||
await sut.getMe(authStub.admin);
|
||||
expect(userMock.get).toHaveBeenCalledWith(authStub.admin.user.id, {});
|
||||
});
|
||||
|
||||
it('should throw an error if a user is not found', async () => {
|
||||
userMock.get.mockResolvedValue(null);
|
||||
await expect(sut.getMe(authStub.admin)).rejects.toBeInstanceOf(BadRequestException);
|
||||
expect(userMock.get).toHaveBeenCalledWith(authStub.admin.user.id, {});
|
||||
});
|
||||
});
|
||||
|
||||
describe('update', () => {
|
||||
it('should update user', async () => {
|
||||
const update: UpdateUserDto = {
|
||||
id: userStub.user1.id,
|
||||
shouldChangePassword: true,
|
||||
email: 'immich@test.com',
|
||||
storageLabel: 'storage_label',
|
||||
};
|
||||
userMock.getByEmail.mockResolvedValue(null);
|
||||
userMock.getByStorageLabel.mockResolvedValue(null);
|
||||
userMock.update.mockResolvedValue(userStub.user1);
|
||||
|
||||
await sut.update({ user: { ...authStub.user1.user, isAdmin: true } }, update);
|
||||
|
||||
expect(userMock.getByEmail).toHaveBeenCalledWith(update.email);
|
||||
expect(userMock.getByStorageLabel).toHaveBeenCalledWith(update.storageLabel);
|
||||
});
|
||||
|
||||
it('should not set an empty string for storage label', async () => {
|
||||
userMock.update.mockResolvedValue(userStub.user1);
|
||||
await sut.update(authStub.admin, { id: userStub.user1.id, storageLabel: '' });
|
||||
expect(userMock.update).toHaveBeenCalledWith(userStub.user1.id, {
|
||||
id: userStub.user1.id,
|
||||
storageLabel: null,
|
||||
updatedAt: expect.any(Date),
|
||||
});
|
||||
});
|
||||
|
||||
it('should omit a storage label set by non-admin users', async () => {
|
||||
userMock.update.mockResolvedValue(userStub.user1);
|
||||
await sut.update({ user: userStub.user1 }, { id: userStub.user1.id, storageLabel: 'admin' });
|
||||
expect(userMock.update).toHaveBeenCalledWith(userStub.user1.id, {
|
||||
id: userStub.user1.id,
|
||||
updatedAt: expect.any(Date),
|
||||
});
|
||||
});
|
||||
|
||||
it('user can only update its information', async () => {
|
||||
userMock.get.mockResolvedValueOnce({
|
||||
...userStub.user1,
|
||||
id: 'not_immich_auth_user_id',
|
||||
});
|
||||
|
||||
const result = sut.update(
|
||||
{ user: userStub.user1 },
|
||||
{
|
||||
id: 'not_immich_auth_user_id',
|
||||
password: 'I take over your account now',
|
||||
},
|
||||
);
|
||||
await expect(result).rejects.toBeInstanceOf(ForbiddenException);
|
||||
});
|
||||
|
||||
it('should let a user change their email', async () => {
|
||||
const dto = { id: userStub.user1.id, email: 'updated@test.com' };
|
||||
|
||||
userMock.get.mockResolvedValue(userStub.user1);
|
||||
userMock.update.mockResolvedValue(userStub.user1);
|
||||
|
||||
await sut.update({ user: userStub.user1 }, dto);
|
||||
|
||||
expect(userMock.update).toHaveBeenCalledWith(userStub.user1.id, {
|
||||
id: 'user-id',
|
||||
email: 'updated@test.com',
|
||||
updatedAt: expect.any(Date),
|
||||
});
|
||||
});
|
||||
|
||||
it('should not let a user change their email to one already in use', async () => {
|
||||
const dto = { id: userStub.user1.id, email: 'updated@test.com' };
|
||||
|
||||
userMock.get.mockResolvedValue(userStub.user1);
|
||||
userMock.getByEmail.mockResolvedValue(userStub.admin);
|
||||
|
||||
await expect(sut.update({ user: userStub.user1 }, dto)).rejects.toBeInstanceOf(BadRequestException);
|
||||
|
||||
expect(userMock.update).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should not let the admin change the storage label to one already in use', async () => {
|
||||
const dto = { id: userStub.user1.id, storageLabel: 'admin' };
|
||||
|
||||
userMock.get.mockResolvedValue(userStub.user1);
|
||||
userMock.getByStorageLabel.mockResolvedValue(userStub.admin);
|
||||
|
||||
await expect(sut.update(authStub.admin, dto)).rejects.toBeInstanceOf(BadRequestException);
|
||||
|
||||
expect(userMock.update).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('admin can update any user information', async () => {
|
||||
const update: UpdateUserDto = {
|
||||
id: userStub.user1.id,
|
||||
shouldChangePassword: true,
|
||||
};
|
||||
|
||||
userMock.update.mockResolvedValueOnce(userStub.user1);
|
||||
await sut.update(authStub.admin, update);
|
||||
expect(userMock.update).toHaveBeenCalledWith(userStub.user1.id, {
|
||||
id: 'user-id',
|
||||
shouldChangePassword: true,
|
||||
updatedAt: expect.any(Date),
|
||||
});
|
||||
});
|
||||
|
||||
it('update user information should throw error if user not found', async () => {
|
||||
userMock.get.mockResolvedValueOnce(null);
|
||||
|
||||
const result = sut.update(authStub.admin, {
|
||||
id: userStub.user1.id,
|
||||
shouldChangePassword: true,
|
||||
});
|
||||
|
||||
await expect(result).rejects.toBeInstanceOf(BadRequestException);
|
||||
});
|
||||
|
||||
it('should let the admin update himself', async () => {
|
||||
const dto = { id: userStub.admin.id, shouldChangePassword: true, isAdmin: true };
|
||||
|
||||
userMock.update.mockResolvedValueOnce(userStub.admin);
|
||||
|
||||
await sut.update(authStub.admin, dto);
|
||||
|
||||
expect(userMock.update).toHaveBeenCalledWith(userStub.admin.id, { ...dto, updatedAt: expect.any(Date) });
|
||||
});
|
||||
|
||||
it('should not let the another user become an admin', async () => {
|
||||
const dto = { id: userStub.user1.id, shouldChangePassword: true, isAdmin: true };
|
||||
|
||||
userMock.get.mockResolvedValueOnce(userStub.user1);
|
||||
|
||||
await expect(sut.update(authStub.admin, dto)).rejects.toBeInstanceOf(BadRequestException);
|
||||
});
|
||||
});
|
||||
|
||||
describe('restore', () => {
|
||||
it('should throw error if user could not be found', async () => {
|
||||
userMock.get.mockResolvedValue(null);
|
||||
await expect(sut.restore(authStub.admin, userStub.admin.id)).rejects.toThrowError(BadRequestException);
|
||||
expect(userMock.update).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should restore an user', async () => {
|
||||
userMock.get.mockResolvedValue(userStub.user1);
|
||||
userMock.update.mockResolvedValue(userStub.user1);
|
||||
await expect(sut.restore(authStub.admin, userStub.user1.id)).resolves.toEqual(mapUser(userStub.user1));
|
||||
expect(userMock.update).toHaveBeenCalledWith(userStub.user1.id, { status: UserStatus.ACTIVE, deletedAt: null });
|
||||
});
|
||||
});
|
||||
|
||||
describe('delete', () => {
|
||||
it('should throw error if user could not be found', async () => {
|
||||
userMock.get.mockResolvedValue(null);
|
||||
|
||||
await expect(sut.delete(authStub.admin, userStub.admin.id, {})).rejects.toThrowError(BadRequestException);
|
||||
expect(userMock.delete).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('cannot delete admin user', async () => {
|
||||
await expect(sut.delete(authStub.admin, userStub.admin.id, {})).rejects.toBeInstanceOf(ForbiddenException);
|
||||
});
|
||||
|
||||
it('should require the auth user be an admin', async () => {
|
||||
await expect(sut.delete(authStub.user1, authStub.admin.user.id, {})).rejects.toBeInstanceOf(ForbiddenException);
|
||||
|
||||
expect(userMock.delete).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should delete user', async () => {
|
||||
userMock.get.mockResolvedValue(userStub.user1);
|
||||
userMock.update.mockResolvedValue(userStub.user1);
|
||||
|
||||
await expect(sut.delete(authStub.admin, userStub.user1.id, {})).resolves.toEqual(mapUser(userStub.user1));
|
||||
expect(userMock.update).toHaveBeenCalledWith(userStub.user1.id, {
|
||||
status: UserStatus.DELETED,
|
||||
deletedAt: expect.any(Date),
|
||||
});
|
||||
});
|
||||
|
||||
it('should force delete user', async () => {
|
||||
userMock.get.mockResolvedValue(userStub.user1);
|
||||
userMock.update.mockResolvedValue(userStub.user1);
|
||||
|
||||
await expect(sut.delete(authStub.admin, userStub.user1.id, { force: true })).resolves.toEqual(
|
||||
mapUser(userStub.user1),
|
||||
);
|
||||
|
||||
expect(userMock.update).toHaveBeenCalledWith(userStub.user1.id, {
|
||||
status: UserStatus.REMOVING,
|
||||
deletedAt: expect.any(Date),
|
||||
});
|
||||
expect(jobMock.queue).toHaveBeenCalledWith({
|
||||
name: JobName.USER_DELETION,
|
||||
data: { id: userStub.user1.id, force: true },
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('create', () => {
|
||||
it('should not create a user if there is no local admin account', async () => {
|
||||
userMock.getAdmin.mockResolvedValueOnce(null);
|
||||
|
||||
await expect(
|
||||
sut.create({
|
||||
email: 'john_smith@email.com',
|
||||
name: 'John Smith',
|
||||
password: 'password',
|
||||
}),
|
||||
).rejects.toBeInstanceOf(BadRequestException);
|
||||
});
|
||||
|
||||
it('should create user', async () => {
|
||||
userMock.getAdmin.mockResolvedValue(userStub.admin);
|
||||
userMock.create.mockResolvedValue(userStub.user1);
|
||||
|
||||
await expect(
|
||||
sut.create({
|
||||
email: userStub.user1.email,
|
||||
name: userStub.user1.name,
|
||||
password: 'password',
|
||||
storageLabel: 'label',
|
||||
}),
|
||||
).resolves.toEqual(mapUser(userStub.user1));
|
||||
|
||||
expect(userMock.getAdmin).toBeCalled();
|
||||
expect(userMock.create).toBeCalledWith({
|
||||
email: userStub.user1.email,
|
||||
name: userStub.user1.name,
|
||||
storageLabel: 'label',
|
||||
password: expect.anything(),
|
||||
it("should get the auth user's info", () => {
|
||||
const user = authStub.admin.user;
|
||||
expect(sut.getMe(authStub.admin)).toMatchObject({
|
||||
id: user.id,
|
||||
email: user.email,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,13 +1,13 @@
|
|||
import { BadRequestException, ForbiddenException, Inject, Injectable, NotFoundException } from '@nestjs/common';
|
||||
import { BadRequestException, Inject, Injectable, NotFoundException } from '@nestjs/common';
|
||||
import { DateTime } from 'luxon';
|
||||
import { SALT_ROUNDS } from 'src/constants';
|
||||
import { StorageCore, StorageFolder } from 'src/cores/storage.core';
|
||||
import { SystemConfigCore } from 'src/cores/system-config.core';
|
||||
import { UserCore } from 'src/cores/user.core';
|
||||
import { AuthDto } from 'src/dtos/auth.dto';
|
||||
import { CreateProfileImageResponseDto, mapCreateProfileImageResponse } from 'src/dtos/user-profile.dto';
|
||||
import { CreateUserDto, DeleteUserDto, UpdateUserDto, UserResponseDto, mapUser } from 'src/dtos/user.dto';
|
||||
import { UserAdminResponseDto, UserResponseDto, UserUpdateMeDto, mapUser, mapUserAdmin } from 'src/dtos/user.dto';
|
||||
import { UserMetadataKey } from 'src/entities/user-metadata.entity';
|
||||
import { UserEntity, UserStatus } from 'src/entities/user.entity';
|
||||
import { UserEntity } from 'src/entities/user.entity';
|
||||
import { IAlbumRepository } from 'src/interfaces/album.interface';
|
||||
import { ICryptoRepository } from 'src/interfaces/crypto.interface';
|
||||
import { IEntityJob, IJobRepository, JobName, JobStatus } from 'src/interfaces/job.interface';
|
||||
|
|
@ -21,73 +21,30 @@ import { getPreferences, getPreferencesPartial } from 'src/utils/preferences';
|
|||
@Injectable()
|
||||
export class UserService {
|
||||
private configCore: SystemConfigCore;
|
||||
private userCore: UserCore;
|
||||
|
||||
constructor(
|
||||
@Inject(IAlbumRepository) private albumRepository: IAlbumRepository,
|
||||
@Inject(ICryptoRepository) cryptoRepository: ICryptoRepository,
|
||||
@Inject(ICryptoRepository) private cryptoRepository: ICryptoRepository,
|
||||
@Inject(IJobRepository) private jobRepository: IJobRepository,
|
||||
@Inject(IStorageRepository) private storageRepository: IStorageRepository,
|
||||
@Inject(ISystemMetadataRepository) systemMetadataRepository: ISystemMetadataRepository,
|
||||
@Inject(IUserRepository) private userRepository: IUserRepository,
|
||||
@Inject(ILoggerRepository) private logger: ILoggerRepository,
|
||||
) {
|
||||
this.userCore = UserCore.create(cryptoRepository, userRepository);
|
||||
this.logger.setContext(UserService.name);
|
||||
this.configCore = SystemConfigCore.create(systemMetadataRepository, this.logger);
|
||||
}
|
||||
|
||||
async listUsers(): Promise<UserResponseDto[]> {
|
||||
const users = await this.userRepository.getList({ withDeleted: true });
|
||||
async search(): Promise<UserResponseDto[]> {
|
||||
const users = await this.userRepository.getList({ withDeleted: false });
|
||||
return users.map((user) => mapUser(user));
|
||||
}
|
||||
|
||||
async getAll(auth: AuthDto, isAll: boolean): Promise<UserResponseDto[]> {
|
||||
const users = await this.userRepository.getList({ withDeleted: !isAll });
|
||||
return users.map((user) => mapUser(user));
|
||||
getMe(auth: AuthDto): UserAdminResponseDto {
|
||||
return mapUserAdmin(auth.user);
|
||||
}
|
||||
|
||||
async get(userId: string): Promise<UserResponseDto> {
|
||||
const user = await this.userRepository.get(userId, { withDeleted: false });
|
||||
if (!user) {
|
||||
throw new NotFoundException('User not found');
|
||||
}
|
||||
|
||||
return mapUser(user);
|
||||
}
|
||||
|
||||
getMe(auth: AuthDto): Promise<UserResponseDto> {
|
||||
return this.findOrFail(auth.user.id, {}).then(mapUser);
|
||||
}
|
||||
|
||||
async create(dto: CreateUserDto): Promise<UserResponseDto> {
|
||||
const { memoriesEnabled, notify, ...rest } = dto;
|
||||
let user = await this.userCore.createUser(rest);
|
||||
|
||||
// TODO remove and replace with entire dto.preferences config
|
||||
if (memoriesEnabled === false) {
|
||||
await this.userRepository.upsertMetadata(user.id, {
|
||||
key: UserMetadataKey.PREFERENCES,
|
||||
value: { memories: { enabled: false } },
|
||||
});
|
||||
|
||||
user = await this.findOrFail(user.id, {});
|
||||
}
|
||||
|
||||
const tempPassword = user.shouldChangePassword ? rest.password : undefined;
|
||||
if (notify) {
|
||||
await this.jobRepository.queue({ name: JobName.NOTIFY_SIGNUP, data: { id: user.id, tempPassword } });
|
||||
}
|
||||
return mapUser(user);
|
||||
}
|
||||
|
||||
async update(auth: AuthDto, dto: UpdateUserDto): Promise<UserResponseDto> {
|
||||
const user = await this.findOrFail(dto.id, {});
|
||||
|
||||
if (dto.quotaSizeInBytes && user.quotaSizeInBytes !== dto.quotaSizeInBytes) {
|
||||
await this.userRepository.syncUsage(dto.id);
|
||||
}
|
||||
|
||||
async updateMe({ user }: AuthDto, dto: UserUpdateMeDto): Promise<UserAdminResponseDto> {
|
||||
// TODO replace with entire preferences object
|
||||
if (dto.memoriesEnabled !== undefined || dto.avatarColor) {
|
||||
const newPreferences = getPreferences(user);
|
||||
|
|
@ -101,42 +58,40 @@ export class UserService {
|
|||
delete dto.avatarColor;
|
||||
}
|
||||
|
||||
await this.userRepository.upsertMetadata(dto.id, {
|
||||
await this.userRepository.upsertMetadata(user.id, {
|
||||
key: UserMetadataKey.PREFERENCES,
|
||||
value: getPreferencesPartial(user, newPreferences),
|
||||
});
|
||||
}
|
||||
|
||||
const updatedUser = await this.userCore.updateUser(auth.user, dto.id, dto);
|
||||
if (dto.email) {
|
||||
const duplicate = await this.userRepository.getByEmail(dto.email);
|
||||
if (duplicate && duplicate.id !== user.id) {
|
||||
throw new BadRequestException('Email already in use by another account');
|
||||
}
|
||||
}
|
||||
|
||||
return mapUser(updatedUser);
|
||||
const update: Partial<UserEntity> = {
|
||||
email: dto.email,
|
||||
name: dto.name,
|
||||
};
|
||||
|
||||
if (dto.password) {
|
||||
const hashedPassword = await this.cryptoRepository.hashBcrypt(dto.password, SALT_ROUNDS);
|
||||
update.password = hashedPassword;
|
||||
update.shouldChangePassword = false;
|
||||
}
|
||||
|
||||
const updatedUser = await this.userRepository.update(user.id, update);
|
||||
|
||||
return mapUserAdmin(updatedUser);
|
||||
}
|
||||
|
||||
async delete(auth: AuthDto, id: string, dto: DeleteUserDto): Promise<UserResponseDto> {
|
||||
const { force } = dto;
|
||||
const { isAdmin } = await this.findOrFail(id, {});
|
||||
if (isAdmin) {
|
||||
throw new ForbiddenException('Cannot delete admin user');
|
||||
}
|
||||
|
||||
await this.albumRepository.softDeleteAll(id);
|
||||
|
||||
const status = force ? UserStatus.REMOVING : UserStatus.DELETED;
|
||||
const user = await this.userRepository.update(id, { status, deletedAt: new Date() });
|
||||
|
||||
if (force) {
|
||||
await this.jobRepository.queue({ name: JobName.USER_DELETION, data: { id: user.id, force } });
|
||||
}
|
||||
|
||||
async get(id: string): Promise<UserResponseDto> {
|
||||
const user = await this.findOrFail(id, { withDeleted: false });
|
||||
return mapUser(user);
|
||||
}
|
||||
|
||||
async restore(auth: AuthDto, id: string): Promise<UserResponseDto> {
|
||||
await this.findOrFail(id, { withDeleted: true });
|
||||
await this.albumRepository.restoreAll(id);
|
||||
return this.userRepository.update(id, { deletedAt: null, status: UserStatus.ACTIVE }).then(mapUser);
|
||||
}
|
||||
|
||||
async createProfileImage(auth: AuthDto, fileInfo: Express.Multer.File): Promise<CreateProfileImageResponseDto> {
|
||||
const { profileImagePath: oldpath } = await this.findOrFail(auth.user.id, { withDeleted: false });
|
||||
const updatedUser = await this.userRepository.update(auth.user.id, { profileImagePath: fileInfo.path });
|
||||
|
|
|
|||
|
|
@ -154,7 +154,7 @@ export function validateCronExpression(expression: string) {
|
|||
|
||||
type IValue = { value: string };
|
||||
|
||||
export const toEmail = ({ value }: IValue) => value?.toLowerCase();
|
||||
export const toEmail = ({ value }: IValue) => (value ? value.toLowerCase() : value);
|
||||
|
||||
export const toSanitized = ({ value }: IValue) => sanitize((value || '').replaceAll('.', ''));
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue