Browse Source

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

martin 1 year ago
parent
commit
d25a245049
58 changed files with 1123 additions and 141 deletions
  1. 119 0
      cli/src/api/open-api/api.ts
  2. 2 7
      mobile/lib/modules/album/views/album_options_part.dart
  3. 0 1
      mobile/lib/modules/album/views/album_viewer_page.dart
  4. 78 0
      mobile/lib/shared/models/user.dart
  5. 164 37
      mobile/lib/shared/models/user.g.dart
  6. 7 21
      mobile/lib/shared/ui/app_bar_dialog/app_bar_profile_info.dart
  7. 1 4
      mobile/lib/shared/ui/immich_app_bar.dart
  8. 7 22
      mobile/lib/shared/ui/user_circle_avatar.dart
  9. 3 0
      mobile/openapi/.openapi-generator/FILES
  10. 2 0
      mobile/openapi/README.md
  11. 1 0
      mobile/openapi/doc/PartnerResponseDto.md
  12. 1 0
      mobile/openapi/doc/UpdateUserDto.md
  13. 51 0
      mobile/openapi/doc/UserApi.md
  14. 14 0
      mobile/openapi/doc/UserAvatarColor.md
  15. 1 0
      mobile/openapi/doc/UserDto.md
  16. 1 0
      mobile/openapi/doc/UserResponseDto.md
  17. 1 0
      mobile/openapi/lib/api.dart
  18. 33 0
      mobile/openapi/lib/api/user_api.dart
  19. 2 0
      mobile/openapi/lib/api_client.dart
  20. 3 0
      mobile/openapi/lib/api_helper.dart
  21. 9 1
      mobile/openapi/lib/model/partner_response_dto.dart
  22. 18 1
      mobile/openapi/lib/model/update_user_dto.dart
  23. 109 0
      mobile/openapi/lib/model/user_avatar_color.dart
  24. 9 1
      mobile/openapi/lib/model/user_dto.dart
  25. 9 1
      mobile/openapi/lib/model/user_response_dto.dart
  26. 5 0
      mobile/openapi/test/partner_response_dto_test.dart
  27. 5 0
      mobile/openapi/test/update_user_dto_test.dart
  28. 5 0
      mobile/openapi/test/user_api_test.dart
  29. 21 0
      mobile/openapi/test/user_avatar_color_test.dart
  30. 5 0
      mobile/openapi/test/user_dto_test.dart
  31. 5 0
      mobile/openapi/test/user_response_dto_test.dart
  32. 53 0
      server/immich-openapi-specs.json
  33. 1 0
      server/src/domain/auth/auth.service.spec.ts
  34. 3 0
      server/src/domain/partner/partner.service.spec.ts
  35. 7 1
      server/src/domain/user/dto/update-user.dto.ts
  36. 18 1
      server/src/domain/user/response-dto/user-response.dto.ts
  37. 0 1
      server/src/domain/user/user.core.ts
  38. 38 3
      server/src/domain/user/user.service.spec.ts
  39. 14 1
      server/src/domain/user/user.service.ts
  40. 2 1
      server/src/immich/controllers/auth.controller.ts
  41. 8 0
      server/src/immich/controllers/user.controller.ts
  42. 16 0
      server/src/infra/entities/user.entity.ts
  43. 14 0
      server/src/infra/migrations/1699889987493-AddAvatarColor.ts
  44. 1 0
      server/test/e2e/auth.e2e-spec.ts
  45. 8 1
      server/test/fixtures/user.stub.ts
  46. 119 0
      web/src/api/open-api/api.ts
  47. 2 2
      web/src/lib/components/album-page/share-info-modal.svelte
  48. 2 2
      web/src/lib/components/album-page/user-selection-modal.svelte
  49. 1 1
      web/src/lib/components/asset-viewer/detail-panel.svelte
  50. 56 3
      web/src/lib/components/shared-components/navigation-bar/account-info-panel.svelte
  51. 39 0
      web/src/lib/components/shared-components/navigation-bar/avatar-selector.svelte
  52. 4 2
      web/src/lib/components/shared-components/navigation-bar/navigation-bar.svelte
  53. 19 20
      web/src/lib/components/shared-components/user-avatar.svelte
  54. 1 1
      web/src/lib/components/user-settings-page/partner-selection-modal.svelte
  55. 1 1
      web/src/lib/components/user-settings-page/partner-settings.svelte
  56. 2 2
      web/src/routes/(user)/albums/[albumId]/+page.svelte
  57. 1 1
      web/src/routes/(user)/sharing/+page.svelte
  58. 2 1
      web/src/test-data/factories/user-factory.ts

+ 119 - 0
cli/src/api/open-api/api.ts

@@ -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.

+ 2 - 7
mobile/lib/modules/album/views/album_options_part.dart

@@ -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(

+ 0 - 1
mobile/lib/modules/album/views/album_viewer_page.dart

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

+ 78 - 0
mobile/lib/shared/models/user.dart

@@ -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);
+    }
+  }
+}

+ 164 - 37
mobile/lib/shared/models/user.g.dart

