mirror of
https://github.com/immich-app/immich
synced 2025-11-14 17:36:12 +00:00
feat(web,server): user avatar color (#4779)
This commit is contained in:
parent
14c7187539
commit
d25a245049
58 changed files with 1123 additions and 141 deletions
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
},
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
};
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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', () => {
|
||||
|
|
|
|||
|
|
@ -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 });
|
||||
|
|
|
|||
|
|
@ -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')
|
||||
|
|
|
|||
|
|
@ -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> {
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
||||
|
|
|
|||
14
server/src/infra/migrations/1699889987493-AddAvatarColor.ts
Normal file
14
server/src/infra/migrations/1699889987493-AddAvatarColor.ts
Normal 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"`);
|
||||
}
|
||||
|
||||
}
|
||||
|
|
@ -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',
|
||||
|
|
|
|||
9
server/test/fixtures/user.stub.ts
vendored
9
server/test/fixtures/user.stub.ts
vendored
|
|
@ -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,
|
||||
}),
|
||||
};
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue