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 | 201 ++++++++++++++---- .../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 | 2 + mobile/openapi/doc/PartnerResponseDto.md | 1 + mobile/openapi/doc/UpdateUserDto.md | 1 + mobile/openapi/doc/UserApi.md | 51 +++++ mobile/openapi/doc/UserAvatarColor.md | 14 ++ mobile/openapi/doc/UserDto.md | 1 + mobile/openapi/doc/UserResponseDto.md | 1 + mobile/openapi/lib/api.dart | 1 + mobile/openapi/lib/api/user_api.dart | 33 +++ mobile/openapi/lib/api_client.dart | 2 + mobile/openapi/lib/api_helper.dart | 3 + .../lib/model/partner_response_dto.dart | 10 +- mobile/openapi/lib/model/update_user_dto.dart | 19 +- .../openapi/lib/model/user_avatar_color.dart | 109 ++++++++++ mobile/openapi/lib/model/user_dto.dart | 10 +- .../openapi/lib/model/user_response_dto.dart | 10 +- .../test/partner_response_dto_test.dart | 5 + mobile/openapi/test/update_user_dto_test.dart | 5 + mobile/openapi/test/user_api_test.dart | 5 + .../openapi/test/user_avatar_color_test.dart | 21 ++ mobile/openapi/test/user_dto_test.dart | 5 + .../openapi/test/user_response_dto_test.dart | 5 + 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, 1123 insertions(+), 141 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 82420a1753..0b2605b949 100644 --- a/mobile/lib/shared/models/user.g.dart +++ b/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 offsets, Map> 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> 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

