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
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
58 changed files with 1123 additions and 141 deletions

View file

@ -5578,6 +5578,29 @@
}
},
"/user/profile-image": {
"delete": {
"operationId": "deleteProfileImage",
"parameters": [],
"responses": {
"204": {
"description": ""
}
},
"security": [
{
"bearer": []
},
{
"cookie": []
},
{
"api_key": []
}
],
"tags": [
"User"
]
},
"post": {
"operationId": "createProfileImage",
"parameters": [],
@ -7632,6 +7655,9 @@
},
"PartnerResponseDto": {
"properties": {
"avatarColor": {
"$ref": "#/components/schemas/UserAvatarColor"
},
"createdAt": {
"format": "date-time",
"type": "string"
@ -7682,6 +7708,7 @@
}
},
"required": [
"avatarColor",
"id",
"name",
"email",
@ -9140,6 +9167,9 @@
},
"UpdateUserDto": {
"properties": {
"avatarColor": {
"$ref": "#/components/schemas/UserAvatarColor"
},
"email": {
"type": "string"
},
@ -9202,8 +9232,26 @@
],
"type": "object"
},
"UserAvatarColor": {
"enum": [
"primary",
"pink",
"red",
"yellow",
"blue",
"green",
"purple",
"orange",
"gray",
"amber"
],
"type": "string"
},
"UserDto": {
"properties": {
"avatarColor": {
"$ref": "#/components/schemas/UserAvatarColor"
},
"email": {
"type": "string"
},
@ -9218,6 +9266,7 @@
}
},
"required": [
"avatarColor",
"id",
"name",
"email",
@ -9227,6 +9276,9 @@
},
"UserResponseDto": {
"properties": {
"avatarColor": {
"$ref": "#/components/schemas/UserAvatarColor"
},
"createdAt": {
"format": "date-time",
"type": "string"
@ -9274,6 +9326,7 @@
}
},
"required": [
"avatarColor",
"id",
"name",
"email",

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

View file

@ -18,6 +18,7 @@ const password = 'Password123';
const email = 'admin@immich.app';
const adminSignupResponse = {
avatarColor: expect.any(String),
id: expect.any(String),
name: 'Immich Admin',
email: 'admin@immich.app',

View file

@ -1,4 +1,4 @@
import { UserEntity } from '@app/infra/entities';
import { UserAvatarColor, UserEntity } from '@app/infra/entities';
import { authStub } from './auth.stub';
export const userStub = {
@ -17,6 +17,7 @@ export const userStub = {
tags: [],
assets: [],
memoriesEnabled: true,
avatarColor: UserAvatarColor.PRIMARY,
}),
user1: Object.freeze<UserEntity>({
...authStub.user1,
@ -33,6 +34,7 @@ export const userStub = {
tags: [],
assets: [],
memoriesEnabled: true,
avatarColor: UserAvatarColor.PRIMARY,
}),
user2: Object.freeze<UserEntity>({
...authStub.user2,
@ -49,6 +51,7 @@ export const userStub = {
tags: [],
assets: [],
memoriesEnabled: true,
avatarColor: UserAvatarColor.PRIMARY,
}),
storageLabel: Object.freeze<UserEntity>({
...authStub.user1,
@ -65,6 +68,7 @@ export const userStub = {
tags: [],
assets: [],
memoriesEnabled: true,
avatarColor: UserAvatarColor.PRIMARY,
}),
externalPath1: Object.freeze<UserEntity>({
...authStub.user1,
@ -81,6 +85,7 @@ export const userStub = {
tags: [],
assets: [],
memoriesEnabled: true,
avatarColor: UserAvatarColor.PRIMARY,
}),
externalPath2: Object.freeze<UserEntity>({
...authStub.user1,
@ -97,6 +102,7 @@ export const userStub = {
tags: [],
assets: [],
memoriesEnabled: true,
avatarColor: UserAvatarColor.PRIMARY,
}),
profilePath: Object.freeze<UserEntity>({
...authStub.user1,
@ -113,5 +119,6 @@ export const userStub = {
tags: [],
assets: [],
memoriesEnabled: true,
avatarColor: UserAvatarColor.PRIMARY,
}),
};