1
0
mirror of https://github.com/immich-app/immich.git synced 2024-12-25 10:43:13 +02:00

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 649 additions and 100 deletions

View File

@ -2355,6 +2355,12 @@ export interface OAuthConfigResponseDto {
* @interface PartnerResponseDto
*/
export interface PartnerResponseDto {
/**
*
* @type {UserAvatarColor}
* @memberof PartnerResponseDto
*/
'avatarColor': UserAvatarColor;
/**
*
* @type {string}
@ -2440,6 +2446,8 @@ export interface PartnerResponseDto {
*/
'updatedAt': string;
}
/**
*
* @export
@ -4344,6 +4352,12 @@ export interface UpdateTagDto {
* @interface UpdateUserDto
*/
export interface UpdateUserDto {
/**
*
* @type {UserAvatarColor}
* @memberof UpdateUserDto
*/
'avatarColor'?: UserAvatarColor;
/**
*
* @type {string}
@ -4399,6 +4413,8 @@ export interface UpdateUserDto {
*/
'storageLabel'?: string;
}
/**
*
* @export
@ -4436,12 +4452,40 @@ export interface UsageByUserDto {
*/
'videos': number;
}
/**
*
* @export
* @enum {string}
*/
export const UserAvatarColor = {
Primary: 'primary',
Pink: 'pink',
Red: 'red',
Yellow: 'yellow',
Blue: 'blue',
Green: 'green',
Purple: 'purple',
Orange: 'orange',
Gray: 'gray',
Amber: 'amber'
} as const;
export type UserAvatarColor = typeof UserAvatarColor[keyof typeof UserAvatarColor];
/**
*
* @export
* @interface UserDto
*/
export interface UserDto {
/**
*
* @type {UserAvatarColor}
* @memberof UserDto
*/
'avatarColor': UserAvatarColor;
/**
*
* @type {string}
@ -4467,12 +4511,20 @@ export interface UserDto {
*/
'profileImagePath': string;
}
/**
*
* @export
* @interface UserResponseDto
*/
export interface UserResponseDto {
/**
*
* @type {UserAvatarColor}
* @memberof UserResponseDto
*/
'avatarColor': UserAvatarColor;
/**
*
* @type {string}
@ -4552,6 +4604,8 @@ export interface UserResponseDto {
*/
'updatedAt': string;
}
/**
*
* @export
@ -16477,6 +16531,44 @@ export const UserApiAxiosParamCreator = function (configuration?: Configuration)
options: localVarRequestOptions,
};
},
/**
*
* @param {*} [options] Override http request option.
* @throws {RequiredError}
*/
deleteProfileImage: async (options: AxiosRequestConfig = {}): Promise<RequestArgs> => {
const localVarPath = `/user/profile-image`;
// use dummy base URL string because the URL constructor only accepts absolute URLs.
const localVarUrlObj = new URL(localVarPath, DUMMY_BASE_URL);
let baseOptions;
if (configuration) {
baseOptions = configuration.baseOptions;
}
const localVarRequestOptions = { method: 'DELETE', ...baseOptions, ...options};
const localVarHeaderParameter = {} as any;
const localVarQueryParameter = {} as any;
// authentication cookie required
// authentication api_key required
await setApiKeyToObject(localVarHeaderParameter, "x-api-key", configuration)
// authentication bearer required
// http bearer authentication required
await setBearerAuthToObject(localVarHeaderParameter, configuration)
setSearchParams(localVarUrlObj, localVarQueryParameter);
let headersFromBaseOptions = baseOptions && baseOptions.headers ? baseOptions.headers : {};
localVarRequestOptions.headers = {...localVarHeaderParameter, ...headersFromBaseOptions, ...options.headers};
return {
url: toPathString(localVarUrlObj),
options: localVarRequestOptions,
};
},
/**
*
* @param {string} id
@ -16802,6 +16894,15 @@ export const UserApiFp = function(configuration?: Configuration) {
const localVarAxiosArgs = await localVarAxiosParamCreator.createUser(createUserDto, options);
return createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration);
},
/**
*
* @param {*} [options] Override http request option.
* @throws {RequiredError}
*/
async deleteProfileImage(options?: AxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise<void>> {
const localVarAxiosArgs = await localVarAxiosParamCreator.deleteProfileImage(options);
return createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration);
},
/**
*
* @param {string} id
@ -16899,6 +17000,14 @@ export const UserApiFactory = function (configuration?: Configuration, basePath?
createUser(requestParameters: UserApiCreateUserRequest, options?: AxiosRequestConfig): AxiosPromise<UserResponseDto> {
return localVarFp.createUser(requestParameters.createUserDto, options).then((request) => request(axios, basePath));
},
/**
*
* @param {*} [options] Override http request option.
* @throws {RequiredError}
*/
deleteProfileImage(options?: AxiosRequestConfig): AxiosPromise<void> {
return localVarFp.deleteProfileImage(options).then((request) => request(axios, basePath));
},
/**
*
* @param {UserApiDeleteUserRequest} requestParameters Request parameters.
@ -17105,6 +17214,16 @@ export class UserApi extends BaseAPI {
return UserApiFp(this.configuration).createUser(requestParameters.createUserDto, options).then((request) => request(this.axios, this.basePath));
}
/**
*
* @param {*} [options] Override http request option.
* @throws {RequiredError}
* @memberof UserApi
*/
public deleteProfileImage(options?: AxiosRequestConfig) {
return UserApiFp(this.configuration).deleteProfileImage(options).then((request) => request(this.axios, this.basePath));
}
/**
*
* @param {UserApiDeleteUserRequest} requestParameters Request parameters.

View File

@ -117,12 +117,8 @@ class AlbumOptionsPage extends HookConsumerWidget {
buildOwnerInfo() {
return ListTile(
leading: owner != null
? UserCircleAvatar(
user: owner,
useRandomBackgroundColor: true,
)
: const SizedBox(),
leading:
owner != null ? UserCircleAvatar(user: owner) : const SizedBox(),
title: Text(
album.owner.value?.name ?? "",
style: const TextStyle(
@ -151,7 +147,6 @@ class AlbumOptionsPage extends HookConsumerWidget {
return ListTile(
leading: UserCircleAvatar(
user: user,
useRandomBackgroundColor: true,
radius: 22,
),
title: Text(

View File

@ -217,7 +217,6 @@ class AlbumViewerPage extends HookConsumerWidget {
user: album.sharedUsers.toList()[index],
radius: 18,
size: 36,
useRandomBackgroundColor: true,
),
);
}),

View File

@ -1,3 +1,5 @@
import 'dart:ui';
import 'package:immich_mobile/shared/models/album.dart';
import 'package:immich_mobile/utils/hash.dart';
import 'package:isar/isar.dart';
@ -16,6 +18,7 @@ class User {
this.isPartnerSharedBy = false,
this.isPartnerSharedWith = false,
this.profileImagePath = '',
this.avatarColor = AvatarColorEnum.primary,
this.memoryEnabled = true,
this.inTimeline = false,
});
@ -32,6 +35,7 @@ class User {
profileImagePath = dto.profileImagePath,
isAdmin = dto.isAdmin,
memoryEnabled = dto.memoriesEnabled ?? false,
avatarColor = dto.avatarColor.toAvatarColor(),
inTimeline = false;
User.fromPartnerDto(PartnerResponseDto dto)
@ -44,6 +48,7 @@ class User {
profileImagePath = dto.profileImagePath,
isAdmin = dto.isAdmin,
memoryEnabled = dto.memoriesEnabled ?? false,
avatarColor = dto.avatarColor.toAvatarColor(),
inTimeline = dto.inTimeline ?? false;
@Index(unique: true, replace: false, type: IndexType.hash)
@ -55,6 +60,8 @@ class User {
bool isPartnerSharedWith;
bool isAdmin;
String profileImagePath;
@Enumerated(EnumType.ordinal)
AvatarColorEnum avatarColor;
bool memoryEnabled;
bool inTimeline;
@ -68,6 +75,7 @@ class User {
if (other is! User) return false;
return id == other.id &&
updatedAt.isAtSameMomentAs(other.updatedAt) &&
avatarColor == other.avatarColor &&
email == other.email &&
name == other.name &&
isPartnerSharedBy == other.isPartnerSharedBy &&
@ -88,7 +96,77 @@ class User {
isPartnerSharedBy.hashCode ^
isPartnerSharedWith.hashCode ^
profileImagePath.hashCode ^
avatarColor.hashCode ^
isAdmin.hashCode ^
memoryEnabled.hashCode ^
inTimeline.hashCode;
}
enum AvatarColorEnum {
// do not change this order or reuse indices for other purposes, adding is OK
primary,
pink,
red,
yellow,
blue,
green,
purple,
orange,
gray,
amber,
}
extension AvatarColorEnumHelper on UserAvatarColor {
AvatarColorEnum toAvatarColor() {
switch (this) {
case UserAvatarColor.primary:
return AvatarColorEnum.primary;
case UserAvatarColor.pink:
return AvatarColorEnum.pink;
case UserAvatarColor.red:
return AvatarColorEnum.red;
case UserAvatarColor.yellow:
return AvatarColorEnum.yellow;
case UserAvatarColor.blue:
return AvatarColorEnum.blue;
case UserAvatarColor.green:
return AvatarColorEnum.green;
case UserAvatarColor.purple:
return AvatarColorEnum.purple;
case UserAvatarColor.orange:
return AvatarColorEnum.orange;
case UserAvatarColor.gray:
return AvatarColorEnum.gray;
case UserAvatarColor.amber:
return AvatarColorEnum.amber;
}
return AvatarColorEnum.primary;
}
}
extension AvatarColorToColorHelper on AvatarColorEnum {
Color toColor([bool isDarkTheme = false]) {
switch (this) {
case AvatarColorEnum.primary:
return isDarkTheme ? const Color(0xFFABCBFA) : const Color(0xFF4250AF);
case AvatarColorEnum.pink:
return const Color.fromARGB(255, 244, 114, 182);
case AvatarColorEnum.red:
return const Color.fromARGB(255, 239, 68, 68);
case AvatarColorEnum.yellow:
return const Color.fromARGB(255, 234, 179, 8);
case AvatarColorEnum.blue:
return const Color.fromARGB(255, 59, 130, 246);
case AvatarColorEnum.green:
return const Color.fromARGB(255, 22, 163, 74);
case AvatarColorEnum.purple:
return const Color.fromARGB(255, 147, 51, 234);
case AvatarColorEnum.orange:
return const Color.fromARGB(255, 234, 88, 12);
case AvatarColorEnum.gray:
return const Color.fromARGB(255, 75, 85, 99);
case AvatarColorEnum.amber:
return const Color.fromARGB(255, 217, 119, 6);
}
}
}

Binary file not shown.

View File

@ -22,14 +22,12 @@ class AppBarProfileInfoBox extends HookConsumerWidget {
final user = Store.tryGet(StoreKey.currentUser);
buildUserProfileImage() {
const immichImage = CircleAvatar(
if (user == null) {
return const CircleAvatar(
radius: 20,
backgroundImage: AssetImage('assets/immich-logo-no-outline.png'),
backgroundColor: Colors.transparent,
);
if (authState.profileImagePath.isEmpty || user == null) {
return immichImage;
}
final userImage = UserCircleAvatar(
@ -38,18 +36,6 @@ class AppBarProfileInfoBox extends HookConsumerWidget {
user: user,
);
if (uploadProfileImageStatus == UploadProfileStatus.idle) {
return authState.profileImagePath.isNotEmpty ? userImage : immichImage;
}
if (uploadProfileImageStatus == UploadProfileStatus.success) {
return userImage;
}
if (uploadProfileImageStatus == UploadProfileStatus.failure) {
return immichImage;
}
if (uploadProfileImageStatus == UploadProfileStatus.loading) {
return const SizedBox(
height: 40,
@ -58,7 +44,7 @@ class AppBarProfileInfoBox extends HookConsumerWidget {
);
}
return immichImage;
return userImage;
}
pickUserProfileImage() async {

View File

@ -4,8 +4,6 @@ import 'package:immich_mobile/extensions/build_context_extensions.dart';
import 'package:immich_mobile/shared/models/store.dart';
import 'package:immich_mobile/shared/ui/app_bar_dialog/app_bar_dialog.dart';
import 'package:immich_mobile/shared/ui/user_circle_avatar.dart';
import 'package:immich_mobile/modules/login/models/authentication_state.model.dart';
import 'package:immich_mobile/modules/login/providers/authentication.provider.dart';
import 'package:immich_mobile/routing/router.dart';
import 'package:immich_mobile/modules/backup/models/backup_state.model.dart';
@ -26,7 +24,6 @@ class ImmichAppBar extends ConsumerWidget implements PreferredSizeWidget {
final bool isEnableAutoBackup =
backupState.backgroundBackup || backupState.autoBackup;
final ServerInfo serverInfoState = ref.watch(serverInfoProvider);
AuthenticationState authState = ref.watch(authenticationProvider);
final user = Store.tryGet(StoreKey.currentUser);
final isDarkTheme = context.isDarkTheme;
const widgetSize = 30.0;
@ -55,7 +52,7 @@ class ImmichAppBar extends ConsumerWidget implements PreferredSizeWidget {
alignment: Alignment.bottomRight,
isLabelVisible: serverInfoState.isVersionMismatch,
offset: const Offset(2, 2),
child: authState.profileImagePath.isEmpty || user == null
child: user == null
? const Icon(
Icons.face_outlined,
size: widgetSize,

View File

@ -3,7 +3,6 @@ import 'dart:math';
import 'package:cached_network_image/cached_network_image.dart';
import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/extensions/build_context_extensions.dart';
import 'package:immich_mobile/shared/models/store.dart';
import 'package:immich_mobile/shared/models/user.dart';
import 'package:immich_mobile/shared/ui/transparent_image.dart';
@ -13,32 +12,17 @@ class UserCircleAvatar extends ConsumerWidget {
final User user;
double radius;
double size;
bool useRandomBackgroundColor;
UserCircleAvatar({
super.key,
this.radius = 22,
this.size = 44,
this.useRandomBackgroundColor = false,
required this.user,
});
@override
Widget build(BuildContext context, WidgetRef ref) {
final randomColors = [
Colors.red[200],
Colors.blue[200],
Colors.green[200],
Colors.yellow[200],
Colors.purple[200],
Colors.orange[200],
Colors.pink[200],
Colors.teal[200],
Colors.indigo[200],
Colors.cyan[200],
Colors.brown[200],
];
bool isDarkTheme = Theme.of(context).brightness == Brightness.dark;
final profileImageUrl =
'${Store.get(StoreKey.serverEndpoint)}/user/profile-image/${user.id}?d=${Random().nextInt(1024)}';
@ -46,15 +30,16 @@ class UserCircleAvatar extends ConsumerWidget {
user.name[0].toUpperCase(),
style: TextStyle(
fontWeight: FontWeight.bold,
color: context.isDarkTheme ? Colors.black : Colors.white,
fontSize: 12,
color: isDarkTheme && user.avatarColor == AvatarColorEnum.primary
? Colors.black
: Colors.white,
),
);
return CircleAvatar(
backgroundColor: useRandomBackgroundColor
? randomColors[Random().nextInt(randomColors.length)]
: context.primaryColor,
backgroundColor: user.avatarColor.toColor(),
radius: radius,
child: user.profileImagePath == ""
child: user.profileImagePath.isEmpty
? textIcon
: ClipRRect(
borderRadius: BorderRadius.circular(50),

View File

@ -166,6 +166,7 @@ doc/UpdateTagDto.md
doc/UpdateUserDto.md
doc/UsageByUserDto.md
doc/UserApi.md
doc/UserAvatarColor.md
doc/UserDto.md
doc/UserResponseDto.md
doc/ValidateAccessTokenResponseDto.md
@ -343,6 +344,7 @@ lib/model/update_stack_parent_dto.dart
lib/model/update_tag_dto.dart
lib/model/update_user_dto.dart
lib/model/usage_by_user_dto.dart
lib/model/user_avatar_color.dart
lib/model/user_dto.dart
lib/model/user_response_dto.dart
lib/model/validate_access_token_response_dto.dart
@ -511,6 +513,7 @@ test/update_tag_dto_test.dart
test/update_user_dto_test.dart
test/usage_by_user_dto_test.dart
test/user_api_test.dart
test/user_avatar_color_test.dart
test/user_dto_test.dart
test/user_response_dto_test.dart
test/validate_access_token_response_dto_test.dart

BIN
mobile/openapi/README.md generated

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

BIN
mobile/openapi/doc/UserAvatarColor.md generated Normal file

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

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,
}),
};

View File

@ -2355,6 +2355,12 @@ export interface OAuthConfigResponseDto {
* @interface PartnerResponseDto
*/
export interface PartnerResponseDto {
/**
*
* @type {UserAvatarColor}
* @memberof PartnerResponseDto
*/
'avatarColor': UserAvatarColor;
/**
*
* @type {string}
@ -2440,6 +2446,8 @@ export interface PartnerResponseDto {
*/
'updatedAt': string;
}
/**
*
* @export
@ -4344,6 +4352,12 @@ export interface UpdateTagDto {
* @interface UpdateUserDto
*/
export interface UpdateUserDto {
/**
*
* @type {UserAvatarColor}
* @memberof UpdateUserDto
*/
'avatarColor'?: UserAvatarColor;
/**
*
* @type {string}
@ -4399,6 +4413,8 @@ export interface UpdateUserDto {
*/
'storageLabel'?: string;
}
/**
*
* @export
@ -4436,12 +4452,40 @@ export interface UsageByUserDto {
*/
'videos': number;
}
/**
*
* @export
* @enum {string}
*/
export const UserAvatarColor = {
Primary: 'primary',
Pink: 'pink',
Red: 'red',
Yellow: 'yellow',
Blue: 'blue',
Green: 'green',
Purple: 'purple',
Orange: 'orange',
Gray: 'gray',
Amber: 'amber'
} as const;
export type UserAvatarColor = typeof UserAvatarColor[keyof typeof UserAvatarColor];
/**
*
* @export
* @interface UserDto
*/
export interface UserDto {
/**
*
* @type {UserAvatarColor}
* @memberof UserDto
*/
'avatarColor': UserAvatarColor;
/**
*
* @type {string}
@ -4467,12 +4511,20 @@ export interface UserDto {
*/
'profileImagePath': string;
}
/**
*
* @export
* @interface UserResponseDto
*/
export interface UserResponseDto {
/**
*
* @type {UserAvatarColor}
* @memberof UserResponseDto
*/
'avatarColor': UserAvatarColor;
/**
*
* @type {string}
@ -4552,6 +4604,8 @@ export interface UserResponseDto {
*/
'updatedAt': string;
}
/**
*
* @export
@ -16477,6 +16531,44 @@ export const UserApiAxiosParamCreator = function (configuration?: Configuration)
options: localVarRequestOptions,
};
},
/**
*
* @param {*} [options] Override http request option.
* @throws {RequiredError}
*/
deleteProfileImage: async (options: AxiosRequestConfig = {}): Promise<RequestArgs> => {
const localVarPath = `/user/profile-image`;
// use dummy base URL string because the URL constructor only accepts absolute URLs.
const localVarUrlObj = new URL(localVarPath, DUMMY_BASE_URL);
let baseOptions;
if (configuration) {
baseOptions = configuration.baseOptions;
}
const localVarRequestOptions = { method: 'DELETE', ...baseOptions, ...options};
const localVarHeaderParameter = {} as any;
const localVarQueryParameter = {} as any;
// authentication cookie required
// authentication api_key required
await setApiKeyToObject(localVarHeaderParameter, "x-api-key", configuration)
// authentication bearer required
// http bearer authentication required
await setBearerAuthToObject(localVarHeaderParameter, configuration)
setSearchParams(localVarUrlObj, localVarQueryParameter);
let headersFromBaseOptions = baseOptions && baseOptions.headers ? baseOptions.headers : {};
localVarRequestOptions.headers = {...localVarHeaderParameter, ...headersFromBaseOptions, ...options.headers};
return {
url: toPathString(localVarUrlObj),
options: localVarRequestOptions,
};
},
/**
*
* @param {string} id
@ -16802,6 +16894,15 @@ export const UserApiFp = function(configuration?: Configuration) {
const localVarAxiosArgs = await localVarAxiosParamCreator.createUser(createUserDto, options);
return createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration);
},
/**
*
* @param {*} [options] Override http request option.
* @throws {RequiredError}
*/
async deleteProfileImage(options?: AxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise<void>> {
const localVarAxiosArgs = await localVarAxiosParamCreator.deleteProfileImage(options);
return createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration);
},
/**
*
* @param {string} id
@ -16899,6 +17000,14 @@ export const UserApiFactory = function (configuration?: Configuration, basePath?
createUser(requestParameters: UserApiCreateUserRequest, options?: AxiosRequestConfig): AxiosPromise<UserResponseDto> {
return localVarFp.createUser(requestParameters.createUserDto, options).then((request) => request(axios, basePath));
},
/**
*
* @param {*} [options] Override http request option.
* @throws {RequiredError}
*/
deleteProfileImage(options?: AxiosRequestConfig): AxiosPromise<void> {
return localVarFp.deleteProfileImage(options).then((request) => request(axios, basePath));
},
/**
*
* @param {UserApiDeleteUserRequest} requestParameters Request parameters.
@ -17105,6 +17214,16 @@ export class UserApi extends BaseAPI {
return UserApiFp(this.configuration).createUser(requestParameters.createUserDto, options).then((request) => request(this.axios, this.basePath));
}
/**
*
* @param {*} [options] Override http request option.
* @throws {RequiredError}
* @memberof UserApi
*/
public deleteProfileImage(options?: AxiosRequestConfig) {
return UserApiFp(this.configuration).deleteProfileImage(options).then((request) => request(this.axios, this.basePath));
}
/**
*
* @param {UserApiDeleteUserRequest} requestParameters Request parameters.

View File

@ -77,7 +77,7 @@
<section class="immich-scrollbar max-h-[400px] overflow-y-auto pb-4">
<div class="flex w-full place-items-center justify-between gap-4 p-5">
<div class="flex place-items-center gap-4">
<UserAvatar user={album.owner} size="md" autoColor />
<UserAvatar user={album.owner} size="md" />
<p class="text-sm font-medium">{album.owner.name}</p>
</div>
@ -90,7 +90,7 @@
class="flex w-full place-items-center justify-between gap-4 p-5 transition-colors hover:bg-gray-50 dark:hover:bg-gray-700"
>
<div class="flex place-items-center gap-4">
<UserAvatar {user} size="md" autoColor />
<UserAvatar {user} size="md" />
<p class="text-sm font-medium">{user.name}</p>
</div>

View File

@ -71,7 +71,7 @@
on:click={() => handleUnselect(user)}
class="flex place-items-center gap-1 rounded-full border border-gray-400 p-1 transition-colors hover:bg-gray-200 dark:hover:bg-gray-700"
>
<UserAvatar {user} size="sm" autoColor />
<UserAvatar {user} size="sm" />
<p class="text-xs font-medium">{user.name}</p>
</button>
{/key}
@ -94,7 +94,7 @@
>✓</span
>
{:else}
<UserAvatar {user} size="md" autoColor />
<UserAvatar {user} size="md" />
{/if}
<div class="text-left">

View File

@ -333,7 +333,7 @@
<p class="text-sm">SHARED BY</p>
<div class="flex gap-4 pt-4">
<div>
<UserAvatar user={asset.owner} size="md" autoColor />
<UserAvatar user={asset.owner} size="md" />
</div>
<div class="mb-auto mt-auto">

View File

@ -1,16 +1,48 @@
<script lang="ts">
import Button from '$lib/components/elements/buttons/button.svelte';
import { AppRoute } from '$lib/constants';
import type { UserResponseDto } from '@api';
import { api, UserAvatarColor, type UserResponseDto } from '@api';
import { createEventDispatcher } from 'svelte';
import Icon from '$lib/components/elements/icon.svelte';
import { fade } from 'svelte/transition';
import UserAvatar from '../user-avatar.svelte';
import { mdiCog, mdiLogout } from '@mdi/js';
import { mdiCog, mdiLogout, mdiPencil } from '@mdi/js';
import { notificationController, NotificationType } from '../notification/notification';
import { handleError } from '$lib/utils/handle-error';
import AvatarSelector from './avatar-selector.svelte';
export let user: UserResponseDto;
let isShowSelectAvatar = false;
const dispatch = createEventDispatcher();
const handleSaveProfile = async (color: UserAvatarColor) => {
try {
if (user.profileImagePath !== '') {
await api.userApi.deleteProfileImage();
}
const { data } = await api.userApi.updateUser({
updateUserDto: {
id: user.id,
email: user.email,
name: user.name,
avatarColor: color,
},
});
user = data;
isShowSelectAvatar = false;
notificationController.show({
message: 'Saved profile',
type: NotificationType.Info,
});
} catch (error) {
handleError(error, 'Unable to save profile');
}
};
</script>
<div
@ -22,8 +54,22 @@
<div
class="mx-4 mt-4 flex flex-col items-center justify-center gap-4 rounded-3xl bg-white p-4 dark:bg-immich-dark-primary/10"
>
<UserAvatar size="xl" {user} />
<div class="relative">
{#key user}
<UserAvatar {user} size="xl" />
<div
class="absolute z-10 bottom-0 right-0 rounded-full w-6 h-6 border dark:border-immich-dark-primary bg-immich-primary"
>
<button
class="flex items-center justify-center w-full h-full text-white"
on:click={() => (isShowSelectAvatar = true)}
>
<Icon path={mdiPencil} />
</button>
</div>
{/key}
</div>
<div>
<p class="text-center text-lg font-medium text-immich-primary dark:text-immich-dark-primary">
{user.name}
@ -51,3 +97,10 @@
>
</div>
</div>
{#if isShowSelectAvatar}
<AvatarSelector
{user}
on:close={() => (isShowSelectAvatar = false)}
on:choose={({ detail: color }) => handleSaveProfile(color)}
/>
{/if}

View File

@ -0,0 +1,39 @@
<script lang="ts">
import { mdiClose } from '@mdi/js';
import { createEventDispatcher } from 'svelte';
import { UserAvatarColor, UserResponseDto } from '@api';
import CircleIconButton from '$lib/components/elements/buttons/circle-icon-button.svelte';
import FullScreenModal from '../full-screen-modal.svelte';
import UserAvatar from '../user-avatar.svelte';
export let user: UserResponseDto;
const dispatch = createEventDispatcher();
const colors: UserAvatarColor[] = Object.values(UserAvatarColor);
</script>
<FullScreenModal on:clickOutside={() => dispatch('close')} on:escape={() => dispatch('close')}>
<div class="flex h-full w-full place-content-center place-items-center overflow-hidden">
<div
class=" rounded-3xl border bg-immich-bg shadow-sm dark:border-immich-dark-gray dark:bg-immich-dark-gray dark:text-immich-dark-fg p-4"
>
<div class="flex items-center">
<h1 class="px-4 w-full self-center font-medium text-immich-primary dark:text-immich-dark-primary text-sm">
SELECT AVATAR COLOR
</h1>
<div>
<CircleIconButton icon={mdiClose} on:click={() => dispatch('close')} />
</div>
</div>
<div class="flex items-center justify-center p-4 mt-4">
<div class="grid grid-cols-2 md:grid-cols-5 gap-4">
{#each colors as color}
<button on:click={() => dispatch('choose', color)}>
<UserAvatar {user} {color} size="xl" showProfileImage={false} />
</button>
{/each}
</div>
</div>
</div>
</div>
</FullScreenModal>

View File

@ -124,7 +124,9 @@
on:mouseleave={() => (shouldShowAccountInfo = false)}
on:click={() => (shouldShowAccountInfoPanel = !shouldShowAccountInfoPanel)}
>
{#key user}
<UserAvatar {user} size="lg" showTitle={false} interactive />
{/key}
</button>
{#if shouldShowAccountInfo && !shouldShowAccountInfoPanel}
@ -139,7 +141,7 @@
{/if}
{#if shouldShowAccountInfoPanel}
<AccountInfoPanel {user} on:logout={logOut} />
<AccountInfoPanel bind:user on:logout={logOut} />
{/if}
</div>
</section>

View File

@ -1,35 +1,40 @@
<script lang="ts" context="module">
export type Color = 'primary' | 'pink' | 'red' | 'yellow' | 'blue' | 'green';
export type Size = 'full' | 'sm' | 'md' | 'lg' | 'xl';
export type Size = 'full' | 'sm' | 'md' | 'lg' | 'xl' | 'xxl' | 'xxxl';
</script>
<script lang="ts">
import { imageLoad } from '$lib/utils/image-load';
import { api } from '@api';
import { UserAvatarColor, api } from '@api';
interface User {
id: string;
name: string;
email: string;
profileImagePath: string;
avatarColor: UserAvatarColor;
}
export let user: User;
export let color: Color = 'primary';
export let color: UserAvatarColor = user.avatarColor;
export let size: Size = 'full';
export let rounded = true;
export let interactive = false;
export let showTitle = true;
export let autoColor = false;
export let showProfileImage = true;
let showFallback = true;
const colorClasses: Record<Color, string> = {
const colorClasses: Record<UserAvatarColor, string> = {
primary: 'bg-immich-primary dark:bg-immich-dark-primary text-immich-dark-fg dark:text-immich-fg',
pink: 'bg-pink-400 text-immich-bg',
red: 'bg-red-500 text-immich-bg',
yellow: 'bg-yellow-500 text-immich-bg',
blue: 'bg-blue-500 text-immich-bg',
green: 'bg-green-600 text-immich-bg',
purple: 'bg-purple-600 text-immich-bg',
orange: 'bg-orange-600 text-immich-bg',
gray: 'bg-gray-600 text-immich-bg',
amber: 'bg-amber-600 text-immich-bg',
};
const sizeClasses: Record<Size, string> = {
@ -37,18 +42,12 @@
sm: 'w-7 h-7',
md: 'w-10 h-10',
lg: 'w-12 h-12',
xl: 'w-20 h-20',
xl: 'w-16 h-16',
xxl: 'w-24 h-24',
xxxl: 'w-28 h-28',
};
// Get color based on the user UUID.
function getUserColor() {
const seed = parseInt(user.id.split('-')[0], 16);
const colors = Object.keys(colorClasses).filter((color) => color !== 'primary') as Color[];
const randomIndex = seed % colors.length;
return colors[randomIndex];
}
$: colorClass = colorClasses[autoColor ? getUserColor() : color];
$: colorClass = colorClasses[color];
$: sizeClass = sizeClasses[size];
$: title = `${user.name} (${user.email})`;
$: interactiveClass = interactive
@ -61,7 +60,7 @@
class:rounded-full={rounded}
title={showTitle ? title : undefined}
>
{#if user.profileImagePath}
{#if showProfileImage && user.profileImagePath}
<img
src={api.getProfileImageUrl(user.id)}
alt="Profile image of {title}"
@ -74,12 +73,12 @@
{/if}
{#if showFallback}
<span
class="flex h-full w-full select-none items-center justify-center"
class="flex h-full w-full select-none items-center justify-center font-medium"
class:text-xs={size === 'sm'}
class:text-lg={size === 'lg'}
class:text-xl={size === 'xl'}
class:font-medium={!autoColor}
class:font-semibold={autoColor}
class:text-2xl={size === 'xxl'}
class:text-3xl={size === 'xxxl'}
>
{(user.name[0] || '').toUpperCase()}
</span>

View File

@ -56,7 +56,7 @@
>✓</span
>
{:else}
<UserAvatar {user} size="lg" autoColor />
<UserAvatar {user} size="lg" />
{/if}
<div class="text-left">

View File

@ -113,7 +113,7 @@
<div class="rounded-2xl border border-gray-200 dark:border-gray-800 mt-6 bg-slate-50 dark:bg-gray-900 p-5">
<div class="flex gap-4 rounded-lg pb-4 transition-all justify-between">
<div class="flex gap-4">
<UserAvatar user={partner.user} size="md" autoColor />
<UserAvatar user={partner.user} size="md" />
<div class="text-left">
<p class="text-immich-fg dark:text-immich-dark-fg">
{partner.user.name}

View File

@ -603,13 +603,13 @@
<!-- owner -->
<button on:click={() => (viewMode = ViewMode.VIEW_USERS)}>
<UserAvatar user={album.owner} size="md" autoColor />
<UserAvatar user={album.owner} size="md" />
</button>
<!-- users -->
{#each album.sharedUsers as user (user.id)}
<button on:click={() => (viewMode = ViewMode.VIEW_USERS)}>
<UserAvatar {user} size="md" autoColor />
<UserAvatar {user} size="md" />
</button>
{/each}

View File

@ -69,7 +69,7 @@
href="/partners/{partner.id}"
class="flex gap-4 rounded-lg px-5 py-4 transition-all hover:bg-gray-200 dark:hover:bg-gray-700"
>
<UserAvatar user={partner} size="lg" autoColor />
<UserAvatar user={partner} size="lg" />
<div class="text-left">
<p class="text-immich-fg dark:text-immich-dark-fg">
{partner.name}

View File

@ -1,4 +1,4 @@
import type { UserResponseDto } from '@api';
import { UserAvatarColor, type UserResponseDto } from '@api';
import { faker } from '@faker-js/faker';
import { Sync } from 'factory.ts';
@ -16,4 +16,5 @@ export const userFactory = Sync.makeFactory<UserResponseDto>({
updatedAt: Sync.each(() => faker.date.past().toISOString()),
memoriesEnabled: true,
oauthId: '',
avatarColor: UserAvatarColor.Primary,
});