@@ -17,53 +17,59 @@ const UserSchema = CollectionSchema(
   name: r'User',
   id: -7838171048429979076,
   properties: {
-    r'email': PropertySchema(
+    r'avatarColor': PropertySchema(
       id: 0,
+      name: r'avatarColor',
+      type: IsarType.byte,
+      enumMap: _UseravatarColorEnumValueMap,
+    ),
+    r'email': PropertySchema(
+      id: 1,
       name: r'email',
       type: IsarType.string,
     ),
     r'id': PropertySchema(
-      id: 1,
+      id: 2,
       name: r'id',
       type: IsarType.string,
     ),
     r'inTimeline': PropertySchema(
-      id: 2,
+      id: 3,
       name: r'inTimeline',
       type: IsarType.bool,
     ),
     r'isAdmin': PropertySchema(
-      id: 3,
+      id: 4,
       name: r'isAdmin',
       type: IsarType.bool,
     ),
     r'isPartnerSharedBy': PropertySchema(
-      id: 4,
+      id: 5,
       name: r'isPartnerSharedBy',
       type: IsarType.bool,
     ),
     r'isPartnerSharedWith': PropertySchema(
-      id: 5,
+      id: 6,
       name: r'isPartnerSharedWith',
       type: IsarType.bool,
     ),
     r'memoryEnabled': PropertySchema(
-      id: 6,
+      id: 7,
       name: r'memoryEnabled',
       type: IsarType.bool,
     ),
     r'name': PropertySchema(
-      id: 7,
+      id: 8,
       name: r'name',
       type: IsarType.string,
     ),
     r'profileImagePath': PropertySchema(
-      id: 8,
+      id: 9,
       name: r'profileImagePath',
       type: IsarType.string,
     ),
     r'updatedAt': PropertySchema(
-      id: 9,
+      id: 10,
       name: r'updatedAt',
       type: IsarType.dateTime,
     )
@@ -130,16 +136,17 @@ void _userSerialize(
   List<int> offsets,
   Map<Type, List<int>> allOffsets,
 ) {
-  writer.writeString(offsets[0], object.email);
-  writer.writeString(offsets[1], object.id);
-  writer.writeBool(offsets[2], object.inTimeline);
-  writer.writeBool(offsets[3], object.isAdmin);
-  writer.writeBool(offsets[4], object.isPartnerSharedBy);
-  writer.writeBool(offsets[5], object.isPartnerSharedWith);
-  writer.writeBool(offsets[6], object.memoryEnabled);
-  writer.writeString(offsets[7], object.name);
-  writer.writeString(offsets[8], object.profileImagePath);
-  writer.writeDateTime(offsets[9], object.updatedAt);
+  writer.writeByte(offsets[0], object.avatarColor.index);
+  writer.writeString(offsets[1], object.email);
+  writer.writeString(offsets[2], object.id);
+  writer.writeBool(offsets[3], object.inTimeline);
+  writer.writeBool(offsets[4], object.isAdmin);
+  writer.writeBool(offsets[5], object.isPartnerSharedBy);
+  writer.writeBool(offsets[6], object.isPartnerSharedWith);
+  writer.writeBool(offsets[7], object.memoryEnabled);
+  writer.writeString(offsets[8], object.name);
+  writer.writeString(offsets[9], object.profileImagePath);
+  writer.writeDateTime(offsets[10], object.updatedAt);
 }
 
 User _userDeserialize(
@@ -149,16 +156,19 @@ User _userDeserialize(
   Map<Type, List<int>> allOffsets,
 ) {
   final object = User(
-    email: reader.readString(offsets[0]),
-    id: reader.readString(offsets[1]),
-    inTimeline: reader.readBoolOrNull(offsets[2]) ?? false,
-    isAdmin: reader.readBool(offsets[3]),
-    isPartnerSharedBy: reader.readBoolOrNull(offsets[4]) ?? false,
-    isPartnerSharedWith: reader.readBoolOrNull(offsets[5]) ?? false,
-    memoryEnabled: reader.readBoolOrNull(offsets[6]) ?? true,
-    name: reader.readString(offsets[7]),
-    profileImagePath: reader.readStringOrNull(offsets[8]) ?? '',
-    updatedAt: reader.readDateTime(offsets[9]),
+    avatarColor:
+        _UseravatarColorValueEnumMap[reader.readByteOrNull(offsets[0])] ??
+            AvatarColorEnum.primary,
+    email: reader.readString(offsets[1]),
+    id: reader.readString(offsets[2]),
+    inTimeline: reader.readBoolOrNull(offsets[3]) ?? false,
+    isAdmin: reader.readBool(offsets[4]),
+    isPartnerSharedBy: reader.readBoolOrNull(offsets[5]) ?? false,
+    isPartnerSharedWith: reader.readBoolOrNull(offsets[6]) ?? false,
+    memoryEnabled: reader.readBoolOrNull(offsets[7]) ?? true,
+    name: reader.readString(offsets[8]),
+    profileImagePath: reader.readStringOrNull(offsets[9]) ?? '',
+    updatedAt: reader.readDateTime(offsets[10]),
   );
   return object;
 }
@@ -171,30 +181,58 @@ P _userDeserializeProp<P>(
 ) {
   switch (propertyId) {
     case 0:
-      return (reader.readString(offset)) as P;
+      return (_UseravatarColorValueEnumMap[reader.readByteOrNull(offset)] ??
+          AvatarColorEnum.primary) as P;
     case 1:
       return (reader.readString(offset)) as P;
     case 2:
-      return (reader.readBoolOrNull(offset) ?? false) as P;
+      return (reader.readString(offset)) as P;
     case 3:
-      return (reader.readBool(offset)) as P;
-    case 4:
       return (reader.readBoolOrNull(offset) ?? false) as P;
+    case 4:
+      return (reader.readBool(offset)) as P;
     case 5:
       return (reader.readBoolOrNull(offset) ?? false) as P;
     case 6:
-      return (reader.readBoolOrNull(offset) ?? true) as P;
+      return (reader.readBoolOrNull(offset) ?? false) as P;
     case 7:
-      return (reader.readString(offset)) as P;
+      return (reader.readBoolOrNull(offset) ?? true) as P;
     case 8:
-      return (reader.readStringOrNull(offset) ?? '') as P;
+      return (reader.readString(offset)) as P;
     case 9:
+      return (reader.readStringOrNull(offset) ?? '') as P;
+    case 10:
       return (reader.readDateTime(offset)) as P;
     default:
       throw IsarError('Unknown property with id $propertyId');
   }
 }
 
+const _UseravatarColorEnumValueMap = {
+  'primary': 0,
+  'pink': 1,
+  'red': 2,
+  'yellow': 3,
+  'blue': 4,
+  'green': 5,
+  'purple': 6,
+  'orange': 7,
+  'gray': 8,
+  'amber': 9,
+};
+const _UseravatarColorValueEnumMap = {
+  0: AvatarColorEnum.primary,
+  1: AvatarColorEnum.pink,
+  2: AvatarColorEnum.red,
+  3: AvatarColorEnum.yellow,
+  4: AvatarColorEnum.blue,
+  5: AvatarColorEnum.green,
+  6: AvatarColorEnum.purple,
+  7: AvatarColorEnum.orange,
+  8: AvatarColorEnum.gray,
+  9: AvatarColorEnum.amber,
+};
+
 Id _userGetId(User object) {
   return object.isarId;
 }
@@ -382,6 +420,59 @@ extension UserQueryWhere on QueryBuilder<User, User, QWhereClause> {
 }
 
 extension UserQueryFilter on QueryBuilder<User, User, QFilterCondition> {
+  QueryBuilder<User, User, QAfterFilterCondition> avatarColorEqualTo(
+      AvatarColorEnum value) {
+    return QueryBuilder.apply(this, (query) {
+      return query.addFilterCondition(FilterCondition.equalTo(
+        property: r'avatarColor',
+        value: value,
+      ));
+    });
+  }
+
+  QueryBuilder<User, User, QAfterFilterCondition> avatarColorGreaterThan(
+    AvatarColorEnum value, {
+    bool include = false,
+  }) {
+    return QueryBuilder.apply(this, (query) {
+      return query.addFilterCondition(FilterCondition.greaterThan(
+        include: include,
+        property: r'avatarColor',
+        value: value,
+      ));
+    });
+  }
+
+  QueryBuilder<User, User, QAfterFilterCondition> avatarColorLessThan(
+    AvatarColorEnum value, {
+    bool include = false,
+  }) {
+    return QueryBuilder.apply(this, (query) {
+      return query.addFilterCondition(FilterCondition.lessThan(
+        include: include,
+        property: r'avatarColor',
+        value: value,
+      ));
+    });
+  }
+
+  QueryBuilder<User, User, QAfterFilterCondition> avatarColorBetween(
+    AvatarColorEnum lower,
+    AvatarColorEnum upper, {
+    bool includeLower = true,
+    bool includeUpper = true,
+  }) {
+    return QueryBuilder.apply(this, (query) {
+      return query.addFilterCondition(FilterCondition.between(
+        property: r'avatarColor',
+        lower: lower,
+        includeLower: includeLower,
+        upper: upper,
+        includeUpper: includeUpper,
+      ));
+    });
+  }
+
   QueryBuilder<User, User, QAfterFilterCondition> emailEqualTo(
     String value, {
     bool caseSensitive = true,
@@ -1167,6 +1258,18 @@ extension UserQueryLinks on QueryBuilder<User, User, QFilterCondition> {
 }
 
 extension UserQuerySortBy on QueryBuilder<User, User, QSortBy> {
+  QueryBuilder<User, User, QAfterSortBy> sortByAvatarColor() {
+    return QueryBuilder.apply(this, (query) {
+      return query.addSortBy(r'avatarColor', Sort.asc);
+    });
+  }
+
+  QueryBuilder<User, User, QAfterSortBy> sortByAvatarColorDesc() {
+    return QueryBuilder.apply(this, (query) {
+      return query.addSortBy(r'avatarColor', Sort.desc);
+    });
+  }
+
   QueryBuilder<User, User, QAfterSortBy> sortByEmail() {
     return QueryBuilder.apply(this, (query) {
       return query.addSortBy(r'email', Sort.asc);
@@ -1289,6 +1392,18 @@ extension UserQuerySortBy on QueryBuilder<User, User, QSortBy> {
 }
 
 extension UserQuerySortThenBy on QueryBuilder<User, User, QSortThenBy> {
+  QueryBuilder<User, User, QAfterSortBy> thenByAvatarColor() {
+    return QueryBuilder.apply(this, (query) {
+      return query.addSortBy(r'avatarColor', Sort.asc);
+    });
+  }
+
+  QueryBuilder<User, User, QAfterSortBy> thenByAvatarColorDesc() {
+    return QueryBuilder.apply(this, (query) {
+      return query.addSortBy(r'avatarColor', Sort.desc);
+    });
+  }
+
   QueryBuilder<User, User, QAfterSortBy> thenByEmail() {
     return QueryBuilder.apply(this, (query) {
       return query.addSortBy(r'email', Sort.asc);
@@ -1423,6 +1538,12 @@ extension UserQuerySortThenBy on QueryBuilder<User, User, QSortThenBy> {
 }
 
 extension UserQueryWhereDistinct on QueryBuilder<User, User, QDistinct> {
+  QueryBuilder<User, User, QDistinct> distinctByAvatarColor() {
+    return QueryBuilder.apply(this, (query) {
+      return query.addDistinctBy(r'avatarColor');
+    });
+  }
+
   QueryBuilder<User, User, QDistinct> distinctByEmail(
       {bool caseSensitive = true}) {
     return QueryBuilder.apply(this, (query) {
@@ -1496,6 +1617,12 @@ extension UserQueryProperty on QueryBuilder<User, User, QQueryProperty> {
     });
   }
 
+  QueryBuilder<User, AvatarColorEnum, QQueryOperations> avatarColorProperty() {
+    return QueryBuilder.apply(this, (query) {
+      return query.addPropertyName(r'avatarColor');
+    });
+  }
+
   QueryBuilder<User, String, QQueryOperations> emailProperty() {
     return QueryBuilder.apply(this, (query) {
       return query.addPropertyName(r'email');

+ 7 - 21
mobile/lib/shared/ui/app_bar_dialog/app_bar_profile_info.dart

@@ -22,14 +22,12 @@ class AppBarProfileInfoBox extends HookConsumerWidget {
     final user = Store.tryGet(StoreKey.currentUser);
 
     buildUserProfileImage() {
-      const immichImage = CircleAvatar(
-        radius: 20,
-        backgroundImage: AssetImage('assets/immich-logo-no-outline.png'),
-        backgroundColor: Colors.transparent,
-      );
-
-      if (authState.profileImagePath.isEmpty || user == null) {
-        return immichImage;
+      if (user == null) {
+        return const CircleAvatar(
+          radius: 20,
+          backgroundImage: AssetImage('assets/immich-logo-no-outline.png'),
+          backgroundColor: Colors.transparent,
+        );
       }
 
       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 {

+ 1 - 4
mobile/lib/shared/ui/immich_app_bar.dart

@@ -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,

+ 7 - 22
mobile/lib/shared/ui/user_circle_avatar.dart

@@ -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),

+ 3 - 0
mobile/openapi/.openapi-generator/FILES

@@ -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

+ 2 - 0
mobile/openapi/README.md

@@ -195,6 +195,7 @@ Class | Method | HTTP request | Description
 *TagApi* | [**updateTag**](doc//TagApi.md#updatetag) | **PATCH** /tag/{id} | 
 *UserApi* | [**createProfileImage**](doc//UserApi.md#createprofileimage) | **POST** /user/profile-image | 
 *UserApi* | [**createUser**](doc//UserApi.md#createuser) | **POST** /user | 
+*UserApi* | [**deleteProfileImage**](doc//UserApi.md#deleteprofileimage) | **DELETE** /user/profile-image | 
 *UserApi* | [**deleteUser**](doc//UserApi.md#deleteuser) | **DELETE** /user/{id} | 
 *UserApi* | [**getAllUsers**](doc//UserApi.md#getallusers) | **GET** /user | 
 *UserApi* | [**getMyUserInfo**](doc//UserApi.md#getmyuserinfo) | **GET** /user/me | 
@@ -352,6 +353,7 @@ Class | Method | HTTP request | Description
  - [UpdateTagDto](doc//UpdateTagDto.md)
  - [UpdateUserDto](doc//UpdateUserDto.md)
  - [UsageByUserDto](doc//UsageByUserDto.md)
+ - [UserAvatarColor](doc//UserAvatarColor.md)
  - [UserDto](doc//UserDto.md)
  - [UserResponseDto](doc//UserResponseDto.md)
  - [ValidateAccessTokenResponseDto](doc//ValidateAccessTokenResponseDto.md)

+ 1 - 0
mobile/openapi/doc/PartnerResponseDto.md

@@ -8,6 +8,7 @@ import 'package:openapi/api.dart';
 ## Properties
 Name | Type | Description | Notes
 ------------ | ------------- | ------------- | -------------
+**avatarColor** | [**UserAvatarColor**](UserAvatarColor.md) |  | 
 **createdAt** | [**DateTime**](DateTime.md) |  | 
 **deletedAt** | [**DateTime**](DateTime.md) |  | 
 **email** | **String** |  | 

+ 1 - 0
mobile/openapi/doc/UpdateUserDto.md

@@ -8,6 +8,7 @@ import 'package:openapi/api.dart';
 ## Properties
 Name | Type | Description | Notes
 ------------ | ------------- | ------------- | -------------
+**avatarColor** | [**UserAvatarColor**](UserAvatarColor.md) |  | [optional] 
 **email** | **String** |  | [optional] 
 **externalPath** | **String** |  | [optional] 
 **id** | **String** |  | 

+ 51 - 0
mobile/openapi/doc/UserApi.md

@@ -11,6 +11,7 @@ Method | HTTP request | Description
 ------------- | ------------- | -------------
 [**createProfileImage**](UserApi.md#createprofileimage) | **POST** /user/profile-image | 
 [**createUser**](UserApi.md#createuser) | **POST** /user | 
+[**deleteProfileImage**](UserApi.md#deleteprofileimage) | **DELETE** /user/profile-image | 
 [**deleteUser**](UserApi.md#deleteuser) | **DELETE** /user/{id} | 
 [**getAllUsers**](UserApi.md#getallusers) | **GET** /user | 
 [**getMyUserInfo**](UserApi.md#getmyuserinfo) | **GET** /user/me | 
@@ -130,6 +131,56 @@ Name | Type | Description  | Notes
 
 [[Back to top]](#) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to Model list]](../README.md#documentation-for-models) [[Back to README]](../README.md)
 
+# **deleteProfileImage**
+> deleteProfileImage()
+
+
+
+### Example
+```dart
+import 'package:openapi/api.dart';
+// TODO Configure API key authorization: cookie
+//defaultApiClient.getAuthentication<ApiKeyAuth>('cookie').apiKey = 'YOUR_API_KEY';
+// uncomment below to setup prefix (e.g. Bearer) for API key, if needed
+//defaultApiClient.getAuthentication<ApiKeyAuth>('cookie').apiKeyPrefix = 'Bearer';
+// TODO Configure API key authorization: api_key
+//defaultApiClient.getAuthentication<ApiKeyAuth>('api_key').apiKey = 'YOUR_API_KEY';
+// uncomment below to setup prefix (e.g. Bearer) for API key, if needed
+//defaultApiClient.getAuthentication<ApiKeyAuth>('api_key').apiKeyPrefix = 'Bearer';
+// TODO Configure HTTP Bearer authorization: bearer
+// Case 1. Use String Token
+//defaultApiClient.getAuthentication<HttpBearerAuth>('bearer').setAccessToken('YOUR_ACCESS_TOKEN');
+// Case 2. Use Function which generate token.
+// String yourTokenGeneratorFunction() { ... }
+//defaultApiClient.getAuthentication<HttpBearerAuth>('bearer').setAccessToken(yourTokenGeneratorFunction);
+
+final api_instance = UserApi();
+
+try {
+    api_instance.deleteProfileImage();
+} catch (e) {
+    print('Exception when calling UserApi->deleteProfileImage: $e\n');
+}
+```
+
+### Parameters
+This endpoint does not need any parameter.
+
+### Return type
+
+void (empty response body)
+
+### Authorization
+
+[cookie](../README.md#cookie), [api_key](../README.md#api_key), [bearer](../README.md#bearer)
+
+### HTTP request headers
+
+ - **Content-Type**: Not defined
+ - **Accept**: Not defined
+
+[[Back to top]](#) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to Model list]](../README.md#documentation-for-models) [[Back to README]](../README.md)
+
 # **deleteUser**
 > UserResponseDto deleteUser(id)
 

+ 14 - 0
mobile/openapi/doc/UserAvatarColor.md

@@ -0,0 +1,14 @@
+# openapi.model.UserAvatarColor
+
+## Load the model package
+```dart
+import 'package:openapi/api.dart';
+```
+
+## Properties
+Name | Type | Description | Notes
+------------ | ------------- | ------------- | -------------
+
+[[Back to Model list]](../README.md#documentation-for-models) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to README]](../README.md)
+
+

+ 1 - 0
mobile/openapi/doc/UserDto.md

@@ -8,6 +8,7 @@ import 'package:openapi/api.dart';
 ## Properties
 Name | Type | Description | Notes
 ------------ | ------------- | ------------- | -------------
+**avatarColor** | [**UserAvatarColor**](UserAvatarColor.md) |  | 
 **email** | **String** |  | 
 **id** | **String** |  | 
 **name** | **String** |  | 

+ 1 - 0
mobile/openapi/doc/UserResponseDto.md

@@ -8,6 +8,7 @@ import 'package:openapi/api.dart';
 ## Properties
 Name | Type | Description | Notes
 ------------ | ------------- | ------------- | -------------
+**avatarColor** | [**UserAvatarColor**](UserAvatarColor.md) |  | 
 **createdAt** | [**DateTime**](DateTime.md) |  | 
 **deletedAt** | [**DateTime**](DateTime.md) |  | 
 **email** | **String** |  | 

+ 1 - 0
mobile/openapi/lib/api.dart

@@ -192,6 +192,7 @@ part 'model/update_stack_parent_dto.dart';
 part 'model/update_tag_dto.dart';
 part 'model/update_user_dto.dart';
 part 'model/usage_by_user_dto.dart';
+part 'model/user_avatar_color.dart';
 part 'model/user_dto.dart';
 part 'model/user_response_dto.dart';
 part 'model/validate_access_token_response_dto.dart';

+ 33 - 0
mobile/openapi/lib/api/user_api.dart

@@ -120,6 +120,39 @@ class UserApi {
     return null;
   }
 
+  /// Performs an HTTP 'DELETE /user/profile-image' operation and returns the [Response].
+  Future<Response> deleteProfileImageWithHttpInfo() async {
+    // ignore: prefer_const_declarations
+    final path = r'/user/profile-image';
+
+    // ignore: prefer_final_locals
+    Object? postBody;
+
+    final queryParams = <QueryParam>[];
+    final headerParams = <String, String>{};
+    final formParams = <String, String>{};
+
+    const contentTypes = <String>[];
+
+
+    return apiClient.invokeAPI(
+      path,
+      'DELETE',
+      queryParams,
+      postBody,
+      headerParams,
+      formParams,
+      contentTypes.isEmpty ? null : contentTypes.first,
+    );
+  }
+
+  Future<void> deleteProfileImage() async {
+    final response = await deleteProfileImageWithHttpInfo();
+    if (response.statusCode >= HttpStatus.badRequest) {
+      throw ApiException(response.statusCode, await _decodeBodyBytes(response));
+    }
+  }
+
   /// Performs an HTTP 'DELETE /user/{id}' operation and returns the [Response].
   /// Parameters:
   ///

+ 2 - 0
mobile/openapi/lib/api_client.dart

@@ -473,6 +473,8 @@ class ApiClient {
           return UpdateUserDto.fromJson(value);
         case 'UsageByUserDto':
           return UsageByUserDto.fromJson(value);
+        case 'UserAvatarColor':
+          return UserAvatarColorTypeTransformer().decode(value);
         case 'UserDto':
           return UserDto.fromJson(value);
         case 'UserResponseDto':

+ 3 - 0
mobile/openapi/lib/api_helper.dart

@@ -127,6 +127,9 @@ String parameterToString(dynamic value) {
   if (value is TranscodePolicy) {
     return TranscodePolicyTypeTransformer().encode(value).toString();
   }
+  if (value is UserAvatarColor) {
+    return UserAvatarColorTypeTransformer().encode(value).toString();
+  }
   if (value is VideoCodec) {
     return VideoCodecTypeTransformer().encode(value).toString();
   }

+ 9 - 1
mobile/openapi/lib/model/partner_response_dto.dart

@@ -13,6 +13,7 @@ part of openapi.api;
 class PartnerResponseDto {
   /// Returns a new [PartnerResponseDto] instance.
   PartnerResponseDto({
+    required this.avatarColor,
     required this.createdAt,
     required this.deletedAt,
     required this.email,
@@ -29,6 +30,8 @@ class PartnerResponseDto {
     required this.updatedAt,
   });
 
+  UserAvatarColor avatarColor;
+
   DateTime createdAt;
 
   DateTime? deletedAt;
@@ -71,6 +74,7 @@ class PartnerResponseDto {
 
   @override
   bool operator ==(Object other) => identical(this, other) || other is PartnerResponseDto &&
+     other.avatarColor == avatarColor &&
      other.createdAt == createdAt &&
      other.deletedAt == deletedAt &&
      other.email == email &&
@@ -89,6 +93,7 @@ class PartnerResponseDto {
   @override
   int get hashCode =>
     // ignore: unnecessary_parenthesis
+    (avatarColor.hashCode) +
     (createdAt.hashCode) +
     (deletedAt == null ? 0 : deletedAt!.hashCode) +
     (email.hashCode) +
@@ -105,10 +110,11 @@ class PartnerResponseDto {
     (updatedAt.hashCode);
 
   @override
-  String toString() => 'PartnerResponseDto[createdAt=$createdAt, deletedAt=$deletedAt, email=$email, externalPath=$externalPath, id=$id, inTimeline=$inTimeline, isAdmin=$isAdmin, memoriesEnabled=$memoriesEnabled, name=$name, oauthId=$oauthId, profileImagePath=$profileImagePath, shouldChangePassword=$shouldChangePassword, storageLabel=$storageLabel, updatedAt=$updatedAt]';
+  String toString() => 'PartnerResponseDto[avatarColor=$avatarColor, createdAt=$createdAt, deletedAt=$deletedAt, email=$email, externalPath=$externalPath, id=$id, inTimeline=$inTimeline, isAdmin=$isAdmin, memoriesEnabled=$memoriesEnabled, name=$name, oauthId=$oauthId, profileImagePath=$profileImagePath, shouldChangePassword=$shouldChangePassword, storageLabel=$storageLabel, updatedAt=$updatedAt]';
 
   Map<String, dynamic> toJson() {
     final json = <String, dynamic>{};
+      json[r'avatarColor'] = this.avatarColor;
       json[r'createdAt'] = this.createdAt.toUtc().toIso8601String();
     if (this.deletedAt != null) {
       json[r'deletedAt'] = this.deletedAt!.toUtc().toIso8601String();
@@ -154,6 +160,7 @@ class PartnerResponseDto {
       final json = value.cast<String, dynamic>();
 
       return PartnerResponseDto(
+        avatarColor: UserAvatarColor.fromJson(json[r'avatarColor'])!,
         createdAt: mapDateTime(json, r'createdAt', '')!,
         deletedAt: mapDateTime(json, r'deletedAt', ''),
         email: mapValueOfType<String>(json, r'email')!,
@@ -215,6 +222,7 @@ class PartnerResponseDto {
 
   /// The list of required keys that must be present in a JSON.
   static const requiredKeys = <String>{
+    'avatarColor',
     'createdAt',
     'deletedAt',
     'email',

+ 18 - 1
mobile/openapi/lib/model/update_user_dto.dart

@@ -13,6 +13,7 @@ part of openapi.api;
 class UpdateUserDto {
   /// Returns a new [UpdateUserDto] instance.
   UpdateUserDto({
+    this.avatarColor,
     this.email,
     this.externalPath,
     required this.id,
@@ -24,6 +25,14 @@ class UpdateUserDto {
     this.storageLabel,
   });
 
+  ///
+  /// Please note: This property should have been non-nullable! Since the specification file
+  /// does not include a default value (using the "default:" property), however, the generated
+  /// source code must fall back to having a nullable type.
+  /// Consider adding a "default:" property in the specification file to hide this note.
+  ///
+  UserAvatarColor? avatarColor;
+
   ///
   /// Please note: This property should have been non-nullable! Since the specification file
   /// does not include a default value (using the "default:" property), however, the generated
@@ -92,6 +101,7 @@ class UpdateUserDto {
 
   @override
   bool operator ==(Object other) => identical(this, other) || other is UpdateUserDto &&
+     other.avatarColor == avatarColor &&
      other.email == email &&
      other.externalPath == externalPath &&
      other.id == id &&
@@ -105,6 +115,7 @@ class UpdateUserDto {
   @override
   int get hashCode =>
     // ignore: unnecessary_parenthesis
+    (avatarColor == null ? 0 : avatarColor!.hashCode) +
     (email == null ? 0 : email!.hashCode) +
     (externalPath == null ? 0 : externalPath!.hashCode) +
     (id.hashCode) +
@@ -116,10 +127,15 @@ class UpdateUserDto {
     (storageLabel == null ? 0 : storageLabel!.hashCode);
 
   @override
-  String toString() => 'UpdateUserDto[email=$email, externalPath=$externalPath, id=$id, isAdmin=$isAdmin, memoriesEnabled=$memoriesEnabled, name=$name, password=$password, shouldChangePassword=$shouldChangePassword, storageLabel=$storageLabel]';
+  String toString() => 'UpdateUserDto[avatarColor=$avatarColor, email=$email, externalPath=$externalPath, id=$id, isAdmin=$isAdmin, memoriesEnabled=$memoriesEnabled, name=$name, password=$password, shouldChangePassword=$shouldChangePassword, storageLabel=$storageLabel]';
 
   Map<String, dynamic> toJson() {
     final json = <String, dynamic>{};
+    if (this.avatarColor != null) {
+      json[r'avatarColor'] = this.avatarColor;
+    } else {
+    //  json[r'avatarColor'] = null;
+    }
     if (this.email != null) {
       json[r'email'] = this.email;
     } else {
@@ -172,6 +188,7 @@ class UpdateUserDto {
       final json = value.cast<String, dynamic>();
 
       return UpdateUserDto(
+        avatarColor: UserAvatarColor.fromJson(json[r'avatarColor']),
         email: mapValueOfType<String>(json, r'email'),
         externalPath: mapValueOfType<String>(json, r'externalPath'),
         id: mapValueOfType<String>(json, r'id')!,

+ 109 - 0
mobile/openapi/lib/model/user_avatar_color.dart

@@ -0,0 +1,109 @@
+//
+// AUTO-GENERATED FILE, DO NOT MODIFY!
+//
+// @dart=2.12
+
+// ignore_for_file: unused_element, unused_import
+// ignore_for_file: always_put_required_named_parameters_first
+// ignore_for_file: constant_identifier_names
+// ignore_for_file: lines_longer_than_80_chars
+
+part of openapi.api;
+
+
+class UserAvatarColor {
+  /// Instantiate a new enum with the provided [value].
+  const UserAvatarColor._(this.value);
+
+  /// The underlying value of this enum member.
+  final String value;
+
+  @override
+  String toString() => value;
+
+  String toJson() => value;
+
+  static const primary = UserAvatarColor._(r'primary');
+  static const pink = UserAvatarColor._(r'pink');
+  static const red = UserAvatarColor._(r'red');
+  static const yellow = UserAvatarColor._(r'yellow');
+  static const blue = UserAvatarColor._(r'blue');
+  static const green = UserAvatarColor._(r'green');
+  static const purple = UserAvatarColor._(r'purple');
+  static const orange = UserAvatarColor._(r'orange');
+  static const gray = UserAvatarColor._(r'gray');
+  static const amber = UserAvatarColor._(r'amber');
+
+  /// List of all possible values in this [enum][UserAvatarColor].
+  static const values = <UserAvatarColor>[
+    primary,
+    pink,
+    red,
+    yellow,
+    blue,
+    green,
+    purple,
+    orange,
+    gray,
+    amber,
+  ];
+
+  static UserAvatarColor? fromJson(dynamic value) => UserAvatarColorTypeTransformer().decode(value);
+
+  static List<UserAvatarColor>? listFromJson(dynamic json, {bool growable = false,}) {
+    final result = <UserAvatarColor>[];
+    if (json is List && json.isNotEmpty) {
+      for (final row in json) {
+        final value = UserAvatarColor.fromJson(row);
+        if (value != null) {
+          result.add(value);
+        }
+      }
+    }
+    return result.toList(growable: growable);
+  }
+}
+
+/// Transformation class that can [encode] an instance of [UserAvatarColor] to String,
+/// and [decode] dynamic data back to [UserAvatarColor].
+class UserAvatarColorTypeTransformer {
+  factory UserAvatarColorTypeTransformer() => _instance ??= const UserAvatarColorTypeTransformer._();
+
+  const UserAvatarColorTypeTransformer._();
+
+  String encode(UserAvatarColor data) => data.value;
+
+  /// Decodes a [dynamic value][data] to a UserAvatarColor.
+  ///
+  /// If [allowNull] is true and the [dynamic value][data] cannot be decoded successfully,
+  /// then null is returned. However, if [allowNull] is false and the [dynamic value][data]
+  /// cannot be decoded successfully, then an [UnimplementedError] is thrown.
+  ///
+  /// The [allowNull] is very handy when an API changes and a new enum value is added or removed,
+  /// and users are still using an old app with the old code.
+  UserAvatarColor? decode(dynamic data, {bool allowNull = true}) {
+    if (data != null) {
+      switch (data) {
+        case r'primary': return UserAvatarColor.primary;
+        case r'pink': return UserAvatarColor.pink;
+        case r'red': return UserAvatarColor.red;
+        case r'yellow': return UserAvatarColor.yellow;
+        case r'blue': return UserAvatarColor.blue;
+        case r'green': return UserAvatarColor.green;
+        case r'purple': return UserAvatarColor.purple;
+        case r'orange': return UserAvatarColor.orange;
+        case r'gray': return UserAvatarColor.gray;
+        case r'amber': return UserAvatarColor.amber;
+        default:
+          if (!allowNull) {
+            throw ArgumentError('Unknown enum value to decode: $data');
+          }
+      }
+    }
+    return null;
+  }
+
+  /// Singleton [UserAvatarColorTypeTransformer] instance.
+  static UserAvatarColorTypeTransformer? _instance;
+}
+

+ 9 - 1
mobile/openapi/lib/model/user_dto.dart

@@ -13,12 +13,15 @@ part of openapi.api;
 class UserDto {
   /// Returns a new [UserDto] instance.
   UserDto({
+    required this.avatarColor,
     required this.email,
     required this.id,
     required this.name,
     required this.profileImagePath,
   });
 
+  UserAvatarColor avatarColor;
+
   String email;
 
   String id;
@@ -29,6 +32,7 @@ class UserDto {
 
   @override
   bool operator ==(Object other) => identical(this, other) || other is UserDto &&
+     other.avatarColor == avatarColor &&
      other.email == email &&
      other.id == id &&
      other.name == name &&
@@ -37,16 +41,18 @@ class UserDto {
   @override
   int get hashCode =>
     // ignore: unnecessary_parenthesis
+    (avatarColor.hashCode) +
     (email.hashCode) +
     (id.hashCode) +
     (name.hashCode) +
     (profileImagePath.hashCode);
 
   @override
-  String toString() => 'UserDto[email=$email, id=$id, name=$name, profileImagePath=$profileImagePath]';
+  String toString() => 'UserDto[avatarColor=$avatarColor, email=$email, id=$id, name=$name, profileImagePath=$profileImagePath]';
 
   Map<String, dynamic> toJson() {
     final json = <String, dynamic>{};
+      json[r'avatarColor'] = this.avatarColor;
       json[r'email'] = this.email;
       json[r'id'] = this.id;
       json[r'name'] = this.name;
@@ -62,6 +68,7 @@ class UserDto {
       final json = value.cast<String, dynamic>();
 
       return UserDto(
+        avatarColor: UserAvatarColor.fromJson(json[r'avatarColor'])!,
         email: mapValueOfType<String>(json, r'email')!,
         id: mapValueOfType<String>(json, r'id')!,
         name: mapValueOfType<String>(json, r'name')!,
@@ -113,6 +120,7 @@ class UserDto {
 
   /// The list of required keys that must be present in a JSON.
   static const requiredKeys = <String>{
+    'avatarColor',
     'email',
     'id',
     'name',

+ 9 - 1
mobile/openapi/lib/model/user_response_dto.dart

@@ -13,6 +13,7 @@ part of openapi.api;
 class UserResponseDto {
   /// Returns a new [UserResponseDto] instance.
   UserResponseDto({
+    required this.avatarColor,
     required this.createdAt,
     required this.deletedAt,
     required this.email,
@@ -28,6 +29,8 @@ class UserResponseDto {
     required this.updatedAt,
   });
 
+  UserAvatarColor avatarColor;
+
   DateTime createdAt;
 
   DateTime? deletedAt;
@@ -62,6 +65,7 @@ class UserResponseDto {
 
   @override
   bool operator ==(Object other) => identical(this, other) || other is UserResponseDto &&
+     other.avatarColor == avatarColor &&
      other.createdAt == createdAt &&
      other.deletedAt == deletedAt &&
      other.email == email &&
@@ -79,6 +83,7 @@ class UserResponseDto {
   @override
   int get hashCode =>
     // ignore: unnecessary_parenthesis
+    (avatarColor.hashCode) +
     (createdAt.hashCode) +
     (deletedAt == null ? 0 : deletedAt!.hashCode) +
     (email.hashCode) +
@@ -94,10 +99,11 @@ class UserResponseDto {
     (updatedAt.hashCode);
 
   @override
-  String toString() => 'UserResponseDto[createdAt=$createdAt, deletedAt=$deletedAt, email=$email, externalPath=$externalPath, id=$id, isAdmin=$isAdmin, memoriesEnabled=$memoriesEnabled, name=$name, oauthId=$oauthId, profileImagePath=$profileImagePath, shouldChangePassword=$shouldChangePassword, storageLabel=$storageLabel, updatedAt=$updatedAt]';
+  String toString() => 'UserResponseDto[avatarColor=$avatarColor, createdAt=$createdAt, deletedAt=$deletedAt, email=$email, externalPath=$externalPath, id=$id, isAdmin=$isAdmin, memoriesEnabled=$memoriesEnabled, name=$name, oauthId=$oauthId, profileImagePath=$profileImagePath, shouldChangePassword=$shouldChangePassword, storageLabel=$storageLabel, updatedAt=$updatedAt]';
 
   Map<String, dynamic> toJson() {
     final json = <String, dynamic>{};
+      json[r'avatarColor'] = this.avatarColor;
       json[r'createdAt'] = this.createdAt.toUtc().toIso8601String();
     if (this.deletedAt != null) {
       json[r'deletedAt'] = this.deletedAt!.toUtc().toIso8601String();
@@ -138,6 +144,7 @@ class UserResponseDto {
       final json = value.cast<String, dynamic>();
 
       return UserResponseDto(
+        avatarColor: UserAvatarColor.fromJson(json[r'avatarColor'])!,
         createdAt: mapDateTime(json, r'createdAt', '')!,
         deletedAt: mapDateTime(json, r'deletedAt', ''),
         email: mapValueOfType<String>(json, r'email')!,
@@ -198,6 +205,7 @@ class UserResponseDto {
 
   /// The list of required keys that must be present in a JSON.
   static const requiredKeys = <String>{
+    'avatarColor',
     'createdAt',
     'deletedAt',
     'email',

+ 5 - 0
mobile/openapi/test/partner_response_dto_test.dart

@@ -16,6 +16,11 @@ void main() {
   // final instance = PartnerResponseDto();
 
   group('test PartnerResponseDto', () {
+    // UserAvatarColor avatarColor
+    test('to test the property `avatarColor`', () async {
+      // TODO
+    });
+
     // DateTime createdAt
     test('to test the property `createdAt`', () async {
       // TODO

+ 5 - 0
mobile/openapi/test/update_user_dto_test.dart

@@ -16,6 +16,11 @@ void main() {
   // final instance = UpdateUserDto();
 
   group('test UpdateUserDto', () {
+    // UserAvatarColor avatarColor
+    test('to test the property `avatarColor`', () async {
+      // TODO
+    });
+
     // String email
     test('to test the property `email`', () async {
       // TODO

+ 5 - 0
mobile/openapi/test/user_api_test.dart

@@ -27,6 +27,11 @@ void main() {
       // TODO
     });
 
+    //Future deleteProfileImage() async
+    test('test deleteProfileImage', () async {
+      // TODO
+    });
+
     //Future<UserResponseDto> deleteUser(String id) async
     test('test deleteUser', () async {
       // TODO

+ 21 - 0
mobile/openapi/test/user_avatar_color_test.dart

@@ -0,0 +1,21 @@
+//
+// AUTO-GENERATED FILE, DO NOT MODIFY!
+//
+// @dart=2.12
+
+// ignore_for_file: unused_element, unused_import
+// ignore_for_file: always_put_required_named_parameters_first
+// ignore_for_file: constant_identifier_names
+// ignore_for_file: lines_longer_than_80_chars
+
+import 'package:openapi/api.dart';
+import 'package:test/test.dart';
+
+// tests for UserAvatarColor
+void main() {
+
+  group('test UserAvatarColor', () {
+
+  });
+
+}

+ 5 - 0
mobile/openapi/test/user_dto_test.dart

@@ -16,6 +16,11 @@ void main() {
   // final instance = UserDto();
 
   group('test UserDto', () {
+    // UserAvatarColor avatarColor
+    test('to test the property `avatarColor`', () async {
+      // TODO
+    });
+
     // String email
     test('to test the property `email`', () async {
       // TODO

+ 5 - 0
mobile/openapi/test/user_response_dto_test.dart

@@ -16,6 +16,11 @@ void main() {
   // final instance = UserResponseDto();
 
   group('test UserResponseDto', () {
+    // UserAvatarColor avatarColor
+    test('to test the property `avatarColor`', () async {
+      // TODO
+    });
+
     // DateTime createdAt
     test('to test the property `createdAt`', () async {
       // TODO

+ 53 - 0
server/immich-openapi-specs.json

@@ -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",

+ 1 - 0
server/src/domain/auth/auth.service.spec.ts

@@ -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',

+ 3 - 0
server/src/domain/partner/partner.service.spec.ts

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

+ 7 - 1
server/src/domain/user/dto/update-user.dto.ts

@@ -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;
 }

+ 18 - 1
server/src/domain/user/response-dto/user-response.dto.ts

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

+ 0 - 1
server/src/domain/user/user.core.ts

@@ -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,

+ 38 - 3
server/src/domain/user/user.service.spec.ts

@@ -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', () => {

+ 14 - 1
server/src/domain/user/user.service.ts

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

+ 2 - 1
server/src/immich/controllers/auth.controller.ts

@@ -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')

+ 8 - 0
server/src/immich/controllers/user.controller.ts

@@ -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> {

+ 16 - 0
server/src/infra/entities/user.entity.ts

@@ -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 - 0
server/src/infra/migrations/1699889987493-AddAvatarColor.ts

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

+ 1 - 0
server/test/e2e/auth.e2e-spec.ts

@@ -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',

+ 8 - 1
server/test/fixtures/user.stub.ts

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

+ 119 - 0
web/src/api/open-api/api.ts

@@ -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.

+ 2 - 2
web/src/lib/components/album-page/share-info-modal.svelte

@@ -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>
 

+ 2 - 2
web/src/lib/components/album-page/user-selection-modal.svelte

@@ -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">

+ 1 - 1
web/src/lib/components/asset-viewer/detail-panel.svelte

@@ -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">

+ 56 - 3
web/src/lib/components/shared-components/navigation-bar/account-info-panel.svelte

@@ -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}

+ 39 - 0
web/src/lib/components/shared-components/navigation-bar/avatar-selector.svelte

@@ -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>

+ 4 - 2
web/src/lib/components/shared-components/navigation-bar/navigation-bar.svelte

@@ -124,7 +124,9 @@
             on:mouseleave={() => (shouldShowAccountInfo = false)}
             on:click={() => (shouldShowAccountInfoPanel = !shouldShowAccountInfoPanel)}
           >
-            <UserAvatar {user} size="lg" showTitle={false} interactive />
+            {#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>

+ 19 - 20
web/src/lib/components/shared-components/user-avatar.svelte

@@ -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>

+ 1 - 1
web/src/lib/components/user-settings-page/partner-selection-modal.svelte

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

+ 1 - 1
web/src/lib/components/user-settings-page/partner-settings.svelte

@@ -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}

+ 2 - 2
web/src/routes/(user)/albums/[albumId]/+page.svelte

@@ -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}
 

+ 1 - 1
web/src/routes/(user)/sharing/+page.svelte

@@ -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}

+ 2 - 1
web/src/test-data/factories/user-factory.ts

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