Bladeren bron

feat(server,web): update email address (#1186)

* feat: change email

* test: change email
Jason Rasmussen 2 jaren geleden
bovenliggende
commit
380f719fd8

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

@@ -9,6 +9,7 @@ import 'package:openapi/api.dart';
 Name | Type | Description | Notes
 Name | Type | Description | Notes
 ------------ | ------------- | ------------- | -------------
 ------------ | ------------- | ------------- | -------------
 **id** | **String** |  | 
 **id** | **String** |  | 
+**email** | **String** |  | [optional] 
 **password** | **String** |  | [optional] 
 **password** | **String** |  | [optional] 
 **firstName** | **String** |  | [optional] 
 **firstName** | **String** |  | [optional] 
 **lastName** | **String** |  | [optional] 
 **lastName** | **String** |  | [optional] 

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

@@ -14,6 +14,7 @@ class UpdateUserDto {
   /// Returns a new [UpdateUserDto] instance.
   /// Returns a new [UpdateUserDto] instance.
   UpdateUserDto({
   UpdateUserDto({
     required this.id,
     required this.id,
+    this.email,
     this.password,
     this.password,
     this.firstName,
     this.firstName,
     this.lastName,
     this.lastName,
@@ -24,6 +25,14 @@ class UpdateUserDto {
 
 
   String id;
   String id;
 
 
+  ///
+  /// 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.
+  ///
+  String? email;
+
   ///
   ///
   /// Please note: This property should have been non-nullable! Since the specification file
   /// 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
   /// does not include a default value (using the "default:" property), however, the generated
@@ -75,6 +84,7 @@ class UpdateUserDto {
   @override
   @override
   bool operator ==(Object other) => identical(this, other) || other is UpdateUserDto &&
   bool operator ==(Object other) => identical(this, other) || other is UpdateUserDto &&
      other.id == id &&
      other.id == id &&
+     other.email == email &&
      other.password == password &&
      other.password == password &&
      other.firstName == firstName &&
      other.firstName == firstName &&
      other.lastName == lastName &&
      other.lastName == lastName &&
@@ -86,6 +96,7 @@ class UpdateUserDto {
   int get hashCode =>
   int get hashCode =>
     // ignore: unnecessary_parenthesis
     // ignore: unnecessary_parenthesis
     (id.hashCode) +
     (id.hashCode) +
+    (email == null ? 0 : email!.hashCode) +
     (password == null ? 0 : password!.hashCode) +
     (password == null ? 0 : password!.hashCode) +
     (firstName == null ? 0 : firstName!.hashCode) +
     (firstName == null ? 0 : firstName!.hashCode) +
     (lastName == null ? 0 : lastName!.hashCode) +
     (lastName == null ? 0 : lastName!.hashCode) +
@@ -94,11 +105,16 @@ class UpdateUserDto {
     (profileImagePath == null ? 0 : profileImagePath!.hashCode);
     (profileImagePath == null ? 0 : profileImagePath!.hashCode);
 
 
   @override
   @override
-  String toString() => 'UpdateUserDto[id=$id, password=$password, firstName=$firstName, lastName=$lastName, isAdmin=$isAdmin, shouldChangePassword=$shouldChangePassword, profileImagePath=$profileImagePath]';
+  String toString() => 'UpdateUserDto[id=$id, email=$email, password=$password, firstName=$firstName, lastName=$lastName, isAdmin=$isAdmin, shouldChangePassword=$shouldChangePassword, profileImagePath=$profileImagePath]';
 
 
   Map<String, dynamic> toJson() {
   Map<String, dynamic> toJson() {
     final _json = <String, dynamic>{};
     final _json = <String, dynamic>{};
       _json[r'id'] = id;
       _json[r'id'] = id;
+    if (email != null) {
+      _json[r'email'] = email;
+    } else {
+      _json[r'email'] = null;
+    }
     if (password != null) {
     if (password != null) {
       _json[r'password'] = password;
       _json[r'password'] = password;
     } else {
     } else {
@@ -152,6 +168,7 @@ class UpdateUserDto {
 
 
       return UpdateUserDto(
       return UpdateUserDto(
         id: mapValueOfType<String>(json, r'id')!,
         id: mapValueOfType<String>(json, r'id')!,
+        email: mapValueOfType<String>(json, r'email'),
         password: mapValueOfType<String>(json, r'password'),
         password: mapValueOfType<String>(json, r'password'),
         firstName: mapValueOfType<String>(json, r'firstName'),
         firstName: mapValueOfType<String>(json, r'firstName'),
         lastName: mapValueOfType<String>(json, r'lastName'),
         lastName: mapValueOfType<String>(json, r'lastName'),

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

@@ -21,6 +21,11 @@ void main() {
       // TODO
       // TODO
     });
     });
 
 
+    // String email
+    test('to test the property `email`', () async {
+      // TODO
+    });
+
     // String password
     // String password
     test('to test the property `password`', () async {
     test('to test the property `password`', () async {
       // TODO
       // TODO

+ 5 - 1
server/apps/immich/src/api-v1/user/dto/update-user.dto.ts

@@ -1,9 +1,13 @@
-import { IsNotEmpty, IsOptional } from 'class-validator';
+import { IsEmail, IsNotEmpty, IsOptional } from 'class-validator';
 
 
 export class UpdateUserDto {
 export class UpdateUserDto {
   @IsNotEmpty()
   @IsNotEmpty()
   id!: string;
   id!: string;
 
 
+  @IsEmail()
+  @IsOptional()
+  email?: string;
+
   @IsOptional()
   @IsOptional()
   password?: string;
   password?: string;
 
 

+ 7 - 0
server/apps/immich/src/api-v1/user/user.core.ts

@@ -28,6 +28,13 @@ export class UserCore {
       throw new BadRequestException('Admin user exists');
       throw new BadRequestException('Admin user exists');
     }
     }
 
 
+    if (dto.email) {
+      const duplicate = await this.userRepository.getByEmail(dto.email);
+      if (duplicate && duplicate.id !== id) {
+        throw new BadRequestException('Email already in user by another account');
+      }
+    }
+
     try {
     try {
       if (dto.password) {
       if (dto.password) {
         dto.password = await hash(dto.password, SALT_ROUNDS);
         dto.password = await hash(dto.password, SALT_ROUNDS);

+ 22 - 0
server/apps/immich/src/api-v1/user/user.service.spec.ts

@@ -102,6 +102,28 @@ describe('UserService', () => {
       await expect(result).rejects.toBeInstanceOf(ForbiddenException);
       await expect(result).rejects.toBeInstanceOf(ForbiddenException);
     });
     });
 
 
+    it('should let a user change their email', async () => {
+      const dto = { id: immichUser.id, email: 'updated@test.com' };
+
+      userRepositoryMock.get.mockResolvedValue(immichUser);
+      userRepositoryMock.update.mockResolvedValue(immichUser);
+
+      await sut.updateUser(immichUser, dto);
+
+      expect(userRepositoryMock.update).toHaveBeenCalledWith(immichUser.id, { email: 'updated@test.com' });
+    });
+
+    it('should not let a user change their email to one already in use', async () => {
+      const dto = { id: immichUser.id, email: 'updated@test.com' };
+
+      userRepositoryMock.get.mockResolvedValue(immichUser);
+      userRepositoryMock.getByEmail.mockResolvedValue(adminUser);
+
+      await expect(sut.updateUser(immichUser, dto)).rejects.toBeInstanceOf(BadRequestException);
+
+      expect(userRepositoryMock.update).not.toHaveBeenCalled();
+    });
+
     it('admin can update any user information', async () => {
     it('admin can update any user information', async () => {
       const update: UpdateUserDto = {
       const update: UpdateUserDto = {
         id: immichUser.id,
         id: immichUser.id,

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

@@ -2400,6 +2400,9 @@
           "id": {
           "id": {
             "type": "string"
             "type": "string"
           },
           },
+          "email": {
+            "type": "string"
+          },
           "password": {
           "password": {
             "type": "string"
             "type": "string"
           },
           },

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

@@ -1779,6 +1779,12 @@ export interface UpdateUserDto {
      * @memberof UpdateUserDto
      * @memberof UpdateUserDto
      */
      */
     'id': string;
     'id': string;
+    /**
+     * 
+     * @type {string}
+     * @memberof UpdateUserDto
+     */
+    'email'?: string;
     /**
     /**
      * 
      * 
      * @type {string}
      * @type {string}

+ 1 - 0
web/src/lib/components/admin-page/settings/setting-input-field.svelte

@@ -1,5 +1,6 @@
 <script lang="ts" context="module">
 <script lang="ts" context="module">
 	export enum SettingInputFieldType {
 	export enum SettingInputFieldType {
+		EMAIL = 'email',
 		TEXT = 'text',
 		TEXT = 'text',
 		NUMBER = 'number',
 		NUMBER = 'number',
 		PASSWORD = 'password'
 		PASSWORD = 'password'

+ 4 - 7
web/src/lib/components/user-settings-page/user-profile-settings.svelte

@@ -5,6 +5,7 @@
 	} from '$lib/components/shared-components/notification/notification';
 	} from '$lib/components/shared-components/notification/notification';
 	import { api, UserResponseDto } from '@api';
 	import { api, UserResponseDto } from '@api';
 	import { fade } from 'svelte/transition';
 	import { fade } from 'svelte/transition';
+	import { handleError } from '../../utils/handle-error';
 	import SettingInputField, {
 	import SettingInputField, {
 		SettingInputFieldType
 		SettingInputFieldType
 	} from '../admin-page/settings/setting-input-field.svelte';
 	} from '../admin-page/settings/setting-input-field.svelte';
@@ -15,6 +16,7 @@
 		try {
 		try {
 			const { data } = await api.userApi.updateUser({
 			const { data } = await api.userApi.updateUser({
 				id: user.id,
 				id: user.id,
+				email: user.email,
 				firstName: user.firstName,
 				firstName: user.firstName,
 				lastName: user.lastName
 				lastName: user.lastName
 			});
 			});
@@ -26,11 +28,7 @@
 				type: NotificationType.Info
 				type: NotificationType.Info
 			});
 			});
 		} catch (error) {
 		} catch (error) {
-			console.error('Error [user-profile] [updateProfile]', error);
-			notificationController.show({
-				message: 'Unable to save profile',
-				type: NotificationType.Error
-			});
+			handleError(error, 'Unable to save profile');
 		}
 		}
 	};
 	};
 </script>
 </script>
@@ -47,10 +45,9 @@
 				/>
 				/>
 
 
 				<SettingInputField
 				<SettingInputField
-					inputType={SettingInputFieldType.TEXT}
+					inputType={SettingInputFieldType.EMAIL}
 					label="Email"
 					label="Email"
 					bind:value={user.email}
 					bind:value={user.email}
-					disabled={true}
 				/>
 				/>
 
 
 				<SettingInputField
 				<SettingInputField