( ) { 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 { } extension UserQueryFilter on QueryBuilder { + QueryBuilder avatarColorEqualTo( + AvatarColorEnum value) { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition(FilterCondition.equalTo( + property: r'avatarColor', + value: value, + )); + }); + } + + QueryBuilder avatarColorGreaterThan( + AvatarColorEnum value, { + bool include = false, + }) { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition(FilterCondition.greaterThan( + include: include, + property: r'avatarColor', + value: value, + )); + }); + } + + QueryBuilder avatarColorLessThan( + AvatarColorEnum value, { + bool include = false, + }) { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition(FilterCondition.lessThan( + include: include, + property: r'avatarColor', + value: value, + )); + }); + } + + QueryBuilder 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 emailEqualTo( String value, { bool caseSensitive = true, @@ -1167,6 +1258,18 @@ extension UserQueryLinks on QueryBuilder { } extension UserQuerySortBy on QueryBuilder { + QueryBuilder sortByAvatarColor() { + return QueryBuilder.apply(this, (query) { + return query.addSortBy(r'avatarColor', Sort.asc); + }); + } + + QueryBuilder sortByAvatarColorDesc() { + return QueryBuilder.apply(this, (query) { + return query.addSortBy(r'avatarColor', Sort.desc); + }); + } + QueryBuilder sortByEmail() { return QueryBuilder.apply(this, (query) { return query.addSortBy(r'email', Sort.asc); @@ -1289,6 +1392,18 @@ extension UserQuerySortBy on QueryBuilder { } extension UserQuerySortThenBy on QueryBuilder { + QueryBuilder thenByAvatarColor() { + return QueryBuilder.apply(this, (query) { + return query.addSortBy(r'avatarColor', Sort.asc); + }); + } + + QueryBuilder thenByAvatarColorDesc() { + return QueryBuilder.apply(this, (query) { + return query.addSortBy(r'avatarColor', Sort.desc); + }); + } + QueryBuilder thenByEmail() { return QueryBuilder.apply(this, (query) { return query.addSortBy(r'email', Sort.asc); @@ -1423,6 +1538,12 @@ extension UserQuerySortThenBy on QueryBuilder { } extension UserQueryWhereDistinct on QueryBuilder { + QueryBuilder distinctByAvatarColor() { + return QueryBuilder.apply(this, (query) { + return query.addDistinctBy(r'avatarColor'); + }); + } + QueryBuilder distinctByEmail( {bool caseSensitive = true}) { return QueryBuilder.apply(this, (query) { @@ -1496,6 +1617,12 @@ extension UserQueryProperty on QueryBuilder { }); } + QueryBuilder avatarColorProperty() { + return QueryBuilder.apply(this, (query) { + return query.addPropertyName(r'avatarColor'); + }); + } + QueryBuilder emailProperty() { return QueryBuilder.apply(this, (query) { return query.addPropertyName(r'email'); 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 551169a993..4d54d41c39 100644 --- a/mobile/openapi/README.md +++ b/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) diff --git a/mobile/openapi/doc/PartnerResponseDto.md b/mobile/openapi/doc/PartnerResponseDto.md index f7133bbf7e..574b96f8df 100644 --- a/mobile/openapi/doc/PartnerResponseDto.md +++ b/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** | | diff --git a/mobile/openapi/doc/UpdateUserDto.md b/mobile/openapi/doc/UpdateUserDto.md index ffbe11253a..567bc43ebb 100644 --- a/mobile/openapi/doc/UpdateUserDto.md +++ b/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** | | diff --git a/mobile/openapi/doc/UserApi.md b/mobile/openapi/doc/UserApi.md index fb88d53bdd..6d2a028da4 100644 --- a/mobile/openapi/doc/UserApi.md +++ b/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('cookie').apiKey = 'YOUR_API_KEY'; +// uncomment below to setup prefix (e.g. Bearer) for API key, if needed +//defaultApiClient.getAuthentication('cookie').apiKeyPrefix = 'Bearer'; +// TODO Configure API key authorization: api_key +//defaultApiClient.getAuthentication('api_key').apiKey = 'YOUR_API_KEY'; +// uncomment below to setup prefix (e.g. Bearer) for API key, if needed +//defaultApiClient.getAuthentication('api_key').apiKeyPrefix = 'Bearer'; +// TODO Configure HTTP Bearer authorization: bearer +// Case 1. Use String Token +//defaultApiClient.getAuthentication('bearer').setAccessToken('YOUR_ACCESS_TOKEN'); +// Case 2. Use Function which generate token. +// String yourTokenGeneratorFunction() { ... } +//defaultApiClient.getAuthentication('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) diff --git a/mobile/openapi/doc/UserAvatarColor.md b/mobile/openapi/doc/UserAvatarColor.md new file mode 100644 index 0000000000..a07350de12 --- /dev/null +++ b/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) + + diff --git a/mobile/openapi/doc/UserDto.md b/mobile/openapi/doc/UserDto.md index c8b750a1d3..7e5770f840 100644 --- a/mobile/openapi/doc/UserDto.md +++ b/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** | | diff --git a/mobile/openapi/doc/UserResponseDto.md b/mobile/openapi/doc/UserResponseDto.md index ddf7c574c6..93f9aa62a3 100644 --- a/mobile/openapi/doc/UserResponseDto.md +++ b/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** | | diff --git a/mobile/openapi/lib/api.dart b/mobile/openapi/lib/api.dart index ed0b51f88d..92bfa3f81d 100644 --- a/mobile/openapi/lib/api.dart +++ b/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'; diff --git a/mobile/openapi/lib/api/user_api.dart b/mobile/openapi/lib/api/user_api.dart index 23f25492ce..26ab3dcd0d 100644 --- a/mobile/openapi/lib/api/user_api.dart +++ b/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 deleteProfileImageWithHttpInfo() async { + // ignore: prefer_const_declarations + final path = r'/user/profile-image'; + + // ignore: prefer_final_locals + Object? postBody; + + final queryParams = []; + final headerParams = {}; + final formParams = {}; + + const contentTypes = []; + + + return apiClient.invokeAPI( + path, + 'DELETE', + queryParams, + postBody, + headerParams, + formParams, + contentTypes.isEmpty ? null : contentTypes.first, + ); + } + + Future 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: /// diff --git a/mobile/openapi/lib/api_client.dart b/mobile/openapi/lib/api_client.dart index c03a469ae3..4c24967ec5 100644 --- a/mobile/openapi/lib/api_client.dart +++ b/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': diff --git a/mobile/openapi/lib/api_helper.dart b/mobile/openapi/lib/api_helper.dart index f391314482..ddb16df0c3 100644 --- a/mobile/openapi/lib/api_helper.dart +++ b/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(); } diff --git a/mobile/openapi/lib/model/partner_response_dto.dart b/mobile/openapi/lib/model/partner_response_dto.dart index 5ccef68d85..6e1776b266 100644 --- a/mobile/openapi/lib/model/partner_response_dto.dart +++ b/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 toJson() { final json = {}; + 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(); return PartnerResponseDto( + avatarColor: UserAvatarColor.fromJson(json[r'avatarColor'])!, createdAt: mapDateTime(json, r'createdAt', '')!, deletedAt: mapDateTime(json, r'deletedAt', ''), email: mapValueOfType(json, r'email')!, @@ -215,6 +222,7 @@ class PartnerResponseDto { /// The list of required keys that must be present in a JSON. static const requiredKeys = { + 'avatarColor', 'createdAt', 'deletedAt', 'email', diff --git a/mobile/openapi/lib/model/update_user_dto.dart b/mobile/openapi/lib/model/update_user_dto.dart index 9d381cd4b7..d0e46e7f5d 100644 --- a/mobile/openapi/lib/model/update_user_dto.dart +++ b/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 toJson() { final json = {}; + 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(); return UpdateUserDto( + avatarColor: UserAvatarColor.fromJson(json[r'avatarColor']), email: mapValueOfType(json, r'email'), externalPath: mapValueOfType(json, r'externalPath'), id: mapValueOfType(json, r'id')!, 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 0000000000..075f58d3a5 --- /dev/null +++ b/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 = [ + primary, + pink, + red, + yellow, + blue, + green, + purple, + orange, + gray, + amber, + ]; + + static UserAvatarColor? fromJson(dynamic value) => UserAvatarColorTypeTransformer().decode(value); + + static List? listFromJson(dynamic json, {bool growable = false,}) { + final result = []; + 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; +} + diff --git a/mobile/openapi/lib/model/user_dto.dart b/mobile/openapi/lib/model/user_dto.dart index de26cc8f24..ad39f84e03 100644 --- a/mobile/openapi/lib/model/user_dto.dart +++ b/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 toJson() { final json = {}; + 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(); return UserDto( + avatarColor: UserAvatarColor.fromJson(json[r'avatarColor'])!, email: mapValueOfType(json, r'email')!, id: mapValueOfType(json, r'id')!, name: mapValueOfType(json, r'name')!, @@ -113,6 +120,7 @@ class UserDto { /// The list of required keys that must be present in a JSON. static const requiredKeys = { + 'avatarColor', 'email', 'id', 'name', diff --git a/mobile/openapi/lib/model/user_response_dto.dart b/mobile/openapi/lib/model/user_response_dto.dart index 73b30881f8..11a182b6b7 100644 --- a/mobile/openapi/lib/model/user_response_dto.dart +++ b/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 toJson() { final json = {}; + 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(); return UserResponseDto( + avatarColor: UserAvatarColor.fromJson(json[r'avatarColor'])!, createdAt: mapDateTime(json, r'createdAt', '')!, deletedAt: mapDateTime(json, r'deletedAt', ''), email: mapValueOfType(json, r'email')!, @@ -198,6 +205,7 @@ class UserResponseDto { /// The list of required keys that must be present in a JSON. static const requiredKeys = { + 'avatarColor', 'createdAt', 'deletedAt', 'email', diff --git a/mobile/openapi/test/partner_response_dto_test.dart b/mobile/openapi/test/partner_response_dto_test.dart index 495762d3b7..50ac1d8050 100644 --- a/mobile/openapi/test/partner_response_dto_test.dart +++ b/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 diff --git a/mobile/openapi/test/update_user_dto_test.dart b/mobile/openapi/test/update_user_dto_test.dart index 039a1a2446..0b4cc0b65d 100644 --- a/mobile/openapi/test/update_user_dto_test.dart +++ b/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 diff --git a/mobile/openapi/test/user_api_test.dart b/mobile/openapi/test/user_api_test.dart index 86c33c7e0e..26ebf3d7ed 100644 --- a/mobile/openapi/test/user_api_test.dart +++ b/mobile/openapi/test/user_api_test.dart @@ -27,6 +27,11 @@ void main() { // TODO }); + //Future deleteProfileImage() async + test('test deleteProfileImage', () async { + // TODO + }); + //Future deleteUser(String id) async test('test deleteUser', () async { // TODO 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 0000000000..83480b580f --- /dev/null +++ b/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', () { + + }); + +} diff --git a/mobile/openapi/test/user_dto_test.dart b/mobile/openapi/test/user_dto_test.dart index e0866d12dc..20229ff65b 100644 --- a/mobile/openapi/test/user_dto_test.dart +++ b/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 diff --git a/mobile/openapi/test/user_response_dto_test.dart b/mobile/openapi/test/user_response_dto_test.dart index 28830a73fd..aa0717e74d 100644 --- a/mobile/openapi/test/user_response_dto_test.dart +++ b/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 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 @@