From d25a245049bd0612b4ba6f3c86b706c98c72c19a Mon Sep 17 00:00:00 2001 From: martin <74269598+martabal@users.noreply.github.com> Date: Tue, 14 Nov 2023 04:10:35 +0100 Subject: [PATCH] feat(web,server): user avatar color (#4779) --- cli/src/api/open-api/api.ts | 119 ++++++++++++++++++ .../album/views/album_options_part.dart | 9 +- .../album/views/album_viewer_page.dart | 1 - mobile/lib/shared/models/user.dart | 78 ++++++++++++ mobile/lib/shared/models/user.g.dart | Bin 44586 -> 48120 bytes .../app_bar_dialog/app_bar_profile_info.dart | 28 ++--- mobile/lib/shared/ui/immich_app_bar.dart | 5 +- mobile/lib/shared/ui/user_circle_avatar.dart | 29 ++--- mobile/openapi/.openapi-generator/FILES | 3 + mobile/openapi/README.md | Bin 23205 -> 23360 bytes mobile/openapi/doc/PartnerResponseDto.md | Bin 922 -> 988 bytes mobile/openapi/doc/UpdateUserDto.md | Bin 762 -> 839 bytes mobile/openapi/doc/UserApi.md | Bin 18089 -> 19904 bytes mobile/openapi/doc/UserAvatarColor.md | Bin 0 -> 381 bytes mobile/openapi/doc/UserDto.md | Bin 496 -> 562 bytes mobile/openapi/doc/UserResponseDto.md | Bin 876 -> 942 bytes mobile/openapi/lib/api.dart | Bin 7572 -> 7609 bytes mobile/openapi/lib/api/user_api.dart | Bin 14602 -> 15515 bytes mobile/openapi/lib/api_client.dart | Bin 22263 -> 22360 bytes mobile/openapi/lib/api_helper.dart | Bin 5628 -> 5736 bytes .../lib/model/partner_response_dto.dart | Bin 7665 -> 7960 bytes mobile/openapi/lib/model/update_user_dto.dart | Bin 8398 -> 9133 bytes .../openapi/lib/model/user_avatar_color.dart | Bin 0 -> 3592 bytes mobile/openapi/lib/model/user_dto.dart | Bin 3336 -> 3631 bytes .../openapi/lib/model/user_response_dto.dart | Bin 6905 -> 7200 bytes .../test/partner_response_dto_test.dart | Bin 1939 -> 2055 bytes mobile/openapi/test/update_user_dto_test.dart | Bin 1391 -> 1507 bytes mobile/openapi/test/user_api_test.dart | Bin 1615 -> 1725 bytes .../openapi/test/user_avatar_color_test.dart | Bin 0 -> 427 bytes mobile/openapi/test/user_dto_test.dart | Bin 838 -> 954 bytes .../openapi/test/user_response_dto_test.dart | Bin 1827 -> 1943 bytes server/immich-openapi-specs.json | 53 ++++++++ server/src/domain/auth/auth.service.spec.ts | 1 + .../domain/partner/partner.service.spec.ts | 3 + server/src/domain/user/dto/update-user.dto.ts | 8 +- .../user/response-dto/user-response.dto.ts | 19 ++- server/src/domain/user/user.core.ts | 1 - server/src/domain/user/user.service.spec.ts | 41 +++++- server/src/domain/user/user.service.ts | 15 ++- .../src/immich/controllers/auth.controller.ts | 3 +- .../src/immich/controllers/user.controller.ts | 8 ++ server/src/infra/entities/user.entity.ts | 16 +++ .../1699889987493-AddAvatarColor.ts | 14 +++ server/test/e2e/auth.e2e-spec.ts | 1 + server/test/fixtures/user.stub.ts | 9 +- web/src/api/open-api/api.ts | 119 ++++++++++++++++++ .../album-page/share-info-modal.svelte | 4 +- .../album-page/user-selection-modal.svelte | 4 +- .../asset-viewer/detail-panel.svelte | 2 +- .../navigation-bar/account-info-panel.svelte | 59 ++++++++- .../navigation-bar/avatar-selector.svelte | 39 ++++++ .../navigation-bar/navigation-bar.svelte | 6 +- .../shared-components/user-avatar.svelte | 39 +++--- .../partner-selection-modal.svelte | 2 +- .../partner-settings.svelte | 2 +- .../(user)/albums/[albumId]/+page.svelte | 4 +- web/src/routes/(user)/sharing/+page.svelte | 2 +- web/src/test-data/factories/user-factory.ts | 3 +- 58 files changed, 649 insertions(+), 100 deletions(-) create mode 100644 mobile/openapi/doc/UserAvatarColor.md create mode 100644 mobile/openapi/lib/model/user_avatar_color.dart create mode 100644 mobile/openapi/test/user_avatar_color_test.dart create mode 100644 server/src/infra/migrations/1699889987493-AddAvatarColor.ts create mode 100644 web/src/lib/components/shared-components/navigation-bar/avatar-selector.svelte diff --git a/cli/src/api/open-api/api.ts b/cli/src/api/open-api/api.ts index 8fb2c1b3d1..4eb5e12288 100644 --- a/cli/src/api/open-api/api.ts +++ b/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 => { + 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> { + 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 { return localVarFp.createUser(requestParameters.createUserDto, options).then((request) => request(axios, basePath)); }, + /** + * + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + deleteProfileImage(options?: AxiosRequestConfig): AxiosPromise { + 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. diff --git a/mobile/lib/modules/album/views/album_options_part.dart b/mobile/lib/modules/album/views/album_options_part.dart index 8d45d36b4f..5e5a3a6b86 100644 --- a/mobile/lib/modules/album/views/album_options_part.dart +++ b/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( diff --git a/mobile/lib/modules/album/views/album_viewer_page.dart b/mobile/lib/modules/album/views/album_viewer_page.dart index b123363286..bcb32b8359 100644 --- a/mobile/lib/modules/album/views/album_viewer_page.dart +++ b/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, ), ); }), diff --git a/mobile/lib/shared/models/user.dart b/mobile/lib/shared/models/user.dart index e9e5004da0..094509c897 100644 --- a/mobile/lib/shared/models/user.dart +++ b/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); + } + } +} diff --git a/mobile/lib/shared/models/user.g.dart b/mobile/lib/shared/models/user.g.dart index 82420a17535826a2c88041f15f3c4a24dc8bcf57..0b2605b949894978dde8a31a66d1d04c114dabfc 100644 GIT binary patch delta 1785 zcma)6O>7fK7?oqM9XavZyS5`h0k%Qln7FR<>mLLOp#c%|mo|x7MWI>8LkKutv$hH0 zN9Mp!C7^&F2vv&Of`mXpYLTp}q7o?)f_qOKTB(O#IP?$+sf1f+HWuCWrUf6o`{sS~ z=6!EwcJB9@rPnnNf3()g$K`^o?ocv{irbF0cwrR`V~h4x@zhL#gq)K0BiZ8AUOAVL zz8K0ARX4kj62o$)NGQ8p91P)mYY=gpfS1+*d}kApv<<<@iTHb`h&OgO5}Y3|ZB01E zd9ju|h$T+KwVO74%YAO?!f9?jdLD4t-(Op)_F|WPGk&uRxO<@C?by%u576%r@R$7{ z=IL|9F<#ReDgWY_=YqY|d$V%rP@WX>BazXNq@<3J@j_VVJUp3AlM|SAwcwU(qooax zU7N9nug5D_0R6lR0e%C{@oSLgB|PElFvPp5FB|Ug>nu?$@KH+)Z}_k^J{rQ~`c>$4 zH_`P$+;>a5WfGMHB~x@-Y%D(--H1x$G*QFs!MyKR_Z2f4T?fHYsk{5F6x);S|85}@ z3Fp+wDOsIin}Ax+21_eKo=vo^Pd#mxc3hzU9eCu4BPxin3GFoKn6QmDGA%@CBlm@b zC5}IZPS(O|@%dQwM!&b)YN!9<6HDS@G2$f%iI&We#SGdZEk+G&oPDJ@4WL+9?aRKD*Y^#^r#uJ2e; z&I~HeRCk4&f`_{=89sIc$yAGO_AYt}3q&0}EN7P$s!lMj_jvI~@~3KzW}U_!lFz?O z;56n3W7Q(PI+0#dn5HLfMH3P|okV5HnZ5*AIPnz@M0yO;-P_LK>(6%JqYkd@Y>RJ#%9@ac|N74`&@;vj6}9 delta 372 zcmX}kPbh<790%}Td!P56Ht)0ddH24vC2dR6+OU}zQx2R+E%_Ir<{*^A4*p%Km5cg{ z!o`Zi2=$~W|4v-gT^`MV2&1SC#*8$;+`@59$lN~$0uYEjtSycW2k2rmw diff --git a/mobile/lib/shared/ui/app_bar_dialog/app_bar_profile_info.dart b/mobile/lib/shared/ui/app_bar_dialog/app_bar_profile_info.dart index c4951e3393..abb81ca895 100644 --- a/mobile/lib/shared/ui/app_bar_dialog/app_bar_profile_info.dart +++ b/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 { diff --git a/mobile/lib/shared/ui/immich_app_bar.dart b/mobile/lib/shared/ui/immich_app_bar.dart index bbf5a48a00..144adc7876 100644 --- a/mobile/lib/shared/ui/immich_app_bar.dart +++ b/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, diff --git a/mobile/lib/shared/ui/user_circle_avatar.dart b/mobile/lib/shared/ui/user_circle_avatar.dart index 5920758a7f..1f6ef15f50 100644 --- a/mobile/lib/shared/ui/user_circle_avatar.dart +++ b/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), diff --git a/mobile/openapi/.openapi-generator/FILES b/mobile/openapi/.openapi-generator/FILES index c4f9679768..9ca8335169 100644 --- a/mobile/openapi/.openapi-generator/FILES +++ b/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 diff --git a/mobile/openapi/README.md b/mobile/openapi/README.md index 551169a993904ddb3b329dcf44be88c034480c75..4d54d41c39ec627003a5de1271522ddea7e5b55f 100644 GIT binary patch delta 92 zcmZ3wmGQtf#tlC$COcXv$fcy_q?V)>6y>L7=A>rkCZ?xqD%2=wX}P%ixQ4h+uC>tI n{Mq81yrN@SVo73=bAC>KQLIKvezLxPXmM&0lIZ3w5g!==fN>+! delta 19 bcmX@GjdAH##tlC$Hm6yhkl!2=`GgSwVSWiZ diff --git a/mobile/openapi/doc/PartnerResponseDto.md b/mobile/openapi/doc/PartnerResponseDto.md index f7133bbf7eb7e6ffd3d71163ea2998fd4bfaa2f1..574b96f8df9b45d7d6d883c360d849c4fa225acd 100644 GIT binary patch delta 74 zcmbQmeusU+UHinc#FE4!=lq=fA}uY28ii;rtcDa%$&)FjG~iwG4gF<{KW_WZ;B(o delta 20 ccmX@k_KS7G4VKj0#LUSJOiG)*8UHW>09y$N2mk;8 diff --git a/mobile/openapi/doc/UserApi.md b/mobile/openapi/doc/UserApi.md index fb88d53bdd178112fd749cf32d6c92217178b2e6..6d2a028da4503f92d5a5135da7b0c929461e2fd4 100644 GIT binary patch delta 203 zcmZ44%XnZmwn{j0?;@2&J7u;LKS`OwX3F)j*&hU;7$p+NISZp7ivd zUV|JdnCP@;OM`yRl-jcqlOB2C1kg0NVq;NGgd+u`>EFzepzAtol7RD(li=h!pI-%M zRkRW&_mw&-ZBtn$agry!>KsskrACd^a(zHYZq|GX`E6$KmF1^FTIW%$c{ G3ILz3S9Rh5 literal 0 HcmV?d00001 diff --git a/mobile/openapi/doc/UserDto.md b/mobile/openapi/doc/UserDto.md index c8b750a1d3750b61bb9d4defd2b80c4cad2b15d9..7e5770f8401f06bc715f050c7e0179aaab183b51 100644 GIT binary patch delta 74 zcmeysyoqJPCHus(#FE4!=lq=fA}uY28ii;rtcwUha E01dtxod5s; delta 11 ScmdnQ@_~86rOhIYa*O~SOat=( diff --git a/mobile/openapi/doc/UserResponseDto.md b/mobile/openapi/doc/UserResponseDto.md index ddf7c574c6e13a10e0082afcab2147fdb74938f4..93f9aa62a379232b335dc8e3427069af788511f7 100644 GIT binary patch delta 86 zcmaFEwvK(mEw{w7#FE4!=lq=fA}uY28ii;rt delta 11 ScmZ3-{)TPBt<5rwlb8S=YXo)x diff --git a/mobile/openapi/lib/api.dart b/mobile/openapi/lib/api.dart index ed0b51f88d3b24536ab2b4d776c605a8322db996..92bfa3f81dc49868c99f727405aaf5fc4414fe46 100644 GIT binary patch delta 29 lcmbPYz0-QbQE8sUvc!_aqWI+eocyB6AEiV$AC$h#2mrfP408Yg delta 12 UcmdmKJ;i#%QR&UEq|Y+~04KNw+yDRo diff --git a/mobile/openapi/lib/api/user_api.dart b/mobile/openapi/lib/api/user_api.dart index 23f25492ce97d35c5a57b0cc6bb274b6261508e5..26ab3dcd0d0ae21d6724b4ded28f28504e86801c 100644 GIT binary patch delta 94 zcmeAwnq9dehJW&5M)S#cqy<@0Qgc!#2QW!bW|#4qe2VGaWG``x$<=~06x>QnN{do$ x%JMT)>=Zz%N>T%g^3yVNQay7M(^EAj%NwXpo+rdMIfzMea;Dsr%_jWUGy#jYB2EAR delta 12 TcmbPT*;TY5hJW*0p>>)7Cp87N diff --git a/mobile/openapi/lib/api_client.dart b/mobile/openapi/lib/api_client.dart index c03a469ae36ee5c72379fb064671b1ebd585090f..4c24967ec551c619e24a66206ed81dba35d41564 100644 GIT binary patch delta 50 wcmeyqmhr|q#tl-w+>T|5C5c7O`8oMTlMPiw_(O|Ri;%?(6!kZY`99^{G!PV8AUeFU~FTQ4lPbCazscdASv1`$(+g}g(RS0 zYl|YE&z{R9jwG*_kyxDJoS%}asi3|28mBuGvSwQqB$YY}n`L!lUt=Xw?A=V@dCiRdDAB*p5nnu|f`WPtzx delta 46 zcmV+}0MY-LKJh!Sm;tl50fqsyT?1eQv*rh00<&QXK?1Y9459(EjSlw(v&s}`36n)0 E)3%5bNdN!< diff --git a/mobile/openapi/lib/model/update_user_dto.dart b/mobile/openapi/lib/model/update_user_dto.dart index 9d381cd4b763f246d814a18b52bf72235d7a6c0f..d0e46e7f5dc5313120a76e059fc89e89df74a120 100644 GIT binary patch delta 315 zcmX@-xYm6`10zpjSz<|Ik#l}dev!`RGDZz1nb6|YB1eR%y#kVw&4oN!jGLD+Z|0Ih z5>>FZMN#C%w~SF0O+K$QCr80v!9c+ZS?T1D0>Ya=@PB7SHqKTBNw1E==I4THjNC{< zlY4|EkquFgRj^ek$;d3$LsnEMDu<%!pqSidUf~_AHb{!B6wo}YmsXUY>s6eer;!CD TqKnj#6syN->TKR4A;bXyI(Ks# delta 42 zcmV+_0M-AkN6ta8fC00X0UiRgG6lN|vlbAS0kh>1_W`r^6D9$(rxmybv!ojd2xZg{ A@&Et; diff --git a/mobile/openapi/lib/model/user_avatar_color.dart b/mobile/openapi/lib/model/user_avatar_color.dart new file mode 100644 index 0000000000000000000000000000000000000000..075f58d3a58bb00ac5bcf753396d37a97d454cef GIT binary patch literal 3592 zcmai1ZBH9V5dQ98F_EfZB-d{9sUme8R7^{hLe-cLm1SA$yFGi$-tKjG56g=9@15DX zJK$atBw)?VGw*NAW~12*F7H=&KmNYFUH*N!T3*5R&FAF=uI}LWZUukdUEN&&^9I?7 z^J6Mpn7^C;{BA@a)!tYqx0!X@Olf%mr768kw^GYonlPz9tGuu-^df{lij&_KW!O6T zU#T3zjmYt5Asqe=(s>l_d_O#~#s^`-R;8#=WlFjT_r1!rGSY9gHG59;goA{_*+;Z2h4rJ*rQA#r7fo(UoNmYAp!_tlouguwzd{+MNu|>#W%dxMWF+yj zs$Awf=}2y-jL`5UxP~)|`Dh=db7%yzRK#HUF+PX+2NoJx8}cV_O`C{e4l1dvFPzGS zJHfoi4|i5k&(IHm*EnS!hCoQ_L0~-$0Fctdf0A0;;{b$-J*XWfX8=Q_9>m^BX$ByX z)`KZZSLh)USSfe(;9*VU#;SX3P{KE9}gOkc{N*2Fj4*1ED z8YBR%1W!e-!Zr6y|eu1aBopg1y;d6??5UzAc8HF1 zfh=Gf81iNgrqueSL;!YTD$=xx9OD3KcNr=IbPi6$Yn1XMF>8!KAoCU?-9X z;_ir;_HUb3F~uxrA_*1;@b~JDYIEDzy;#iq*H23YZX@pX-d#1sT&)id*tlgGkn3^Z z(1Z6}Gfu8zb9h{#>pD`gC^lp1xI8Q0~GD)QO848J`f$9VCCYB~i(2sxa@1+Q=ml%CEG6l^sLD~jhEi2gt=L{_$n zVo?QtTxXrqT@Sb>oj6S>nazBoxkZf785tfAujIhgmxCk`Uc&QTa#5wRZO*h>H9Q_^ z(9}S>O@>iO?Lg={8AKql1E_A3K@8J7uy~#ff{5CI@Od(biR2EbI!^{sOz*&|`(zNt z^bRbZD1#uPb|8GB3}Pbr8I;OQ;Cj5knykO5tll(5_p*G5BT%QorQ4VEpH1w*@!7q3 zFxY>5iF4cFsN!LA0Y6ZY@}X7h_{P$rS}(JqoRKH7EU_f9$T>eJzes0t3S+5sXmM(hBSKICNx|eRjN7G<_zJeRDAF}d zm&K8!^)eERGo15NQZ*H{CwnqaK-Oxjf}~JKVe(DpdB{eo$12z=lw@QU>me!IoWb&r w(H2R-N&(HWdTB-Zxn9Njc^X+jBDzQ&NwIpYrlQVfU$$B{USv_7$wzox0360+(f|Me delta 41 zcmV+^0M`Gn9EcjQcL9@@0b-LF0qQl1B#P)1fH`+1>XU)Y6o-%lkp8cV^9#d diff --git a/mobile/openapi/lib/model/user_response_dto.dart b/mobile/openapi/lib/model/user_response_dto.dart index 73b30881f82533d90a0ad71c88b337c63d556606..11a182b6b7475b93c344ee90b635822e6d5b0449 100644 GIT binary patch delta 277 zcmexqy1-&XKO=WySz<|Ik#l}de$nJWCXvnkjD?KSp~b01jtB__Bt@HlGksu^LK0B0 zwMCJ?!|KK)jwG*_kyxDJoS%}asi3`ChJz19v#kn}N*#sGT3lU>$fl{sD%dKNWMmfW zA*tKEl)H@87D>QL0nNdBX+`7Nc}%acYrcSz<|Ik#l}devv{Vf<3vBS&^{33-!1bgxVCPi*!zCEkjKmlGK2r{ItxRRL|VR^i++>jBMg;5b?<~So}9J`!fRorjHPK delta 16 YcmdnXd!A>*JEqBdS%o&|G5a$E06d}wBLDyZ diff --git a/mobile/openapi/test/user_avatar_color_test.dart b/mobile/openapi/test/user_avatar_color_test.dart new file mode 100644 index 0000000000000000000000000000000000000000..83480b580f35d768e75ae65289f7038b38535406 GIT binary patch literal 427 zcmZvY!Ab)$5Qgu0it)59tc~_0ErP{vrJ!Aq_TVWDyVGrOH;I#MMWpX;qU@#ikO@D0 zU;ZRg3MpZ++m_c)#in>&Y>OqV*3U%_%Mv!_7GBEbdi8b{u|z)9$l-oExt)p>bz>}( z+FMq8-O>zt(|f8FwbYRb`H*$zEQgB*wx9UrmFt6I`sg)B-C!4|LymhO_VIA`m-WgT zAJ7D)>sYAwn)p}u7t300$g9?xCSu`$M%~@0>VWKp_`4%y4y$);Xl5N5bXvwWNw3c4 z|BygF$TS8Zm8R?=ehKV6@#2U9`O&tP#nI{-I@D$~hHoJN8n(R~Wyv^)vm6HMG@c7_ F5^{33-!1bea~lOi`Ve<72}<^zmvi~zUV7IOdq delta 12 TcmdnRevECyPR7l;OudW%AnXKa diff --git a/mobile/openapi/test/user_response_dto_test.dart b/mobile/openapi/test/user_response_dto_test.dart index 28830a73fd91c4a1f38eaed4de583ebbc82316ef..aa0717e74d29a6d8daab41e8811ef187b7d6742c 100644 GIT binary patch delta 60 xcmZ3?H=TdOMMmk+;?yF?vc!_aBIo>^{33-!1bcEJt0FftpO?vO^DRbCRsgf3754xD delta 12 TcmbQvznE{sMaIppOhK#wA0PxP diff --git a/server/immich-openapi-specs.json b/server/immich-openapi-specs.json index b776e4b28c..bcc301ee2e 100644 --- a/server/immich-openapi-specs.json +++ b/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", diff --git a/server/src/domain/auth/auth.service.spec.ts b/server/src/domain/auth/auth.service.spec.ts index f915883a5b..a815e22d1f 100644 --- a/server/src/domain/auth/auth.service.spec.ts +++ b/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', diff --git a/server/src/domain/partner/partner.service.spec.ts b/server/src/domain/partner/partner.service.spec.ts index 6eaa87ea05..c632cc8da3 100644 --- a/server/src/domain/partner/partner.service.spec.ts +++ b/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: { @@ -35,6 +37,7 @@ const responseDto = { updatedAt: new Date('2021-01-01'), externalPath: null, memoriesEnabled: true, + avatarColor: UserAvatarColor.PRIMARY, inTimeline: true, }, }; diff --git a/server/src/domain/user/dto/update-user.dto.ts b/server/src/domain/user/dto/update-user.dto.ts index 4f05498da9..a71c0e21a5 100644 --- a/server/src/domain/user/dto/update-user.dto.ts +++ b/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; } diff --git a/server/src/domain/user/response-dto/user-response.dto.ts b/server/src/domain/user/response-dto/user-response.dto.ts index fd9a121678..7b6aef1910 100644 --- a/server/src/domain/user/response-dto/user-response.dto.ts +++ b/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), }; }; diff --git a/server/src/domain/user/user.core.ts b/server/src/domain/user/user.core.ts index 431caa8e1f..c8e77a5004 100644 --- a/server/src/domain/user/user.core.ts +++ b/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, diff --git a/server/src/domain/user/user.service.spec.ts b/server/src/domain/user/user.service.spec.ts index 94f919178f..04b4206cab 100644 --- a/server/src/domain/user/user.service.spec.ts +++ b/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', () => { diff --git a/server/src/domain/user/user.service.ts b/server/src/domain/user/user.service.ts index a155d401df..3232a6f94d 100644 --- a/server/src/domain/user/user.service.ts +++ b/server/src/domain/user/user.service.ts @@ -93,10 +93,23 @@ export class UserService { authUser: AuthUserDto, fileInfo: Express.Multer.File, ): Promise { + 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 { + 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 { 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 }); diff --git a/server/src/immich/controllers/auth.controller.ts b/server/src/immich/controllers/auth.controller.ts index ae48a78ebc..dda546cf04 100644 --- a/server/src/immich/controllers/auth.controller.ts +++ b/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 { - return this.service.changePassword(authUser, dto); + return this.service.changePassword(authUser, dto).then(mapUser); } @Post('logout') diff --git a/server/src/immich/controllers/user.controller.ts b/server/src/immich/controllers/user.controller.ts index 92b3fdcc0c..1772fb5481 100644 --- a/server/src/immich/controllers/user.controller.ts +++ b/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 { + return this.service.deleteProfileImage(authUser); + } + @AdminRoute() @Delete(':id') deleteUser(@AuthUser() authUser: AuthUserDto, @Param() { id }: UUIDParamDto): Promise { diff --git a/server/src/infra/entities/user.entity.ts b/server/src/infra/entities/user.entity.ts index 83f23bef60..5a0a6afd6c 100644 --- a/server/src/infra/entities/user.entity.ts +++ b/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; diff --git a/server/src/infra/migrations/1699889987493-AddAvatarColor.ts b/server/src/infra/migrations/1699889987493-AddAvatarColor.ts new file mode 100644 index 0000000000..b075a5d2af --- /dev/null +++ b/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 { + await queryRunner.query(`ALTER TABLE "users" ADD "avatarColor" character varying`); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(`ALTER TABLE "users" DROP COLUMN "avatarColor"`); + } + +} diff --git a/server/test/e2e/auth.e2e-spec.ts b/server/test/e2e/auth.e2e-spec.ts index eb47a87255..97a8551ff4 100644 --- a/server/test/e2e/auth.e2e-spec.ts +++ b/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', diff --git a/server/test/fixtures/user.stub.ts b/server/test/fixtures/user.stub.ts index b528a107f3..c070a6769d 100644 --- a/server/test/fixtures/user.stub.ts +++ b/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({ ...authStub.user1, @@ -33,6 +34,7 @@ export const userStub = { tags: [], assets: [], memoriesEnabled: true, + avatarColor: UserAvatarColor.PRIMARY, }), user2: Object.freeze({ ...authStub.user2, @@ -49,6 +51,7 @@ export const userStub = { tags: [], assets: [], memoriesEnabled: true, + avatarColor: UserAvatarColor.PRIMARY, }), storageLabel: Object.freeze({ ...authStub.user1, @@ -65,6 +68,7 @@ export const userStub = { tags: [], assets: [], memoriesEnabled: true, + avatarColor: UserAvatarColor.PRIMARY, }), externalPath1: Object.freeze({ ...authStub.user1, @@ -81,6 +85,7 @@ export const userStub = { tags: [], assets: [], memoriesEnabled: true, + avatarColor: UserAvatarColor.PRIMARY, }), externalPath2: Object.freeze({ ...authStub.user1, @@ -97,6 +102,7 @@ export const userStub = { tags: [], assets: [], memoriesEnabled: true, + avatarColor: UserAvatarColor.PRIMARY, }), profilePath: Object.freeze({ ...authStub.user1, @@ -113,5 +119,6 @@ export const userStub = { tags: [], assets: [], memoriesEnabled: true, + avatarColor: UserAvatarColor.PRIMARY, }), }; diff --git a/web/src/api/open-api/api.ts b/web/src/api/open-api/api.ts index 8fb2c1b3d1..4eb5e12288 100644 --- a/web/src/api/open-api/api.ts +++ b/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 => { + 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> { + 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 { return localVarFp.createUser(requestParameters.createUserDto, options).then((request) => request(axios, basePath)); }, + /** + * + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + deleteProfileImage(options?: AxiosRequestConfig): AxiosPromise { + 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. diff --git a/web/src/lib/components/album-page/share-info-modal.svelte b/web/src/lib/components/album-page/share-info-modal.svelte index 668946d837..b92f20028e 100644 --- a/web/src/lib/components/album-page/share-info-modal.svelte +++ b/web/src/lib/components/album-page/share-info-modal.svelte @@ -77,7 +77,7 @@
- +

{album.owner.name}

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

{user.name}

diff --git a/web/src/lib/components/album-page/user-selection-modal.svelte b/web/src/lib/components/album-page/user-selection-modal.svelte index 0b4818ad5f..33b5494fcc 100644 --- a/web/src/lib/components/album-page/user-selection-modal.svelte +++ b/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" > - +

{user.name}

{/key} @@ -94,7 +94,7 @@ >✓ {:else} - + {/if}
diff --git a/web/src/lib/components/asset-viewer/detail-panel.svelte b/web/src/lib/components/asset-viewer/detail-panel.svelte index 70d76ca1aa..8c36df2d8b 100644 --- a/web/src/lib/components/asset-viewer/detail-panel.svelte +++ b/web/src/lib/components/asset-viewer/detail-panel.svelte @@ -333,7 +333,7 @@

SHARED BY

- +
diff --git a/web/src/lib/components/shared-components/navigation-bar/account-info-panel.svelte b/web/src/lib/components/shared-components/navigation-bar/account-info-panel.svelte index 6a135066f9..27b7423bbd 100644 --- a/web/src/lib/components/shared-components/navigation-bar/account-info-panel.svelte +++ b/web/src/lib/components/shared-components/navigation-bar/account-info-panel.svelte @@ -1,16 +1,48 @@
- +
+ {#key user} + +
+ +
+ {/key} +

{user.name} @@ -51,3 +97,10 @@ >

+{#if isShowSelectAvatar} + (isShowSelectAvatar = false)} + on:choose={({ detail: color }) => handleSaveProfile(color)} + /> +{/if} diff --git a/web/src/lib/components/shared-components/navigation-bar/avatar-selector.svelte b/web/src/lib/components/shared-components/navigation-bar/avatar-selector.svelte new file mode 100644 index 0000000000..6d7e395785 --- /dev/null +++ b/web/src/lib/components/shared-components/navigation-bar/avatar-selector.svelte @@ -0,0 +1,39 @@ + + + dispatch('close')} on:escape={() => dispatch('close')}> +
+
+
+

+ SELECT AVATAR COLOR +

+
+ dispatch('close')} /> +
+
+
+
+ {#each colors as color} + + {/each} +
+
+
+
+
diff --git a/web/src/lib/components/shared-components/navigation-bar/navigation-bar.svelte b/web/src/lib/components/shared-components/navigation-bar/navigation-bar.svelte index c72803b54c..2bb95495fa 100644 --- a/web/src/lib/components/shared-components/navigation-bar/navigation-bar.svelte +++ b/web/src/lib/components/shared-components/navigation-bar/navigation-bar.svelte @@ -124,7 +124,9 @@ on:mouseleave={() => (shouldShowAccountInfo = false)} on:click={() => (shouldShowAccountInfoPanel = !shouldShowAccountInfoPanel)} > - + {#key user} + + {/key} {#if shouldShowAccountInfo && !shouldShowAccountInfoPanel} @@ -139,7 +141,7 @@ {/if} {#if shouldShowAccountInfoPanel} - + {/if}
diff --git a/web/src/lib/components/shared-components/user-avatar.svelte b/web/src/lib/components/shared-components/user-avatar.svelte index 07e65d94aa..a77a5f2bb1 100644 --- a/web/src/lib/components/shared-components/user-avatar.svelte +++ b/web/src/lib/components/shared-components/user-avatar.svelte @@ -1,35 +1,40 @@