feat(web,server): user avatar color (#4779)

This commit is contained in:
martin
2023-11-14 04:10:35 +01:00
committed by GitHub
parent 14c7187539
commit d25a245049
58 changed files with 1123 additions and 141 deletions

View File

@@ -248,6 +248,7 @@ describe('AuthService', () => {
userMock.getAdmin.mockResolvedValue(null);
userMock.create.mockResolvedValue({ ...dto, id: 'admin', createdAt: new Date('2021-01-01') } as UserEntity);
await expect(sut.adminSignUp(dto)).resolves.toEqual({
avatarColor: expect.any(String),
id: 'admin',
createdAt: new Date('2021-01-01'),
email: 'test@immich.com',

View File

@@ -1,3 +1,4 @@
import { UserAvatarColor } from '@app/infra/entities';
import { BadRequestException } from '@nestjs/common';
import { authStub, newPartnerRepositoryMock, partnerStub } from '@test';
import { IAccessRepository, IPartnerRepository, PartnerDirection } from '../repositories';
@@ -19,6 +20,7 @@ const responseDto = {
updatedAt: new Date('2021-01-01'),
externalPath: null,
memoriesEnabled: true,
avatarColor: UserAvatarColor.PRIMARY,
inTimeline: true,
},
user1: <PartnerResponseDto>{
@@ -35,6 +37,7 @@ const responseDto = {
updatedAt: new Date('2021-01-01'),
externalPath: null,
memoriesEnabled: true,
avatarColor: UserAvatarColor.PRIMARY,
inTimeline: true,
},
};

View File

@@ -1,6 +1,7 @@
import { UserAvatarColor } from '@app/infra/entities';
import { ApiProperty } from '@nestjs/swagger';
import { Transform } from 'class-transformer';
import { IsBoolean, IsEmail, IsNotEmpty, IsString, IsUUID } from 'class-validator';
import { IsBoolean, IsEmail, IsEnum, IsNotEmpty, IsString, IsUUID } from 'class-validator';
import { Optional, toEmail, toSanitized } from '../../domain.util';
export class UpdateUserDto {
@@ -44,4 +45,9 @@ export class UpdateUserDto {
@Optional()
@IsBoolean()
memoriesEnabled?: boolean;
@Optional()
@IsEnum(UserAvatarColor)
@ApiProperty({ enumName: 'UserAvatarColor', enum: UserAvatarColor })
avatarColor?: UserAvatarColor;
}

View File

@@ -1,10 +1,26 @@
import { UserEntity } from '@app/infra/entities';
import { UserAvatarColor, UserEntity } from '@app/infra/entities';
import { ApiProperty } from '@nestjs/swagger';
import { IsEnum } from 'class-validator';
export const getRandomAvatarColor = (user: UserEntity): UserAvatarColor => {
const values = Object.values(UserAvatarColor);
const randomIndex = Math.floor(
user.email
.split('')
.map((letter) => letter.charCodeAt(0))
.reduce((a, b) => a + b, 0) % values.length,
);
return values[randomIndex] as UserAvatarColor;
};
export class UserDto {
id!: string;
name!: string;
email!: string;
profileImagePath!: string;
@IsEnum(UserAvatarColor)
@ApiProperty({ enumName: 'UserAvatarColor', enum: UserAvatarColor })
avatarColor!: UserAvatarColor;
}
export class UserResponseDto extends UserDto {
@@ -25,6 +41,7 @@ export const mapSimpleUser = (entity: UserEntity): UserDto => {
email: entity.email,
name: entity.name,
profileImagePath: entity.profileImagePath,
avatarColor: entity.avatarColor ?? getRandomAvatarColor(entity),
};
};

View File

@@ -98,7 +98,6 @@ export class UserCore {
if (payload.storageLabel) {
payload.storageLabel = sanitize(payload.storageLabel);
}
const userEntity = await this.userRepository.create(payload);
await this.libraryRepository.create({
owner: { id: userEntity.id } as UserEntity,

View File

@@ -323,17 +323,52 @@ describe(UserService.name, () => {
const file = { path: '/profile/path' } as Express.Multer.File;
userMock.update.mockResolvedValue({ ...userStub.admin, profileImagePath: file.path });
await sut.createProfileImage(userStub.admin, file);
expect(userMock.update).toHaveBeenCalledWith(userStub.admin.id, { profileImagePath: file.path });
await expect(sut.createProfileImage(userStub.admin, file)).rejects.toThrowError(BadRequestException);
});
it('should throw an error if the user profile could not be updated with the new image', async () => {
const file = { path: '/profile/path' } as Express.Multer.File;
userMock.get.mockResolvedValue(userStub.profilePath);
userMock.update.mockRejectedValue(new InternalServerErrorException('mocked error'));
await expect(sut.createProfileImage(userStub.admin, file)).rejects.toThrowError(InternalServerErrorException);
});
it('should delete the previous profile image', async () => {
const file = { path: '/profile/path' } as Express.Multer.File;
userMock.get.mockResolvedValue(userStub.profilePath);
const files = [userStub.profilePath.profileImagePath];
userMock.update.mockResolvedValue({ ...userStub.admin, profileImagePath: file.path });
await sut.createProfileImage(userStub.admin, file);
await expect(jobMock.queue.mock.calls).toEqual([[{ name: JobName.DELETE_FILES, data: { files } }]]);
});
it('should not delete the profile image if it has not been set', async () => {
const file = { path: '/profile/path' } as Express.Multer.File;
userMock.get.mockResolvedValue(userStub.admin);
userMock.update.mockResolvedValue({ ...userStub.admin, profileImagePath: file.path });
await sut.createProfileImage(userStub.admin, file);
expect(jobMock.queue).not.toHaveBeenCalled();
});
});
describe('deleteProfileImage', () => {
it('should send an http error has no profile image', async () => {
userMock.get.mockResolvedValue(userStub.admin);
await expect(sut.deleteProfileImage(userStub.admin)).rejects.toBeInstanceOf(BadRequestException);
expect(jobMock.queue).not.toHaveBeenCalled();
});
it('should delete the profile image if user has one', async () => {
userMock.get.mockResolvedValue(userStub.profilePath);
const files = [userStub.profilePath.profileImagePath];
await sut.deleteProfileImage(userStub.admin);
await expect(jobMock.queue.mock.calls).toEqual([[{ name: JobName.DELETE_FILES, data: { files } }]]);
});
});
describe('getUserProfileImage', () => {

View File

@@ -93,10 +93,23 @@ export class UserService {
authUser: AuthUserDto,
fileInfo: Express.Multer.File,
): Promise<CreateProfileImageResponseDto> {
const { profileImagePath: oldpath } = await this.findOrFail(authUser.id, { withDeleted: false });
const updatedUser = await this.userRepository.update(authUser.id, { profileImagePath: fileInfo.path });
if (oldpath !== '') {
await this.jobRepository.queue({ name: JobName.DELETE_FILES, data: { files: [oldpath] } });
}
return mapCreateProfileImageResponse(updatedUser.id, updatedUser.profileImagePath);
}
async deleteProfileImage(authUser: AuthUserDto): Promise<void> {
const user = await this.findOrFail(authUser.id, { withDeleted: false });
if (user.profileImagePath === '') {
throw new BadRequestException("Can't delete a missing profile Image");
}
await this.userRepository.update(authUser.id, { profileImagePath: '' });
await this.jobRepository.queue({ name: JobName.DELETE_FILES, data: { files: [user.profileImagePath] } });
}
async getProfileImage(id: string): Promise<ImmichReadStream> {
const user = await this.findOrFail(id, {});
if (!user.profileImagePath) {
@@ -111,7 +124,7 @@ export class UserService {
throw new BadRequestException('Admin account does not exist');
}
const providedPassword = await ask(admin);
const providedPassword = await ask(mapUser(admin));
const password = providedPassword || randomBytes(24).toString('base64').replace(/\W/g, '');
await this.userCore.updateUser(admin, admin.id, { password });

View File

@@ -12,6 +12,7 @@ import {
SignUpDto,
UserResponseDto,
ValidateAccessTokenResponseDto,
mapUser,
} from '@app/domain';
import { Body, Controller, Delete, Get, HttpCode, HttpStatus, Param, Post, Req, Res } from '@nestjs/common';
import { ApiTags } from '@nestjs/swagger';
@@ -71,7 +72,7 @@ export class AuthController {
@Post('change-password')
@HttpCode(HttpStatus.OK)
changePassword(@AuthUser() authUser: AuthUserDto, @Body() dto: ChangePasswordDto): Promise<UserResponseDto> {
return this.service.changePassword(authUser, dto);
return this.service.changePassword(authUser, dto).then(mapUser);
}
@Post('logout')

View File

@@ -13,6 +13,8 @@ import {
Delete,
Get,
Header,
HttpCode,
HttpStatus,
Param,
Post,
Put,
@@ -54,6 +56,12 @@ export class UserController {
return this.service.create(createUserDto);
}
@Delete('profile-image')
@HttpCode(HttpStatus.NO_CONTENT)
deleteProfileImage(@AuthUser() authUser: AuthUserDto): Promise<void> {
return this.service.deleteProfileImage(authUser);
}
@AdminRoute()
@Delete(':id')
deleteUser(@AuthUser() authUser: AuthUserDto, @Param() { id }: UUIDParamDto): Promise<UserResponseDto> {

View File

@@ -10,6 +10,19 @@ import {
import { AssetEntity } from './asset.entity';
import { TagEntity } from './tag.entity';
export enum UserAvatarColor {
PRIMARY = 'primary',
PINK = 'pink',
RED = 'red',
YELLOW = 'yellow',
BLUE = 'blue',
GREEN = 'green',
PURPLE = 'purple',
ORANGE = 'orange',
GRAY = 'gray',
AMBER = 'amber',
}
@Entity('users')
export class UserEntity {
@PrimaryGeneratedColumn('uuid')
@@ -18,6 +31,9 @@ export class UserEntity {
@Column({ default: '' })
name!: string;
@Column({ type: 'varchar', nullable: true })
avatarColor!: UserAvatarColor | null;
@Column({ default: false })
isAdmin!: boolean;

View File

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