mirror of
				https://github.com/immich-app/immich.git
				synced 2025-11-03 19:17:11 -05:00 
			
		
		
		
	feat(web,server): link/unlink oauth account (#1154)
* feat(web,server): link/unlink oauth account * chore: linting * fix: broken oauth callback * fix: user core bugs * fix: tests * fix: use user response * chore: update docs * feat: prevent the same oauth account from being linked twice * chore: mock logger
This commit is contained in:
		
							parent
							
								
									ab0a3690f3
								
							
						
					
					
						commit
						7dc12dea1e
					
				@ -26,14 +26,33 @@ Before enabling OAuth in Immich, a new client application needs to be configured
 | 
				
			|||||||
 | 
					
 | 
				
			||||||
The **Sign-in redirect URIs** should include:
 | 
					The **Sign-in redirect URIs** should include:
 | 
				
			||||||
 | 
					
 | 
				
			||||||
- All URLs that will be used to access the login page of the Immich web client (eg. `http://localhost:2283/auth/login`, `http://192.168.0.200:2283/auth/login`, `https://immich.example.com/auth/login`)
 | 
					- `app.immich:/` - for logging in with OAuth from the [Mobile App](/docs/features/mobile-app.mdx)
 | 
				
			||||||
- Mobile app redirect URL `app.immich:/`
 | 
					- `http://DOMAIN:PORT/auth/login` - for logging in with OAuth from the Web Client
 | 
				
			||||||
 | 
					- `http://DOMAIN:PORT/user-settings` - for manually linking OAuth in the Web Client
 | 
				
			||||||
 | 
					
 | 
				
			||||||
:::caution
 | 
					:::info Redirect URIs
 | 
				
			||||||
You **MUST** include `app.immich:/` as the redirect URI for iOS and Android mobile app to work properly.
 | 
					
 | 
				
			||||||
 | 
					Redirect URIs should contain all the domains you will be using to access Immich. Some examples include:
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					Mobile
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					- `app.immich:/` (You **MUST** include this for iOS and Android mobile apps to work properly)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					Localhost
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					- `http://localhost:2283/auth/login`
 | 
				
			||||||
 | 
					- `http://localhost:2283/user-settings`
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					Local IP
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					- `http://192.168.0.200:2283/auth/login`
 | 
				
			||||||
 | 
					- `http://192.168.0.200:2283/user-settings`
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					Hostname
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					- `https://immich.example.com/auth/login`)
 | 
				
			||||||
 | 
					- `https://immich.example.com/user-settings`)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
**Authentik example**
 | 
					 | 
				
			||||||
<img src={require('./img/authentik-redirect.png').default} title="Authentik Redirection URL" width="80%" />
 | 
					 | 
				
			||||||
:::
 | 
					:::
 | 
				
			||||||
 | 
					
 | 
				
			||||||
## Enable OAuth
 | 
					## Enable OAuth
 | 
				
			||||||
@ -42,7 +61,7 @@ Once you have a new OAuth client application configured, Immich can be configure
 | 
				
			|||||||
 | 
					
 | 
				
			||||||
| Setting       | Type    | Default              | Description                                                               |
 | 
					| Setting       | Type    | Default              | Description                                                               |
 | 
				
			||||||
| ------------- | ------- | -------------------- | ------------------------------------------------------------------------- |
 | 
					| ------------- | ------- | -------------------- | ------------------------------------------------------------------------- |
 | 
				
			||||||
| Enabled       | boolean | false                | Enable/disable OAuth                                                     |
 | 
					| Enabled       | boolean | false                | Enable/disable OAuth                                                      |
 | 
				
			||||||
| Issuer URL    | URL     | (required)           | Required. Self-discovery URL for client (from previous step)              |
 | 
					| Issuer URL    | URL     | (required)           | Required. Self-discovery URL for client (from previous step)              |
 | 
				
			||||||
| Client ID     | string  | (required)           | Required. Client ID (from previous step)                                  |
 | 
					| Client ID     | string  | (required)           | Required. Client ID (from previous step)                                  |
 | 
				
			||||||
| Client secret | string  | (required)           | Required. Client Secret (previous step)                                   |
 | 
					| Client secret | string  | (required)           | Required. Client Secret (previous step)                                   |
 | 
				
			||||||
 | 
				
			|||||||
							
								
								
									
										2
									
								
								mobile/openapi/README.md
									
									
									
										generated
									
									
									
								
							
							
						
						
									
										2
									
								
								mobile/openapi/README.md
									
									
									
										generated
									
									
									
								
							@ -108,6 +108,8 @@ Class | Method | HTTP request | Description
 | 
				
			|||||||
*JobApi* | [**sendJobCommand**](doc//JobApi.md#sendjobcommand) | **PUT** /jobs/{jobId} | 
 | 
					*JobApi* | [**sendJobCommand**](doc//JobApi.md#sendjobcommand) | **PUT** /jobs/{jobId} | 
 | 
				
			||||||
*OAuthApi* | [**callback**](doc//OAuthApi.md#callback) | **POST** /oauth/callback | 
 | 
					*OAuthApi* | [**callback**](doc//OAuthApi.md#callback) | **POST** /oauth/callback | 
 | 
				
			||||||
*OAuthApi* | [**generateConfig**](doc//OAuthApi.md#generateconfig) | **POST** /oauth/config | 
 | 
					*OAuthApi* | [**generateConfig**](doc//OAuthApi.md#generateconfig) | **POST** /oauth/config | 
 | 
				
			||||||
 | 
					*OAuthApi* | [**link**](doc//OAuthApi.md#link) | **POST** /oauth/link | 
 | 
				
			||||||
 | 
					*OAuthApi* | [**unlink**](doc//OAuthApi.md#unlink) | **POST** /oauth/unlink | 
 | 
				
			||||||
*ServerInfoApi* | [**getServerInfo**](doc//ServerInfoApi.md#getserverinfo) | **GET** /server-info | 
 | 
					*ServerInfoApi* | [**getServerInfo**](doc//ServerInfoApi.md#getserverinfo) | **GET** /server-info | 
 | 
				
			||||||
*ServerInfoApi* | [**getServerVersion**](doc//ServerInfoApi.md#getserverversion) | **GET** /server-info/version | 
 | 
					*ServerInfoApi* | [**getServerVersion**](doc//ServerInfoApi.md#getserverversion) | **GET** /server-info/version | 
 | 
				
			||||||
*ServerInfoApi* | [**getStats**](doc//ServerInfoApi.md#getstats) | **GET** /server-info/stats | 
 | 
					*ServerInfoApi* | [**getStats**](doc//ServerInfoApi.md#getstats) | **GET** /server-info/stats | 
 | 
				
			||||||
 | 
				
			|||||||
							
								
								
									
										80
									
								
								mobile/openapi/doc/OAuthApi.md
									
									
									
										generated
									
									
									
								
							
							
						
						
									
										80
									
								
								mobile/openapi/doc/OAuthApi.md
									
									
									
										generated
									
									
									
								
							@ -11,6 +11,8 @@ Method | HTTP request | Description
 | 
				
			|||||||
------------- | ------------- | -------------
 | 
					------------- | ------------- | -------------
 | 
				
			||||||
[**callback**](OAuthApi.md#callback) | **POST** /oauth/callback | 
 | 
					[**callback**](OAuthApi.md#callback) | **POST** /oauth/callback | 
 | 
				
			||||||
[**generateConfig**](OAuthApi.md#generateconfig) | **POST** /oauth/config | 
 | 
					[**generateConfig**](OAuthApi.md#generateconfig) | **POST** /oauth/config | 
 | 
				
			||||||
 | 
					[**link**](OAuthApi.md#link) | **POST** /oauth/link | 
 | 
				
			||||||
 | 
					[**unlink**](OAuthApi.md#unlink) | **POST** /oauth/unlink | 
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
# **callback**
 | 
					# **callback**
 | 
				
			||||||
@ -95,3 +97,81 @@ No authorization required
 | 
				
			|||||||
 | 
					
 | 
				
			||||||
[[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)
 | 
					[[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)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					# **link**
 | 
				
			||||||
 | 
					> UserResponseDto link(oAuthCallbackDto)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					### Example
 | 
				
			||||||
 | 
					```dart
 | 
				
			||||||
 | 
					import 'package:openapi/api.dart';
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					final api_instance = OAuthApi();
 | 
				
			||||||
 | 
					final oAuthCallbackDto = OAuthCallbackDto(); // OAuthCallbackDto | 
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					try {
 | 
				
			||||||
 | 
					    final result = api_instance.link(oAuthCallbackDto);
 | 
				
			||||||
 | 
					    print(result);
 | 
				
			||||||
 | 
					} catch (e) {
 | 
				
			||||||
 | 
					    print('Exception when calling OAuthApi->link: $e\n');
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					```
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					### Parameters
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					Name | Type | Description  | Notes
 | 
				
			||||||
 | 
					------------- | ------------- | ------------- | -------------
 | 
				
			||||||
 | 
					 **oAuthCallbackDto** | [**OAuthCallbackDto**](OAuthCallbackDto.md)|  | 
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					### Return type
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					[**UserResponseDto**](UserResponseDto.md)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					### Authorization
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					No authorization required
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					### HTTP request headers
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					 - **Content-Type**: application/json
 | 
				
			||||||
 | 
					 - **Accept**: application/json
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					[[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)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					# **unlink**
 | 
				
			||||||
 | 
					> UserResponseDto unlink()
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					### Example
 | 
				
			||||||
 | 
					```dart
 | 
				
			||||||
 | 
					import 'package:openapi/api.dart';
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					final api_instance = OAuthApi();
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					try {
 | 
				
			||||||
 | 
					    final result = api_instance.unlink();
 | 
				
			||||||
 | 
					    print(result);
 | 
				
			||||||
 | 
					} catch (e) {
 | 
				
			||||||
 | 
					    print('Exception when calling OAuthApi->unlink: $e\n');
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					```
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					### Parameters
 | 
				
			||||||
 | 
					This endpoint does not need any parameter.
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					### Return type
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					[**UserResponseDto**](UserResponseDto.md)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					### Authorization
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					No authorization required
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					### HTTP request headers
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					 - **Content-Type**: Not defined
 | 
				
			||||||
 | 
					 - **Accept**: application/json
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					[[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)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
				
			|||||||
							
								
								
									
										1
									
								
								mobile/openapi/doc/UserResponseDto.md
									
									
									
										generated
									
									
									
								
							
							
						
						
									
										1
									
								
								mobile/openapi/doc/UserResponseDto.md
									
									
									
										generated
									
									
									
								
							@ -17,6 +17,7 @@ Name | Type | Description | Notes
 | 
				
			|||||||
**shouldChangePassword** | **bool** |  | 
 | 
					**shouldChangePassword** | **bool** |  | 
 | 
				
			||||||
**isAdmin** | **bool** |  | 
 | 
					**isAdmin** | **bool** |  | 
 | 
				
			||||||
**deletedAt** | [**DateTime**](DateTime.md) |  | [optional] 
 | 
					**deletedAt** | [**DateTime**](DateTime.md) |  | [optional] 
 | 
				
			||||||
 | 
					**oauthId** | **String** |  | 
 | 
				
			||||||
 | 
					
 | 
				
			||||||
[[Back to Model list]](../README.md#documentation-for-models) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to README]](../README.md)
 | 
					[[Back to Model list]](../README.md#documentation-for-models) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to README]](../README.md)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
				
			|||||||
							
								
								
									
										88
									
								
								mobile/openapi/lib/api/o_auth_api.dart
									
									
									
										generated
									
									
									
								
							
							
						
						
									
										88
									
								
								mobile/openapi/lib/api/o_auth_api.dart
									
									
									
										generated
									
									
									
								
							@ -109,4 +109,92 @@ class OAuthApi {
 | 
				
			|||||||
    }
 | 
					    }
 | 
				
			||||||
    return null;
 | 
					    return null;
 | 
				
			||||||
  }
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  /// Performs an HTTP 'POST /oauth/link' operation and returns the [Response].
 | 
				
			||||||
 | 
					  /// Parameters:
 | 
				
			||||||
 | 
					  ///
 | 
				
			||||||
 | 
					  /// * [OAuthCallbackDto] oAuthCallbackDto (required):
 | 
				
			||||||
 | 
					  Future<Response> linkWithHttpInfo(OAuthCallbackDto oAuthCallbackDto,) async {
 | 
				
			||||||
 | 
					    // ignore: prefer_const_declarations
 | 
				
			||||||
 | 
					    final path = r'/oauth/link';
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    // ignore: prefer_final_locals
 | 
				
			||||||
 | 
					    Object? postBody = oAuthCallbackDto;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    final queryParams = <QueryParam>[];
 | 
				
			||||||
 | 
					    final headerParams = <String, String>{};
 | 
				
			||||||
 | 
					    final formParams = <String, String>{};
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    const contentTypes = <String>['application/json'];
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    return apiClient.invokeAPI(
 | 
				
			||||||
 | 
					      path,
 | 
				
			||||||
 | 
					      'POST',
 | 
				
			||||||
 | 
					      queryParams,
 | 
				
			||||||
 | 
					      postBody,
 | 
				
			||||||
 | 
					      headerParams,
 | 
				
			||||||
 | 
					      formParams,
 | 
				
			||||||
 | 
					      contentTypes.isEmpty ? null : contentTypes.first,
 | 
				
			||||||
 | 
					    );
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  /// Parameters:
 | 
				
			||||||
 | 
					  ///
 | 
				
			||||||
 | 
					  /// * [OAuthCallbackDto] oAuthCallbackDto (required):
 | 
				
			||||||
 | 
					  Future<UserResponseDto?> link(OAuthCallbackDto oAuthCallbackDto,) async {
 | 
				
			||||||
 | 
					    final response = await linkWithHttpInfo(oAuthCallbackDto,);
 | 
				
			||||||
 | 
					    if (response.statusCode >= HttpStatus.badRequest) {
 | 
				
			||||||
 | 
					      throw ApiException(response.statusCode, await _decodeBodyBytes(response));
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					    // When a remote server returns no body with a status of 204, we shall not decode it.
 | 
				
			||||||
 | 
					    // At the time of writing this, `dart:convert` will throw an "Unexpected end of input"
 | 
				
			||||||
 | 
					    // FormatException when trying to decode an empty string.
 | 
				
			||||||
 | 
					    if (response.body.isNotEmpty && response.statusCode != HttpStatus.noContent) {
 | 
				
			||||||
 | 
					      return await apiClient.deserializeAsync(await _decodeBodyBytes(response), 'UserResponseDto',) as UserResponseDto;
 | 
				
			||||||
 | 
					    
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					    return null;
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  /// Performs an HTTP 'POST /oauth/unlink' operation and returns the [Response].
 | 
				
			||||||
 | 
					  Future<Response> unlinkWithHttpInfo() async {
 | 
				
			||||||
 | 
					    // ignore: prefer_const_declarations
 | 
				
			||||||
 | 
					    final path = r'/oauth/unlink';
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    // ignore: prefer_final_locals
 | 
				
			||||||
 | 
					    Object? postBody;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    final queryParams = <QueryParam>[];
 | 
				
			||||||
 | 
					    final headerParams = <String, String>{};
 | 
				
			||||||
 | 
					    final formParams = <String, String>{};
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    const contentTypes = <String>[];
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    return apiClient.invokeAPI(
 | 
				
			||||||
 | 
					      path,
 | 
				
			||||||
 | 
					      'POST',
 | 
				
			||||||
 | 
					      queryParams,
 | 
				
			||||||
 | 
					      postBody,
 | 
				
			||||||
 | 
					      headerParams,
 | 
				
			||||||
 | 
					      formParams,
 | 
				
			||||||
 | 
					      contentTypes.isEmpty ? null : contentTypes.first,
 | 
				
			||||||
 | 
					    );
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  Future<UserResponseDto?> unlink() async {
 | 
				
			||||||
 | 
					    final response = await unlinkWithHttpInfo();
 | 
				
			||||||
 | 
					    if (response.statusCode >= HttpStatus.badRequest) {
 | 
				
			||||||
 | 
					      throw ApiException(response.statusCode, await _decodeBodyBytes(response));
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					    // When a remote server returns no body with a status of 204, we shall not decode it.
 | 
				
			||||||
 | 
					    // At the time of writing this, `dart:convert` will throw an "Unexpected end of input"
 | 
				
			||||||
 | 
					    // FormatException when trying to decode an empty string.
 | 
				
			||||||
 | 
					    if (response.body.isNotEmpty && response.statusCode != HttpStatus.noContent) {
 | 
				
			||||||
 | 
					      return await apiClient.deserializeAsync(await _decodeBodyBytes(response), 'UserResponseDto',) as UserResponseDto;
 | 
				
			||||||
 | 
					    
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					    return null;
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 | 
				
			|||||||
							
								
								
									
										14
									
								
								mobile/openapi/lib/model/user_response_dto.dart
									
									
									
										generated
									
									
									
								
							
							
						
						
									
										14
									
								
								mobile/openapi/lib/model/user_response_dto.dart
									
									
									
										generated
									
									
									
								
							@ -22,6 +22,7 @@ class UserResponseDto {
 | 
				
			|||||||
    required this.shouldChangePassword,
 | 
					    required this.shouldChangePassword,
 | 
				
			||||||
    required this.isAdmin,
 | 
					    required this.isAdmin,
 | 
				
			||||||
    this.deletedAt,
 | 
					    this.deletedAt,
 | 
				
			||||||
 | 
					    required this.oauthId,
 | 
				
			||||||
  });
 | 
					  });
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  String id;
 | 
					  String id;
 | 
				
			||||||
@ -48,6 +49,8 @@ class UserResponseDto {
 | 
				
			|||||||
  ///
 | 
					  ///
 | 
				
			||||||
  DateTime? deletedAt;
 | 
					  DateTime? deletedAt;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  String oauthId;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  @override
 | 
					  @override
 | 
				
			||||||
  bool operator ==(Object other) => identical(this, other) || other is UserResponseDto &&
 | 
					  bool operator ==(Object other) => identical(this, other) || other is UserResponseDto &&
 | 
				
			||||||
     other.id == id &&
 | 
					     other.id == id &&
 | 
				
			||||||
@ -58,7 +61,8 @@ class UserResponseDto {
 | 
				
			|||||||
     other.profileImagePath == profileImagePath &&
 | 
					     other.profileImagePath == profileImagePath &&
 | 
				
			||||||
     other.shouldChangePassword == shouldChangePassword &&
 | 
					     other.shouldChangePassword == shouldChangePassword &&
 | 
				
			||||||
     other.isAdmin == isAdmin &&
 | 
					     other.isAdmin == isAdmin &&
 | 
				
			||||||
     other.deletedAt == deletedAt;
 | 
					     other.deletedAt == deletedAt &&
 | 
				
			||||||
 | 
					     other.oauthId == oauthId;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  @override
 | 
					  @override
 | 
				
			||||||
  int get hashCode =>
 | 
					  int get hashCode =>
 | 
				
			||||||
@ -71,10 +75,11 @@ class UserResponseDto {
 | 
				
			|||||||
    (profileImagePath.hashCode) +
 | 
					    (profileImagePath.hashCode) +
 | 
				
			||||||
    (shouldChangePassword.hashCode) +
 | 
					    (shouldChangePassword.hashCode) +
 | 
				
			||||||
    (isAdmin.hashCode) +
 | 
					    (isAdmin.hashCode) +
 | 
				
			||||||
    (deletedAt == null ? 0 : deletedAt!.hashCode);
 | 
					    (deletedAt == null ? 0 : deletedAt!.hashCode) +
 | 
				
			||||||
 | 
					    (oauthId.hashCode);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  @override
 | 
					  @override
 | 
				
			||||||
  String toString() => 'UserResponseDto[id=$id, email=$email, firstName=$firstName, lastName=$lastName, createdAt=$createdAt, profileImagePath=$profileImagePath, shouldChangePassword=$shouldChangePassword, isAdmin=$isAdmin, deletedAt=$deletedAt]';
 | 
					  String toString() => 'UserResponseDto[id=$id, email=$email, firstName=$firstName, lastName=$lastName, createdAt=$createdAt, profileImagePath=$profileImagePath, shouldChangePassword=$shouldChangePassword, isAdmin=$isAdmin, deletedAt=$deletedAt, oauthId=$oauthId]';
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  Map<String, dynamic> toJson() {
 | 
					  Map<String, dynamic> toJson() {
 | 
				
			||||||
    final _json = <String, dynamic>{};
 | 
					    final _json = <String, dynamic>{};
 | 
				
			||||||
@ -91,6 +96,7 @@ class UserResponseDto {
 | 
				
			|||||||
    } else {
 | 
					    } else {
 | 
				
			||||||
      _json[r'deletedAt'] = null;
 | 
					      _json[r'deletedAt'] = null;
 | 
				
			||||||
    }
 | 
					    }
 | 
				
			||||||
 | 
					      _json[r'oauthId'] = oauthId;
 | 
				
			||||||
    return _json;
 | 
					    return _json;
 | 
				
			||||||
  }
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
@ -122,6 +128,7 @@ class UserResponseDto {
 | 
				
			|||||||
        shouldChangePassword: mapValueOfType<bool>(json, r'shouldChangePassword')!,
 | 
					        shouldChangePassword: mapValueOfType<bool>(json, r'shouldChangePassword')!,
 | 
				
			||||||
        isAdmin: mapValueOfType<bool>(json, r'isAdmin')!,
 | 
					        isAdmin: mapValueOfType<bool>(json, r'isAdmin')!,
 | 
				
			||||||
        deletedAt: mapDateTime(json, r'deletedAt', ''),
 | 
					        deletedAt: mapDateTime(json, r'deletedAt', ''),
 | 
				
			||||||
 | 
					        oauthId: mapValueOfType<String>(json, r'oauthId')!,
 | 
				
			||||||
      );
 | 
					      );
 | 
				
			||||||
    }
 | 
					    }
 | 
				
			||||||
    return null;
 | 
					    return null;
 | 
				
			||||||
@ -179,6 +186,7 @@ class UserResponseDto {
 | 
				
			|||||||
    'profileImagePath',
 | 
					    'profileImagePath',
 | 
				
			||||||
    'shouldChangePassword',
 | 
					    'shouldChangePassword',
 | 
				
			||||||
    'isAdmin',
 | 
					    'isAdmin',
 | 
				
			||||||
 | 
					    'oauthId',
 | 
				
			||||||
  };
 | 
					  };
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
				
			|||||||
							
								
								
									
										10
									
								
								mobile/openapi/test/o_auth_api_test.dart
									
									
									
										generated
									
									
									
								
							
							
						
						
									
										10
									
								
								mobile/openapi/test/o_auth_api_test.dart
									
									
									
										generated
									
									
									
								
							@ -27,5 +27,15 @@ void main() {
 | 
				
			|||||||
      // TODO
 | 
					      // TODO
 | 
				
			||||||
    });
 | 
					    });
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    //Future<UserResponseDto> link(OAuthCallbackDto oAuthCallbackDto) async
 | 
				
			||||||
 | 
					    test('test link', () async {
 | 
				
			||||||
 | 
					      // TODO
 | 
				
			||||||
 | 
					    });
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    //Future<UserResponseDto> unlink() async
 | 
				
			||||||
 | 
					    test('test unlink', () async {
 | 
				
			||||||
 | 
					      // TODO
 | 
				
			||||||
 | 
					    });
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  });
 | 
					  });
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 | 
				
			|||||||
							
								
								
									
										5
									
								
								mobile/openapi/test/user_response_dto_test.dart
									
									
									
										generated
									
									
									
								
							
							
						
						
									
										5
									
								
								mobile/openapi/test/user_response_dto_test.dart
									
									
									
										generated
									
									
									
								
							@ -61,6 +61,11 @@ void main() {
 | 
				
			|||||||
      // TODO
 | 
					      // TODO
 | 
				
			||||||
    });
 | 
					    });
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    // String oauthId
 | 
				
			||||||
 | 
					    test('to test the property `oauthId`', () async {
 | 
				
			||||||
 | 
					      // TODO
 | 
				
			||||||
 | 
					    });
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  });
 | 
					  });
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
				
			|||||||
@ -74,9 +74,7 @@ export class AuthService {
 | 
				
			|||||||
      throw new BadRequestException('Wrong password');
 | 
					      throw new BadRequestException('Wrong password');
 | 
				
			||||||
    }
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    user.password = newPassword;
 | 
					    return this.userCore.updateUser(authUser, authUser.id, { password: newPassword });
 | 
				
			||||||
 | 
					 | 
				
			||||||
    return this.userCore.updateUser(authUser, user, dto);
 | 
					 | 
				
			||||||
  }
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  public async adminSignUp(dto: SignUpDto): Promise<AdminSignupResponseDto> {
 | 
					  public async adminSignUp(dto: SignUpDto): Promise<AdminSignupResponseDto> {
 | 
				
			||||||
 | 
				
			|||||||
@ -3,8 +3,10 @@ import { ApiTags } from '@nestjs/swagger';
 | 
				
			|||||||
import { Response } from 'express';
 | 
					import { Response } from 'express';
 | 
				
			||||||
import { AuthType } from '../../constants/jwt.constant';
 | 
					import { AuthType } from '../../constants/jwt.constant';
 | 
				
			||||||
import { AuthUserDto, GetAuthUser } from '../../decorators/auth-user.decorator';
 | 
					import { AuthUserDto, GetAuthUser } from '../../decorators/auth-user.decorator';
 | 
				
			||||||
 | 
					import { Authenticated } from '../../decorators/authenticated.decorator';
 | 
				
			||||||
import { ImmichJwtService } from '../../modules/immich-jwt/immich-jwt.service';
 | 
					import { ImmichJwtService } from '../../modules/immich-jwt/immich-jwt.service';
 | 
				
			||||||
import { LoginResponseDto } from '../auth/response-dto/login-response.dto';
 | 
					import { LoginResponseDto } from '../auth/response-dto/login-response.dto';
 | 
				
			||||||
 | 
					import { UserResponseDto } from '../user/response-dto/user-response.dto';
 | 
				
			||||||
import { OAuthCallbackDto } from './dto/oauth-auth-code.dto';
 | 
					import { OAuthCallbackDto } from './dto/oauth-auth-code.dto';
 | 
				
			||||||
import { OAuthConfigDto } from './dto/oauth-config.dto';
 | 
					import { OAuthConfigDto } from './dto/oauth-config.dto';
 | 
				
			||||||
import { OAuthService } from './oauth.service';
 | 
					import { OAuthService } from './oauth.service';
 | 
				
			||||||
@ -22,12 +24,26 @@ export class OAuthController {
 | 
				
			|||||||
 | 
					
 | 
				
			||||||
  @Post('/callback')
 | 
					  @Post('/callback')
 | 
				
			||||||
  public async callback(
 | 
					  public async callback(
 | 
				
			||||||
    @GetAuthUser() authUser: AuthUserDto,
 | 
					 | 
				
			||||||
    @Res({ passthrough: true }) response: Response,
 | 
					    @Res({ passthrough: true }) response: Response,
 | 
				
			||||||
    @Body(ValidationPipe) dto: OAuthCallbackDto,
 | 
					    @Body(ValidationPipe) dto: OAuthCallbackDto,
 | 
				
			||||||
  ): Promise<LoginResponseDto> {
 | 
					  ): Promise<LoginResponseDto> {
 | 
				
			||||||
    const loginResponse = await this.oauthService.callback(authUser, dto);
 | 
					    const loginResponse = await this.oauthService.login(dto);
 | 
				
			||||||
    response.setHeader('Set-Cookie', this.immichJwtService.getCookies(loginResponse, AuthType.OAUTH));
 | 
					    response.setHeader('Set-Cookie', this.immichJwtService.getCookies(loginResponse, AuthType.OAUTH));
 | 
				
			||||||
    return loginResponse;
 | 
					    return loginResponse;
 | 
				
			||||||
  }
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  @Authenticated()
 | 
				
			||||||
 | 
					  @Post('link')
 | 
				
			||||||
 | 
					  public async link(
 | 
				
			||||||
 | 
					    @GetAuthUser() authUser: AuthUserDto,
 | 
				
			||||||
 | 
					    @Body(ValidationPipe) dto: OAuthCallbackDto,
 | 
				
			||||||
 | 
					  ): Promise<UserResponseDto> {
 | 
				
			||||||
 | 
					    return this.oauthService.link(authUser, dto);
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  @Authenticated()
 | 
				
			||||||
 | 
					  @Post('unlink')
 | 
				
			||||||
 | 
					  public async unlink(@GetAuthUser() authUser: AuthUserDto): Promise<UserResponseDto> {
 | 
				
			||||||
 | 
					    return this.oauthService.unlink(authUser);
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 | 
				
			|||||||
@ -1,7 +1,7 @@
 | 
				
			|||||||
import { SystemConfig } from '@app/database/entities/system-config.entity';
 | 
					import { SystemConfig } from '@app/database/entities/system-config.entity';
 | 
				
			||||||
import { UserEntity } from '@app/database/entities/user.entity';
 | 
					import { UserEntity } from '@app/database/entities/user.entity';
 | 
				
			||||||
import { ImmichConfigService } from '@app/immich-config';
 | 
					import { ImmichConfigService } from '@app/immich-config';
 | 
				
			||||||
import { BadRequestException } from '@nestjs/common';
 | 
					import { BadRequestException, Logger } from '@nestjs/common';
 | 
				
			||||||
import { generators, Issuer } from 'openid-client';
 | 
					import { generators, Issuer } from 'openid-client';
 | 
				
			||||||
import { AuthUserDto } from '../../decorators/auth-user.decorator';
 | 
					import { AuthUserDto } from '../../decorators/auth-user.decorator';
 | 
				
			||||||
import { ImmichJwtService } from '../../modules/immich-jwt/immich-jwt.service';
 | 
					import { ImmichJwtService } from '../../modules/immich-jwt/immich-jwt.service';
 | 
				
			||||||
@ -32,6 +32,21 @@ const loginResponse = {
 | 
				
			|||||||
  userEmail: 'user@immich.com,',
 | 
					  userEmail: 'user@immich.com,',
 | 
				
			||||||
} as LoginResponseDto;
 | 
					} as LoginResponseDto;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					jest.mock('@nestjs/common', () => {
 | 
				
			||||||
 | 
					  return {
 | 
				
			||||||
 | 
					    ...jest.requireActual('@nestjs/common'),
 | 
				
			||||||
 | 
					    Logger: function MockLogger() {
 | 
				
			||||||
 | 
					      Object.assign(this as Logger, {
 | 
				
			||||||
 | 
					        verbose: jest.fn(),
 | 
				
			||||||
 | 
					        debug: jest.fn(),
 | 
				
			||||||
 | 
					        log: jest.fn(),
 | 
				
			||||||
 | 
					        warn: jest.fn(),
 | 
				
			||||||
 | 
					        error: jest.fn(),
 | 
				
			||||||
 | 
					      });
 | 
				
			||||||
 | 
					    },
 | 
				
			||||||
 | 
					  };
 | 
				
			||||||
 | 
					});
 | 
				
			||||||
 | 
					
 | 
				
			||||||
describe('OAuthService', () => {
 | 
					describe('OAuthService', () => {
 | 
				
			||||||
  let sut: OAuthService;
 | 
					  let sut: OAuthService;
 | 
				
			||||||
  let userRepositoryMock: jest.Mocked<IUserRepository>;
 | 
					  let userRepositoryMock: jest.Mocked<IUserRepository>;
 | 
				
			||||||
@ -109,9 +124,9 @@ describe('OAuthService', () => {
 | 
				
			|||||||
    });
 | 
					    });
 | 
				
			||||||
  });
 | 
					  });
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  describe('callback', () => {
 | 
					  describe('login', () => {
 | 
				
			||||||
    it('should throw an error if OAuth is not enabled', async () => {
 | 
					    it('should throw an error if OAuth is not enabled', async () => {
 | 
				
			||||||
      await expect(sut.callback(authUser, { url: '' })).rejects.toBeInstanceOf(BadRequestException);
 | 
					      await expect(sut.login({ url: '' })).rejects.toBeInstanceOf(BadRequestException);
 | 
				
			||||||
    });
 | 
					    });
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    it('should not allow auto registering', async () => {
 | 
					    it('should not allow auto registering', async () => {
 | 
				
			||||||
@ -122,10 +137,8 @@ describe('OAuthService', () => {
 | 
				
			|||||||
        },
 | 
					        },
 | 
				
			||||||
      } as SystemConfig);
 | 
					      } as SystemConfig);
 | 
				
			||||||
      sut = new OAuthService(immichJwtServiceMock, immichConfigServiceMock, userRepositoryMock);
 | 
					      sut = new OAuthService(immichJwtServiceMock, immichConfigServiceMock, userRepositoryMock);
 | 
				
			||||||
      jest.spyOn(sut['logger'], 'debug').mockImplementation(() => null);
 | 
					 | 
				
			||||||
      jest.spyOn(sut['logger'], 'warn').mockImplementation(() => null);
 | 
					 | 
				
			||||||
      userRepositoryMock.getByEmail.mockResolvedValue(null);
 | 
					      userRepositoryMock.getByEmail.mockResolvedValue(null);
 | 
				
			||||||
      await expect(sut.callback(authUser, { url: 'http://immich/auth/login?code=abc123' })).rejects.toBeInstanceOf(
 | 
					      await expect(sut.login({ url: 'http://immich/auth/login?code=abc123' })).rejects.toBeInstanceOf(
 | 
				
			||||||
        BadRequestException,
 | 
					        BadRequestException,
 | 
				
			||||||
      );
 | 
					      );
 | 
				
			||||||
      expect(userRepositoryMock.getByEmail).toHaveBeenCalledTimes(1);
 | 
					      expect(userRepositoryMock.getByEmail).toHaveBeenCalledTimes(1);
 | 
				
			||||||
@ -139,15 +152,11 @@ describe('OAuthService', () => {
 | 
				
			|||||||
        },
 | 
					        },
 | 
				
			||||||
      } as SystemConfig);
 | 
					      } as SystemConfig);
 | 
				
			||||||
      sut = new OAuthService(immichJwtServiceMock, immichConfigServiceMock, userRepositoryMock);
 | 
					      sut = new OAuthService(immichJwtServiceMock, immichConfigServiceMock, userRepositoryMock);
 | 
				
			||||||
      jest.spyOn(sut['logger'], 'debug').mockImplementation(() => null);
 | 
					 | 
				
			||||||
      jest.spyOn(sut['logger'], 'warn').mockImplementation(() => null);
 | 
					 | 
				
			||||||
      userRepositoryMock.getByEmail.mockResolvedValue(user);
 | 
					      userRepositoryMock.getByEmail.mockResolvedValue(user);
 | 
				
			||||||
      userRepositoryMock.update.mockResolvedValue(user);
 | 
					      userRepositoryMock.update.mockResolvedValue(user);
 | 
				
			||||||
      immichJwtServiceMock.createLoginResponse.mockResolvedValue(loginResponse);
 | 
					      immichJwtServiceMock.createLoginResponse.mockResolvedValue(loginResponse);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
      await expect(sut.callback(authUser, { url: 'http://immich/auth/login?code=abc123' })).resolves.toEqual(
 | 
					      await expect(sut.login({ url: 'http://immich/auth/login?code=abc123' })).resolves.toEqual(loginResponse);
 | 
				
			||||||
        loginResponse,
 | 
					 | 
				
			||||||
      );
 | 
					 | 
				
			||||||
 | 
					
 | 
				
			||||||
      expect(userRepositoryMock.getByEmail).toHaveBeenCalledTimes(1);
 | 
					      expect(userRepositoryMock.getByEmail).toHaveBeenCalledTimes(1);
 | 
				
			||||||
      expect(userRepositoryMock.update).toHaveBeenCalledWith(user.id, { oauthId: sub });
 | 
					      expect(userRepositoryMock.update).toHaveBeenCalledWith(user.id, { oauthId: sub });
 | 
				
			||||||
@ -161,16 +170,12 @@ describe('OAuthService', () => {
 | 
				
			|||||||
        },
 | 
					        },
 | 
				
			||||||
      } as SystemConfig);
 | 
					      } as SystemConfig);
 | 
				
			||||||
      sut = new OAuthService(immichJwtServiceMock, immichConfigServiceMock, userRepositoryMock);
 | 
					      sut = new OAuthService(immichJwtServiceMock, immichConfigServiceMock, userRepositoryMock);
 | 
				
			||||||
      jest.spyOn(sut['logger'], 'debug').mockImplementation(() => null);
 | 
					 | 
				
			||||||
      jest.spyOn(sut['logger'], 'log').mockImplementation(() => null);
 | 
					 | 
				
			||||||
      userRepositoryMock.getByEmail.mockResolvedValue(null);
 | 
					      userRepositoryMock.getByEmail.mockResolvedValue(null);
 | 
				
			||||||
      userRepositoryMock.getAdmin.mockResolvedValue(user);
 | 
					      userRepositoryMock.getAdmin.mockResolvedValue(user);
 | 
				
			||||||
      userRepositoryMock.create.mockResolvedValue(user);
 | 
					      userRepositoryMock.create.mockResolvedValue(user);
 | 
				
			||||||
      immichJwtServiceMock.createLoginResponse.mockResolvedValue(loginResponse);
 | 
					      immichJwtServiceMock.createLoginResponse.mockResolvedValue(loginResponse);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
      await expect(sut.callback(authUser, { url: 'http://immich/auth/login?code=abc123' })).resolves.toEqual(
 | 
					      await expect(sut.login({ url: 'http://immich/auth/login?code=abc123' })).resolves.toEqual(loginResponse);
 | 
				
			||||||
        loginResponse,
 | 
					 | 
				
			||||||
      );
 | 
					 | 
				
			||||||
 | 
					
 | 
				
			||||||
      expect(userRepositoryMock.getByEmail).toHaveBeenCalledTimes(2); // second call is for domain check before create
 | 
					      expect(userRepositoryMock.getByEmail).toHaveBeenCalledTimes(2); // second call is for domain check before create
 | 
				
			||||||
      expect(userRepositoryMock.create).toHaveBeenCalledTimes(1);
 | 
					      expect(userRepositoryMock.create).toHaveBeenCalledTimes(1);
 | 
				
			||||||
@ -178,6 +183,57 @@ describe('OAuthService', () => {
 | 
				
			|||||||
    });
 | 
					    });
 | 
				
			||||||
  });
 | 
					  });
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  describe('link', () => {
 | 
				
			||||||
 | 
					    it('should link an account', async () => {
 | 
				
			||||||
 | 
					      immichConfigServiceMock.getConfig.mockResolvedValue({
 | 
				
			||||||
 | 
					        oauth: {
 | 
				
			||||||
 | 
					          enabled: true,
 | 
				
			||||||
 | 
					          autoRegister: true,
 | 
				
			||||||
 | 
					        },
 | 
				
			||||||
 | 
					      } as SystemConfig);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					      userRepositoryMock.update.mockResolvedValue(user);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					      await sut.link(authUser, { url: 'http://immich/user-settings?code=abc123' });
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					      expect(userRepositoryMock.update).toHaveBeenCalledWith(authUser.id, { oauthId: sub });
 | 
				
			||||||
 | 
					    });
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    it('should not link an already linked oauth.sub', async () => {
 | 
				
			||||||
 | 
					      immichConfigServiceMock.getConfig.mockResolvedValue({
 | 
				
			||||||
 | 
					        oauth: {
 | 
				
			||||||
 | 
					          enabled: true,
 | 
				
			||||||
 | 
					          autoRegister: true,
 | 
				
			||||||
 | 
					        },
 | 
				
			||||||
 | 
					      } as SystemConfig);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					      userRepositoryMock.getByOAuthId.mockResolvedValue({ id: 'other-user' } as UserEntity);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					      await expect(sut.link(authUser, { url: 'http://immich/user-settings?code=abc123' })).rejects.toBeInstanceOf(
 | 
				
			||||||
 | 
					        BadRequestException,
 | 
				
			||||||
 | 
					      );
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					      expect(userRepositoryMock.update).not.toHaveBeenCalled();
 | 
				
			||||||
 | 
					    });
 | 
				
			||||||
 | 
					  });
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  describe('unlink', () => {
 | 
				
			||||||
 | 
					    it('should unlink an account', async () => {
 | 
				
			||||||
 | 
					      immichConfigServiceMock.getConfig.mockResolvedValue({
 | 
				
			||||||
 | 
					        oauth: {
 | 
				
			||||||
 | 
					          enabled: true,
 | 
				
			||||||
 | 
					          autoRegister: true,
 | 
				
			||||||
 | 
					        },
 | 
				
			||||||
 | 
					      } as SystemConfig);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					      userRepositoryMock.update.mockResolvedValue(user);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					      await sut.unlink(authUser);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					      expect(userRepositoryMock.update).toHaveBeenCalledWith(authUser.id, { oauthId: '' });
 | 
				
			||||||
 | 
					    });
 | 
				
			||||||
 | 
					  });
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  describe('getLogoutEndpoint', () => {
 | 
					  describe('getLogoutEndpoint', () => {
 | 
				
			||||||
    it('should return null if OAuth is not configured', async () => {
 | 
					    it('should return null if OAuth is not configured', async () => {
 | 
				
			||||||
      await expect(sut.getLogoutEndpoint()).resolves.toBeNull();
 | 
					      await expect(sut.getLogoutEndpoint()).resolves.toBeNull();
 | 
				
			||||||
 | 
				
			|||||||
@ -4,6 +4,7 @@ import { ClientMetadata, custom, generators, Issuer, UserinfoResponse } from 'op
 | 
				
			|||||||
import { AuthUserDto } from '../../decorators/auth-user.decorator';
 | 
					import { AuthUserDto } from '../../decorators/auth-user.decorator';
 | 
				
			||||||
import { ImmichJwtService } from '../../modules/immich-jwt/immich-jwt.service';
 | 
					import { ImmichJwtService } from '../../modules/immich-jwt/immich-jwt.service';
 | 
				
			||||||
import { LoginResponseDto } from '../auth/response-dto/login-response.dto';
 | 
					import { LoginResponseDto } from '../auth/response-dto/login-response.dto';
 | 
				
			||||||
 | 
					import { UserResponseDto } from '../user/response-dto/user-response.dto';
 | 
				
			||||||
import { IUserRepository, USER_REPOSITORY } from '../user/user-repository';
 | 
					import { IUserRepository, USER_REPOSITORY } from '../user/user-repository';
 | 
				
			||||||
import { UserCore } from '../user/user.core';
 | 
					import { UserCore } from '../user/user.core';
 | 
				
			||||||
import { OAuthCallbackDto } from './dto/oauth-auth-code.dto';
 | 
					import { OAuthCallbackDto } from './dto/oauth-auth-code.dto';
 | 
				
			||||||
@ -47,12 +48,8 @@ export class OAuthService {
 | 
				
			|||||||
    return { enabled: true, buttonText, url };
 | 
					    return { enabled: true, buttonText, url };
 | 
				
			||||||
  }
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  public async callback(authUser: AuthUserDto, dto: OAuthCallbackDto): Promise<LoginResponseDto> {
 | 
					  public async login(dto: OAuthCallbackDto): Promise<LoginResponseDto> {
 | 
				
			||||||
    const redirectUri = dto.url.split('?')[0];
 | 
					    const profile = await this.callback(dto.url);
 | 
				
			||||||
    const client = await this.getClient();
 | 
					 | 
				
			||||||
    const params = client.callbackParams(dto.url);
 | 
					 | 
				
			||||||
    const tokens = await client.callback(redirectUri, params, { state: params.state });
 | 
					 | 
				
			||||||
    const profile = await client.userinfo<OAuthProfile>(tokens.access_token || '');
 | 
					 | 
				
			||||||
 | 
					
 | 
				
			||||||
    this.logger.debug(`Logging in with OAuth: ${JSON.stringify(profile)}`);
 | 
					    this.logger.debug(`Logging in with OAuth: ${JSON.stringify(profile)}`);
 | 
				
			||||||
    let user = await this.userCore.getByOAuthId(profile.sub);
 | 
					    let user = await this.userCore.getByOAuthId(profile.sub);
 | 
				
			||||||
@ -61,7 +58,7 @@ export class OAuthService {
 | 
				
			|||||||
    if (!user) {
 | 
					    if (!user) {
 | 
				
			||||||
      const emailUser = await this.userCore.getByEmail(profile.email);
 | 
					      const emailUser = await this.userCore.getByEmail(profile.email);
 | 
				
			||||||
      if (emailUser) {
 | 
					      if (emailUser) {
 | 
				
			||||||
        user = await this.userCore.updateUser(authUser, emailUser, { oauthId: profile.sub });
 | 
					        user = await this.userCore.updateUser(emailUser, emailUser.id, { oauthId: profile.sub });
 | 
				
			||||||
      }
 | 
					      }
 | 
				
			||||||
    }
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
@ -88,6 +85,20 @@ export class OAuthService {
 | 
				
			|||||||
    return this.immichJwtService.createLoginResponse(user);
 | 
					    return this.immichJwtService.createLoginResponse(user);
 | 
				
			||||||
  }
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  public async link(user: AuthUserDto, dto: OAuthCallbackDto): Promise<UserResponseDto> {
 | 
				
			||||||
 | 
					    const { sub: oauthId } = await this.callback(dto.url);
 | 
				
			||||||
 | 
					    const duplicate = await this.userCore.getByOAuthId(oauthId);
 | 
				
			||||||
 | 
					    if (duplicate && duplicate.id !== user.id) {
 | 
				
			||||||
 | 
					      this.logger.warn(`OAuth link account failed: sub is already linked to another user (${duplicate.email}).`);
 | 
				
			||||||
 | 
					      throw new BadRequestException('This OAuth account has already been linked to another user.');
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					    return this.userCore.updateUser(user, user.id, { oauthId });
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  public async unlink(user: AuthUserDto): Promise<UserResponseDto> {
 | 
				
			||||||
 | 
					    return this.userCore.updateUser(user, user.id, { oauthId: '' });
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  public async getLogoutEndpoint(): Promise<string | null> {
 | 
					  public async getLogoutEndpoint(): Promise<string | null> {
 | 
				
			||||||
    const config = await this.immichConfigService.getConfig();
 | 
					    const config = await this.immichConfigService.getConfig();
 | 
				
			||||||
    const { enabled } = config.oauth;
 | 
					    const { enabled } = config.oauth;
 | 
				
			||||||
@ -98,6 +109,14 @@ export class OAuthService {
 | 
				
			|||||||
    return (await this.getClient()).issuer.metadata.end_session_endpoint || null;
 | 
					    return (await this.getClient()).issuer.metadata.end_session_endpoint || null;
 | 
				
			||||||
  }
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  private async callback(url: string): Promise<any> {
 | 
				
			||||||
 | 
					    const redirectUri = url.split('?')[0];
 | 
				
			||||||
 | 
					    const client = await this.getClient();
 | 
				
			||||||
 | 
					    const params = client.callbackParams(url);
 | 
				
			||||||
 | 
					    const tokens = await client.callback(redirectUri, params, { state: params.state });
 | 
				
			||||||
 | 
					    return await client.userinfo<OAuthProfile>(tokens.access_token || '');
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  private async getClient() {
 | 
					  private async getClient() {
 | 
				
			||||||
    const config = await this.immichConfigService.getConfig();
 | 
					    const config = await this.immichConfigService.getConfig();
 | 
				
			||||||
    const { enabled, clientId, clientSecret, issuerUrl } = config.oauth;
 | 
					    const { enabled, clientId, clientSecret, issuerUrl } = config.oauth;
 | 
				
			||||||
 | 
				
			|||||||
@ -10,6 +10,7 @@ export class UserResponseDto {
 | 
				
			|||||||
  shouldChangePassword!: boolean;
 | 
					  shouldChangePassword!: boolean;
 | 
				
			||||||
  isAdmin!: boolean;
 | 
					  isAdmin!: boolean;
 | 
				
			||||||
  deletedAt?: Date;
 | 
					  deletedAt?: Date;
 | 
				
			||||||
 | 
					  oauthId!: string;
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
export function mapUser(entity: UserEntity): UserResponseDto {
 | 
					export function mapUser(entity: UserEntity): UserResponseDto {
 | 
				
			||||||
@ -23,5 +24,6 @@ export function mapUser(entity: UserEntity): UserResponseDto {
 | 
				
			|||||||
    shouldChangePassword: entity.shouldChangePassword,
 | 
					    shouldChangePassword: entity.shouldChangePassword,
 | 
				
			||||||
    isAdmin: entity.isAdmin,
 | 
					    isAdmin: entity.isAdmin,
 | 
				
			||||||
    deletedAt: entity.deletedAt,
 | 
					    deletedAt: entity.deletedAt,
 | 
				
			||||||
 | 
					    oauthId: entity.oauthId,
 | 
				
			||||||
  };
 | 
					  };
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 | 
				
			|||||||
@ -25,27 +25,22 @@ export class UserCore {
 | 
				
			|||||||
    return hash(password, salt);
 | 
					    return hash(password, salt);
 | 
				
			||||||
  }
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  async updateUser(authUser: AuthUserDto, userToUpdate: UserEntity, data: Partial<UserEntity>): Promise<UserEntity> {
 | 
					  async updateUser(authUser: AuthUserDto, id: string, dto: Partial<UserEntity>): Promise<UserEntity> {
 | 
				
			||||||
    if (!authUser.isAdmin && (authUser.id !== userToUpdate.id || userToUpdate.id != data.id)) {
 | 
					    if (!(authUser.isAdmin || authUser.id === id)) {
 | 
				
			||||||
      throw new ForbiddenException('You are not allowed to update this user');
 | 
					      throw new ForbiddenException('You are not allowed to update this user');
 | 
				
			||||||
    }
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    // TODO: can this happen? If so we should implement a test case, otherwise remove it (also from DTO)
 | 
					    if (dto.isAdmin && authUser.isAdmin && authUser.id !== id) {
 | 
				
			||||||
    if (userToUpdate.isAdmin) {
 | 
					      throw new BadRequestException('Admin user exists');
 | 
				
			||||||
      const adminUser = await this.userRepository.getAdmin();
 | 
					 | 
				
			||||||
      if (adminUser && adminUser.id !== userToUpdate.id) {
 | 
					 | 
				
			||||||
        throw new BadRequestException('Admin user exists');
 | 
					 | 
				
			||||||
      }
 | 
					 | 
				
			||||||
    }
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    try {
 | 
					    try {
 | 
				
			||||||
      const payload: Partial<UserEntity> = { ...data };
 | 
					      if (dto.password) {
 | 
				
			||||||
      if (payload.password) {
 | 
					 | 
				
			||||||
        const salt = await this.generateSalt();
 | 
					        const salt = await this.generateSalt();
 | 
				
			||||||
        payload.salt = salt;
 | 
					        dto.salt = salt;
 | 
				
			||||||
        payload.password = await this.hashPassword(payload.password, salt);
 | 
					        dto.password = await this.hashPassword(dto.password, salt);
 | 
				
			||||||
      }
 | 
					      }
 | 
				
			||||||
      return this.userRepository.update(userToUpdate.id, payload);
 | 
					      return this.userRepository.update(id, dto);
 | 
				
			||||||
    } catch (e) {
 | 
					    } catch (e) {
 | 
				
			||||||
      Logger.error(e, 'Failed to update user info');
 | 
					      Logger.error(e, 'Failed to update user info');
 | 
				
			||||||
      throw new InternalServerErrorException('Failed to update user info');
 | 
					      throw new InternalServerErrorException('Failed to update user info');
 | 
				
			||||||
 | 
				
			|||||||
@ -141,6 +141,25 @@ describe('UserService', () => {
 | 
				
			|||||||
  });
 | 
					  });
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  describe('Create user', () => {
 | 
					  describe('Create user', () => {
 | 
				
			||||||
 | 
					    it('should let the admin update himself', async () => {
 | 
				
			||||||
 | 
					      const dto = { id: adminUser.id, shouldChangePassword: true, isAdmin: true };
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					      when(userRepositoryMock.get).calledWith(adminUser.id).mockResolvedValueOnce(null);
 | 
				
			||||||
 | 
					      when(userRepositoryMock.update).calledWith(adminUser.id, dto).mockResolvedValueOnce(adminUser);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					      await sut.updateUser(adminUser, dto);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					      expect(userRepositoryMock.update).toHaveBeenCalledWith(adminUser.id, dto);
 | 
				
			||||||
 | 
					    });
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    it('should not let the another user become an admin', async () => {
 | 
				
			||||||
 | 
					      const dto = { id: immichUser.id, shouldChangePassword: true, isAdmin: true };
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					      when(userRepositoryMock.get).calledWith(immichUser.id).mockResolvedValueOnce(immichUser);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					      await expect(sut.updateUser(adminUser, dto)).rejects.toBeInstanceOf(BadRequestException);
 | 
				
			||||||
 | 
					    });
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    it('should not create a user if there is no local admin account', async () => {
 | 
					    it('should not create a user if there is no local admin account', async () => {
 | 
				
			||||||
      when(userRepositoryMock.getAdmin).calledWith().mockResolvedValueOnce(null);
 | 
					      when(userRepositoryMock.getAdmin).calledWith().mockResolvedValueOnce(null);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
				
			|||||||
@ -65,12 +65,12 @@ export class UserService {
 | 
				
			|||||||
    return mapUser(createdUser);
 | 
					    return mapUser(createdUser);
 | 
				
			||||||
  }
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  async updateUser(authUser: AuthUserDto, updateUserDto: UpdateUserDto): Promise<UserResponseDto> {
 | 
					  async updateUser(authUser: AuthUserDto, dto: UpdateUserDto): Promise<UserResponseDto> {
 | 
				
			||||||
    const user = await this.userCore.get(updateUserDto.id);
 | 
					    const user = await this.userCore.get(dto.id);
 | 
				
			||||||
    if (!user) {
 | 
					    if (!user) {
 | 
				
			||||||
      throw new NotFoundException('User not found');
 | 
					      throw new NotFoundException('User not found');
 | 
				
			||||||
    }
 | 
					    }
 | 
				
			||||||
    const updatedUser = await this.userCore.updateUser(authUser, user, updateUserDto);
 | 
					    const updatedUser = await this.userCore.updateUser(authUser, dto.id, dto);
 | 
				
			||||||
    return mapUser(updatedUser);
 | 
					    return mapUser(updatedUser);
 | 
				
			||||||
  }
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
				
			|||||||
@ -107,6 +107,7 @@ describe('User', () => {
 | 
				
			|||||||
              shouldChangePassword: true,
 | 
					              shouldChangePassword: true,
 | 
				
			||||||
              profileImagePath: '',
 | 
					              profileImagePath: '',
 | 
				
			||||||
              deletedAt: null,
 | 
					              deletedAt: null,
 | 
				
			||||||
 | 
					              oauthId: '',
 | 
				
			||||||
            },
 | 
					            },
 | 
				
			||||||
            {
 | 
					            {
 | 
				
			||||||
              email: userTwoEmail,
 | 
					              email: userTwoEmail,
 | 
				
			||||||
@ -118,6 +119,7 @@ describe('User', () => {
 | 
				
			|||||||
              shouldChangePassword: true,
 | 
					              shouldChangePassword: true,
 | 
				
			||||||
              profileImagePath: '',
 | 
					              profileImagePath: '',
 | 
				
			||||||
              deletedAt: null,
 | 
					              deletedAt: null,
 | 
				
			||||||
 | 
					              oauthId: '',
 | 
				
			||||||
            },
 | 
					            },
 | 
				
			||||||
          ]),
 | 
					          ]),
 | 
				
			||||||
        );
 | 
					        );
 | 
				
			||||||
 | 
				
			|||||||
@ -1826,6 +1826,58 @@
 | 
				
			|||||||
        ]
 | 
					        ]
 | 
				
			||||||
      }
 | 
					      }
 | 
				
			||||||
    },
 | 
					    },
 | 
				
			||||||
 | 
					    "/oauth/link": {
 | 
				
			||||||
 | 
					      "post": {
 | 
				
			||||||
 | 
					        "operationId": "link",
 | 
				
			||||||
 | 
					        "parameters": [],
 | 
				
			||||||
 | 
					        "requestBody": {
 | 
				
			||||||
 | 
					          "required": true,
 | 
				
			||||||
 | 
					          "content": {
 | 
				
			||||||
 | 
					            "application/json": {
 | 
				
			||||||
 | 
					              "schema": {
 | 
				
			||||||
 | 
					                "$ref": "#/components/schemas/OAuthCallbackDto"
 | 
				
			||||||
 | 
					              }
 | 
				
			||||||
 | 
					            }
 | 
				
			||||||
 | 
					          }
 | 
				
			||||||
 | 
					        },
 | 
				
			||||||
 | 
					        "responses": {
 | 
				
			||||||
 | 
					          "201": {
 | 
				
			||||||
 | 
					            "description": "",
 | 
				
			||||||
 | 
					            "content": {
 | 
				
			||||||
 | 
					              "application/json": {
 | 
				
			||||||
 | 
					                "schema": {
 | 
				
			||||||
 | 
					                  "$ref": "#/components/schemas/UserResponseDto"
 | 
				
			||||||
 | 
					                }
 | 
				
			||||||
 | 
					              }
 | 
				
			||||||
 | 
					            }
 | 
				
			||||||
 | 
					          }
 | 
				
			||||||
 | 
					        },
 | 
				
			||||||
 | 
					        "tags": [
 | 
				
			||||||
 | 
					          "OAuth"
 | 
				
			||||||
 | 
					        ]
 | 
				
			||||||
 | 
					      }
 | 
				
			||||||
 | 
					    },
 | 
				
			||||||
 | 
					    "/oauth/unlink": {
 | 
				
			||||||
 | 
					      "post": {
 | 
				
			||||||
 | 
					        "operationId": "unlink",
 | 
				
			||||||
 | 
					        "parameters": [],
 | 
				
			||||||
 | 
					        "responses": {
 | 
				
			||||||
 | 
					          "201": {
 | 
				
			||||||
 | 
					            "description": "",
 | 
				
			||||||
 | 
					            "content": {
 | 
				
			||||||
 | 
					              "application/json": {
 | 
				
			||||||
 | 
					                "schema": {
 | 
				
			||||||
 | 
					                  "$ref": "#/components/schemas/UserResponseDto"
 | 
				
			||||||
 | 
					                }
 | 
				
			||||||
 | 
					              }
 | 
				
			||||||
 | 
					            }
 | 
				
			||||||
 | 
					          }
 | 
				
			||||||
 | 
					        },
 | 
				
			||||||
 | 
					        "tags": [
 | 
				
			||||||
 | 
					          "OAuth"
 | 
				
			||||||
 | 
					        ]
 | 
				
			||||||
 | 
					      }
 | 
				
			||||||
 | 
					    },
 | 
				
			||||||
    "/device-info": {
 | 
					    "/device-info": {
 | 
				
			||||||
      "post": {
 | 
					      "post": {
 | 
				
			||||||
        "operationId": "createDeviceInfo",
 | 
					        "operationId": "createDeviceInfo",
 | 
				
			||||||
@ -2287,6 +2339,9 @@
 | 
				
			|||||||
          "deletedAt": {
 | 
					          "deletedAt": {
 | 
				
			||||||
            "format": "date-time",
 | 
					            "format": "date-time",
 | 
				
			||||||
            "type": "string"
 | 
					            "type": "string"
 | 
				
			||||||
 | 
					          },
 | 
				
			||||||
 | 
					          "oauthId": {
 | 
				
			||||||
 | 
					            "type": "string"
 | 
				
			||||||
          }
 | 
					          }
 | 
				
			||||||
        },
 | 
					        },
 | 
				
			||||||
        "required": [
 | 
					        "required": [
 | 
				
			||||||
@ -2297,7 +2352,8 @@
 | 
				
			|||||||
          "createdAt",
 | 
					          "createdAt",
 | 
				
			||||||
          "profileImagePath",
 | 
					          "profileImagePath",
 | 
				
			||||||
          "shouldChangePassword",
 | 
					          "shouldChangePassword",
 | 
				
			||||||
          "isAdmin"
 | 
					          "isAdmin",
 | 
				
			||||||
 | 
					          "oauthId"
 | 
				
			||||||
        ]
 | 
					        ]
 | 
				
			||||||
      },
 | 
					      },
 | 
				
			||||||
      "CreateUserDto": {
 | 
					      "CreateUserDto": {
 | 
				
			||||||
 | 
				
			|||||||
@ -24,7 +24,7 @@ export class UserEntity {
 | 
				
			|||||||
  @Column({ default: '', select: false })
 | 
					  @Column({ default: '', select: false })
 | 
				
			||||||
  salt?: string;
 | 
					  salt?: string;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  @Column({ default: '', select: false })
 | 
					  @Column({ default: '' })
 | 
				
			||||||
  oauthId!: string;
 | 
					  oauthId!: string;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  @Column({ default: '' })
 | 
					  @Column({ default: '' })
 | 
				
			||||||
 | 
				
			|||||||
							
								
								
									
										127
									
								
								web/src/api/open-api/api.ts
									
									
									
										generated
									
									
									
								
							
							
						
						
									
										127
									
								
								web/src/api/open-api/api.ts
									
									
									
										generated
									
									
									
								
							@ -1951,6 +1951,12 @@ export interface UserResponseDto {
 | 
				
			|||||||
     * @memberof UserResponseDto
 | 
					     * @memberof UserResponseDto
 | 
				
			||||||
     */
 | 
					     */
 | 
				
			||||||
    'deletedAt'?: string;
 | 
					    'deletedAt'?: string;
 | 
				
			||||||
 | 
					    /**
 | 
				
			||||||
 | 
					     * 
 | 
				
			||||||
 | 
					     * @type {string}
 | 
				
			||||||
 | 
					     * @memberof UserResponseDto
 | 
				
			||||||
 | 
					     */
 | 
				
			||||||
 | 
					    'oauthId': string;
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
/**
 | 
					/**
 | 
				
			||||||
 * 
 | 
					 * 
 | 
				
			||||||
@ -5059,6 +5065,70 @@ export const OAuthApiAxiosParamCreator = function (configuration?: Configuration
 | 
				
			|||||||
            localVarRequestOptions.headers = {...localVarHeaderParameter, ...headersFromBaseOptions, ...options.headers};
 | 
					            localVarRequestOptions.headers = {...localVarHeaderParameter, ...headersFromBaseOptions, ...options.headers};
 | 
				
			||||||
            localVarRequestOptions.data = serializeDataIfNeeded(oAuthConfigDto, localVarRequestOptions, configuration)
 | 
					            localVarRequestOptions.data = serializeDataIfNeeded(oAuthConfigDto, localVarRequestOptions, configuration)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					            return {
 | 
				
			||||||
 | 
					                url: toPathString(localVarUrlObj),
 | 
				
			||||||
 | 
					                options: localVarRequestOptions,
 | 
				
			||||||
 | 
					            };
 | 
				
			||||||
 | 
					        },
 | 
				
			||||||
 | 
					        /**
 | 
				
			||||||
 | 
					         * 
 | 
				
			||||||
 | 
					         * @param {OAuthCallbackDto} oAuthCallbackDto 
 | 
				
			||||||
 | 
					         * @param {*} [options] Override http request option.
 | 
				
			||||||
 | 
					         * @throws {RequiredError}
 | 
				
			||||||
 | 
					         */
 | 
				
			||||||
 | 
					        link: async (oAuthCallbackDto: OAuthCallbackDto, options: AxiosRequestConfig = {}): Promise<RequestArgs> => {
 | 
				
			||||||
 | 
					            // verify required parameter 'oAuthCallbackDto' is not null or undefined
 | 
				
			||||||
 | 
					            assertParamExists('link', 'oAuthCallbackDto', oAuthCallbackDto)
 | 
				
			||||||
 | 
					            const localVarPath = `/oauth/link`;
 | 
				
			||||||
 | 
					            // 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: 'POST', ...baseOptions, ...options};
 | 
				
			||||||
 | 
					            const localVarHeaderParameter = {} as any;
 | 
				
			||||||
 | 
					            const localVarQueryParameter = {} as any;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    
 | 
				
			||||||
 | 
					            localVarHeaderParameter['Content-Type'] = 'application/json';
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					            setSearchParams(localVarUrlObj, localVarQueryParameter);
 | 
				
			||||||
 | 
					            let headersFromBaseOptions = baseOptions && baseOptions.headers ? baseOptions.headers : {};
 | 
				
			||||||
 | 
					            localVarRequestOptions.headers = {...localVarHeaderParameter, ...headersFromBaseOptions, ...options.headers};
 | 
				
			||||||
 | 
					            localVarRequestOptions.data = serializeDataIfNeeded(oAuthCallbackDto, localVarRequestOptions, configuration)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					            return {
 | 
				
			||||||
 | 
					                url: toPathString(localVarUrlObj),
 | 
				
			||||||
 | 
					                options: localVarRequestOptions,
 | 
				
			||||||
 | 
					            };
 | 
				
			||||||
 | 
					        },
 | 
				
			||||||
 | 
					        /**
 | 
				
			||||||
 | 
					         * 
 | 
				
			||||||
 | 
					         * @param {*} [options] Override http request option.
 | 
				
			||||||
 | 
					         * @throws {RequiredError}
 | 
				
			||||||
 | 
					         */
 | 
				
			||||||
 | 
					        unlink: async (options: AxiosRequestConfig = {}): Promise<RequestArgs> => {
 | 
				
			||||||
 | 
					            const localVarPath = `/oauth/unlink`;
 | 
				
			||||||
 | 
					            // 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: 'POST', ...baseOptions, ...options};
 | 
				
			||||||
 | 
					            const localVarHeaderParameter = {} as any;
 | 
				
			||||||
 | 
					            const localVarQueryParameter = {} as any;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    
 | 
				
			||||||
 | 
					            setSearchParams(localVarUrlObj, localVarQueryParameter);
 | 
				
			||||||
 | 
					            let headersFromBaseOptions = baseOptions && baseOptions.headers ? baseOptions.headers : {};
 | 
				
			||||||
 | 
					            localVarRequestOptions.headers = {...localVarHeaderParameter, ...headersFromBaseOptions, ...options.headers};
 | 
				
			||||||
 | 
					
 | 
				
			||||||
            return {
 | 
					            return {
 | 
				
			||||||
                url: toPathString(localVarUrlObj),
 | 
					                url: toPathString(localVarUrlObj),
 | 
				
			||||||
                options: localVarRequestOptions,
 | 
					                options: localVarRequestOptions,
 | 
				
			||||||
@ -5094,6 +5164,25 @@ export const OAuthApiFp = function(configuration?: Configuration) {
 | 
				
			|||||||
            const localVarAxiosArgs = await localVarAxiosParamCreator.generateConfig(oAuthConfigDto, options);
 | 
					            const localVarAxiosArgs = await localVarAxiosParamCreator.generateConfig(oAuthConfigDto, options);
 | 
				
			||||||
            return createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration);
 | 
					            return createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration);
 | 
				
			||||||
        },
 | 
					        },
 | 
				
			||||||
 | 
					        /**
 | 
				
			||||||
 | 
					         * 
 | 
				
			||||||
 | 
					         * @param {OAuthCallbackDto} oAuthCallbackDto 
 | 
				
			||||||
 | 
					         * @param {*} [options] Override http request option.
 | 
				
			||||||
 | 
					         * @throws {RequiredError}
 | 
				
			||||||
 | 
					         */
 | 
				
			||||||
 | 
					        async link(oAuthCallbackDto: OAuthCallbackDto, options?: AxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise<UserResponseDto>> {
 | 
				
			||||||
 | 
					            const localVarAxiosArgs = await localVarAxiosParamCreator.link(oAuthCallbackDto, options);
 | 
				
			||||||
 | 
					            return createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration);
 | 
				
			||||||
 | 
					        },
 | 
				
			||||||
 | 
					        /**
 | 
				
			||||||
 | 
					         * 
 | 
				
			||||||
 | 
					         * @param {*} [options] Override http request option.
 | 
				
			||||||
 | 
					         * @throws {RequiredError}
 | 
				
			||||||
 | 
					         */
 | 
				
			||||||
 | 
					        async unlink(options?: AxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise<UserResponseDto>> {
 | 
				
			||||||
 | 
					            const localVarAxiosArgs = await localVarAxiosParamCreator.unlink(options);
 | 
				
			||||||
 | 
					            return createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration);
 | 
				
			||||||
 | 
					        },
 | 
				
			||||||
    }
 | 
					    }
 | 
				
			||||||
};
 | 
					};
 | 
				
			||||||
 | 
					
 | 
				
			||||||
@ -5122,6 +5211,23 @@ export const OAuthApiFactory = function (configuration?: Configuration, basePath
 | 
				
			|||||||
        generateConfig(oAuthConfigDto: OAuthConfigDto, options?: any): AxiosPromise<OAuthConfigResponseDto> {
 | 
					        generateConfig(oAuthConfigDto: OAuthConfigDto, options?: any): AxiosPromise<OAuthConfigResponseDto> {
 | 
				
			||||||
            return localVarFp.generateConfig(oAuthConfigDto, options).then((request) => request(axios, basePath));
 | 
					            return localVarFp.generateConfig(oAuthConfigDto, options).then((request) => request(axios, basePath));
 | 
				
			||||||
        },
 | 
					        },
 | 
				
			||||||
 | 
					        /**
 | 
				
			||||||
 | 
					         * 
 | 
				
			||||||
 | 
					         * @param {OAuthCallbackDto} oAuthCallbackDto 
 | 
				
			||||||
 | 
					         * @param {*} [options] Override http request option.
 | 
				
			||||||
 | 
					         * @throws {RequiredError}
 | 
				
			||||||
 | 
					         */
 | 
				
			||||||
 | 
					        link(oAuthCallbackDto: OAuthCallbackDto, options?: any): AxiosPromise<UserResponseDto> {
 | 
				
			||||||
 | 
					            return localVarFp.link(oAuthCallbackDto, options).then((request) => request(axios, basePath));
 | 
				
			||||||
 | 
					        },
 | 
				
			||||||
 | 
					        /**
 | 
				
			||||||
 | 
					         * 
 | 
				
			||||||
 | 
					         * @param {*} [options] Override http request option.
 | 
				
			||||||
 | 
					         * @throws {RequiredError}
 | 
				
			||||||
 | 
					         */
 | 
				
			||||||
 | 
					        unlink(options?: any): AxiosPromise<UserResponseDto> {
 | 
				
			||||||
 | 
					            return localVarFp.unlink(options).then((request) => request(axios, basePath));
 | 
				
			||||||
 | 
					        },
 | 
				
			||||||
    };
 | 
					    };
 | 
				
			||||||
};
 | 
					};
 | 
				
			||||||
 | 
					
 | 
				
			||||||
@ -5153,6 +5259,27 @@ export class OAuthApi extends BaseAPI {
 | 
				
			|||||||
    public generateConfig(oAuthConfigDto: OAuthConfigDto, options?: AxiosRequestConfig) {
 | 
					    public generateConfig(oAuthConfigDto: OAuthConfigDto, options?: AxiosRequestConfig) {
 | 
				
			||||||
        return OAuthApiFp(this.configuration).generateConfig(oAuthConfigDto, options).then((request) => request(this.axios, this.basePath));
 | 
					        return OAuthApiFp(this.configuration).generateConfig(oAuthConfigDto, options).then((request) => request(this.axios, this.basePath));
 | 
				
			||||||
    }
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    /**
 | 
				
			||||||
 | 
					     * 
 | 
				
			||||||
 | 
					     * @param {OAuthCallbackDto} oAuthCallbackDto 
 | 
				
			||||||
 | 
					     * @param {*} [options] Override http request option.
 | 
				
			||||||
 | 
					     * @throws {RequiredError}
 | 
				
			||||||
 | 
					     * @memberof OAuthApi
 | 
				
			||||||
 | 
					     */
 | 
				
			||||||
 | 
					    public link(oAuthCallbackDto: OAuthCallbackDto, options?: AxiosRequestConfig) {
 | 
				
			||||||
 | 
					        return OAuthApiFp(this.configuration).link(oAuthCallbackDto, options).then((request) => request(this.axios, this.basePath));
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    /**
 | 
				
			||||||
 | 
					     * 
 | 
				
			||||||
 | 
					     * @param {*} [options] Override http request option.
 | 
				
			||||||
 | 
					     * @throws {RequiredError}
 | 
				
			||||||
 | 
					     * @memberof OAuthApi
 | 
				
			||||||
 | 
					     */
 | 
				
			||||||
 | 
					    public unlink(options?: AxiosRequestConfig) {
 | 
				
			||||||
 | 
					        return OAuthApiFp(this.configuration).unlink(options).then((request) => request(this.axios, this.basePath));
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
				
			|||||||
@ -1,3 +1,7 @@
 | 
				
			|||||||
 | 
					import { AxiosError, AxiosPromise } from 'axios';
 | 
				
			||||||
 | 
					import { api } from './api';
 | 
				
			||||||
 | 
					import { UserResponseDto } from './open-api';
 | 
				
			||||||
 | 
					
 | 
				
			||||||
const _basePath = '/api';
 | 
					const _basePath = '/api';
 | 
				
			||||||
 | 
					
 | 
				
			||||||
export function getFileUrl(assetId: string, isThumb?: boolean, isWeb?: boolean) {
 | 
					export function getFileUrl(assetId: string, isThumb?: boolean, isWeb?: boolean) {
 | 
				
			||||||
@ -9,3 +13,26 @@ export function getFileUrl(assetId: string, isThumb?: boolean, isWeb?: boolean)
 | 
				
			|||||||
 | 
					
 | 
				
			||||||
	return urlObj.href;
 | 
						return urlObj.href;
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					export type ApiError = AxiosError<{ message: string }>;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					export const oauth = {
 | 
				
			||||||
 | 
						isCallback: (location: Location) => {
 | 
				
			||||||
 | 
							const search = location.search;
 | 
				
			||||||
 | 
							return search.includes('code=') || search.includes('error=');
 | 
				
			||||||
 | 
						},
 | 
				
			||||||
 | 
						getConfig: (location: Location) => {
 | 
				
			||||||
 | 
							const redirectUri = location.href.split('?')[0];
 | 
				
			||||||
 | 
							console.log(`OAuth Redirect URI: ${redirectUri}`);
 | 
				
			||||||
 | 
							return api.oauthApi.generateConfig({ redirectUri });
 | 
				
			||||||
 | 
						},
 | 
				
			||||||
 | 
						login: (location: Location) => {
 | 
				
			||||||
 | 
							return api.oauthApi.callback({ url: location.href });
 | 
				
			||||||
 | 
						},
 | 
				
			||||||
 | 
						link: (location: Location): AxiosPromise<UserResponseDto> => {
 | 
				
			||||||
 | 
							return api.oauthApi.link({ url: location.href });
 | 
				
			||||||
 | 
						},
 | 
				
			||||||
 | 
						unlink: () => {
 | 
				
			||||||
 | 
							return api.oauthApi.unlink();
 | 
				
			||||||
 | 
						}
 | 
				
			||||||
 | 
					};
 | 
				
			||||||
 | 
				
			|||||||
@ -1,7 +1,7 @@
 | 
				
			|||||||
<script lang="ts">
 | 
					<script lang="ts">
 | 
				
			||||||
	import LoadingSpinner from '$lib/components/shared-components/loading-spinner.svelte';
 | 
						import LoadingSpinner from '$lib/components/shared-components/loading-spinner.svelte';
 | 
				
			||||||
	import { loginPageMessage } from '$lib/constants';
 | 
						import { loginPageMessage } from '$lib/constants';
 | 
				
			||||||
	import { api, OAuthConfigResponseDto } from '@api';
 | 
						import { api, oauth, OAuthConfigResponseDto } from '@api';
 | 
				
			||||||
	import { createEventDispatcher, onMount } from 'svelte';
 | 
						import { createEventDispatcher, onMount } from 'svelte';
 | 
				
			||||||
 | 
					
 | 
				
			||||||
	let error: string;
 | 
						let error: string;
 | 
				
			||||||
@ -14,11 +14,10 @@
 | 
				
			|||||||
	const dispatch = createEventDispatcher();
 | 
						const dispatch = createEventDispatcher();
 | 
				
			||||||
 | 
					
 | 
				
			||||||
	onMount(async () => {
 | 
						onMount(async () => {
 | 
				
			||||||
		const search = window.location.search;
 | 
							if (oauth.isCallback(window.location)) {
 | 
				
			||||||
		if (search.includes('code=') || search.includes('error=')) {
 | 
					 | 
				
			||||||
			try {
 | 
								try {
 | 
				
			||||||
				loading = true;
 | 
									loading = true;
 | 
				
			||||||
				await api.oauthApi.callback({ url: window.location.href });
 | 
									await oauth.login(window.location);
 | 
				
			||||||
				dispatch('success');
 | 
									dispatch('success');
 | 
				
			||||||
				return;
 | 
									return;
 | 
				
			||||||
			} catch (e) {
 | 
								} catch (e) {
 | 
				
			||||||
@ -29,9 +28,7 @@
 | 
				
			|||||||
		}
 | 
							}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
		try {
 | 
							try {
 | 
				
			||||||
			const redirectUri = window.location.href.split('?')[0];
 | 
								const { data } = await oauth.getConfig(window.location);
 | 
				
			||||||
			console.log(`OAuth Redirect URI: ${redirectUri}`);
 | 
					 | 
				
			||||||
			const { data } = await api.oauthApi.generateConfig({ redirectUri });
 | 
					 | 
				
			||||||
			oauthConfig = data;
 | 
								oauthConfig = data;
 | 
				
			||||||
		} catch (e) {
 | 
							} catch (e) {
 | 
				
			||||||
			console.error('Error [login-form] [oauth.generateConfig]', e);
 | 
								console.error('Error [login-form] [oauth.generateConfig]', e);
 | 
				
			||||||
 | 
				
			|||||||
@ -0,0 +1,78 @@
 | 
				
			|||||||
 | 
					<script lang="ts">
 | 
				
			||||||
 | 
						import {
 | 
				
			||||||
 | 
							notificationController,
 | 
				
			||||||
 | 
							NotificationType
 | 
				
			||||||
 | 
						} from '$lib/components/shared-components/notification/notification';
 | 
				
			||||||
 | 
						import { api, ApiError } from '@api';
 | 
				
			||||||
 | 
						import { fade } from 'svelte/transition';
 | 
				
			||||||
 | 
						import SettingInputField, {
 | 
				
			||||||
 | 
							SettingInputFieldType
 | 
				
			||||||
 | 
						} from '../admin-page/settings/setting-input-field.svelte';
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
						let password = '';
 | 
				
			||||||
 | 
						let newPassword = '';
 | 
				
			||||||
 | 
						let confirmPassword = '';
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
						const handleChangePassword = async () => {
 | 
				
			||||||
 | 
							try {
 | 
				
			||||||
 | 
								await api.authenticationApi.changePassword({
 | 
				
			||||||
 | 
									password,
 | 
				
			||||||
 | 
									newPassword
 | 
				
			||||||
 | 
								});
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
								notificationController.show({
 | 
				
			||||||
 | 
									message: 'Updated password',
 | 
				
			||||||
 | 
									type: NotificationType.Info
 | 
				
			||||||
 | 
								});
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
								password = '';
 | 
				
			||||||
 | 
								newPassword = '';
 | 
				
			||||||
 | 
								confirmPassword = '';
 | 
				
			||||||
 | 
							} catch (error) {
 | 
				
			||||||
 | 
								console.error('Error [user-profile] [changePassword]', error);
 | 
				
			||||||
 | 
								notificationController.show({
 | 
				
			||||||
 | 
									message: (error as ApiError)?.response?.data?.message || 'Unable to change password',
 | 
				
			||||||
 | 
									type: NotificationType.Error
 | 
				
			||||||
 | 
								});
 | 
				
			||||||
 | 
							}
 | 
				
			||||||
 | 
						};
 | 
				
			||||||
 | 
					</script>
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					<section class="my-4">
 | 
				
			||||||
 | 
						<div in:fade={{ duration: 500 }}>
 | 
				
			||||||
 | 
							<form autocomplete="off" on:submit|preventDefault>
 | 
				
			||||||
 | 
								<div class="flex flex-col gap-4 ml-4 mt-4">
 | 
				
			||||||
 | 
									<SettingInputField
 | 
				
			||||||
 | 
										inputType={SettingInputFieldType.PASSWORD}
 | 
				
			||||||
 | 
										label="Password"
 | 
				
			||||||
 | 
										bind:value={password}
 | 
				
			||||||
 | 
										required={true}
 | 
				
			||||||
 | 
									/>
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
									<SettingInputField
 | 
				
			||||||
 | 
										inputType={SettingInputFieldType.PASSWORD}
 | 
				
			||||||
 | 
										label="New password"
 | 
				
			||||||
 | 
										bind:value={newPassword}
 | 
				
			||||||
 | 
										required={true}
 | 
				
			||||||
 | 
									/>
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
									<SettingInputField
 | 
				
			||||||
 | 
										inputType={SettingInputFieldType.PASSWORD}
 | 
				
			||||||
 | 
										label="Confirm password"
 | 
				
			||||||
 | 
										bind:value={confirmPassword}
 | 
				
			||||||
 | 
										required={true}
 | 
				
			||||||
 | 
									/>
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
									<div class="flex justify-end">
 | 
				
			||||||
 | 
										<button
 | 
				
			||||||
 | 
											type="submit"
 | 
				
			||||||
 | 
											disabled={!(password && newPassword && newPassword === confirmPassword)}
 | 
				
			||||||
 | 
											on:click={() => handleChangePassword()}
 | 
				
			||||||
 | 
											class="text-sm bg-immich-primary dark:bg-immich-dark-primary hover:bg-immich-primary/75 dark:hover:bg-immich-dark-primary/80 px-4 py-2 text-white dark:text-immich-dark-gray rounded-full shadow-md font-medium disabled:opacity-50 disabled:cursor-not-allowed"
 | 
				
			||||||
 | 
											>Save
 | 
				
			||||||
 | 
										</button>
 | 
				
			||||||
 | 
									</div>
 | 
				
			||||||
 | 
								</div>
 | 
				
			||||||
 | 
							</form>
 | 
				
			||||||
 | 
						</div>
 | 
				
			||||||
 | 
					</section>
 | 
				
			||||||
@ -0,0 +1,86 @@
 | 
				
			|||||||
 | 
					<script lang="ts">
 | 
				
			||||||
 | 
						import { goto } from '$app/navigation';
 | 
				
			||||||
 | 
						import { oauth, OAuthConfigResponseDto, UserResponseDto } from '@api';
 | 
				
			||||||
 | 
						import { onMount } from 'svelte';
 | 
				
			||||||
 | 
						import { fade } from 'svelte/transition';
 | 
				
			||||||
 | 
						import { handleError } from '../../utils/handle-error';
 | 
				
			||||||
 | 
						import LoadingSpinner from '../shared-components/loading-spinner.svelte';
 | 
				
			||||||
 | 
						import {
 | 
				
			||||||
 | 
							notificationController,
 | 
				
			||||||
 | 
							NotificationType
 | 
				
			||||||
 | 
						} from '../shared-components/notification/notification';
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
						export let user: UserResponseDto;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
						let config: OAuthConfigResponseDto = { enabled: false };
 | 
				
			||||||
 | 
						let loading = true;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
						onMount(async () => {
 | 
				
			||||||
 | 
							if (oauth.isCallback(window.location)) {
 | 
				
			||||||
 | 
								try {
 | 
				
			||||||
 | 
									loading = true;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
									const { data } = await oauth.link(window.location);
 | 
				
			||||||
 | 
									user = data;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
									notificationController.show({
 | 
				
			||||||
 | 
										message: 'Linked OAuth account',
 | 
				
			||||||
 | 
										type: NotificationType.Info
 | 
				
			||||||
 | 
									});
 | 
				
			||||||
 | 
								} catch (error) {
 | 
				
			||||||
 | 
									handleError(error, 'Unable to link OAuth account');
 | 
				
			||||||
 | 
								} finally {
 | 
				
			||||||
 | 
									goto('?open=oauth');
 | 
				
			||||||
 | 
								}
 | 
				
			||||||
 | 
							}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
							try {
 | 
				
			||||||
 | 
								const { data } = await oauth.getConfig(window.location);
 | 
				
			||||||
 | 
								config = data;
 | 
				
			||||||
 | 
							} catch (error) {
 | 
				
			||||||
 | 
								handleError(error, 'Unable to load OAuth config');
 | 
				
			||||||
 | 
							}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
							loading = false;
 | 
				
			||||||
 | 
						});
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
						const handleUnlink = async () => {
 | 
				
			||||||
 | 
							try {
 | 
				
			||||||
 | 
								const { data } = await oauth.unlink();
 | 
				
			||||||
 | 
								user = data;
 | 
				
			||||||
 | 
								notificationController.show({
 | 
				
			||||||
 | 
									message: 'Unlinked OAuth account',
 | 
				
			||||||
 | 
									type: NotificationType.Info
 | 
				
			||||||
 | 
								});
 | 
				
			||||||
 | 
							} catch (error) {
 | 
				
			||||||
 | 
								handleError(error, 'Unable to unlink account');
 | 
				
			||||||
 | 
							}
 | 
				
			||||||
 | 
						};
 | 
				
			||||||
 | 
					</script>
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					<section class="my-4">
 | 
				
			||||||
 | 
						<div in:fade={{ duration: 500 }}>
 | 
				
			||||||
 | 
							<div class="flex justify-end">
 | 
				
			||||||
 | 
								{#if loading}
 | 
				
			||||||
 | 
									<div class="flex place-items-center place-content-center">
 | 
				
			||||||
 | 
										<LoadingSpinner />
 | 
				
			||||||
 | 
									</div>
 | 
				
			||||||
 | 
								{:else if config.enabled}
 | 
				
			||||||
 | 
									{#if user.oauthId}
 | 
				
			||||||
 | 
										<button
 | 
				
			||||||
 | 
											on:click={() => handleUnlink()}
 | 
				
			||||||
 | 
											class="text-sm bg-immich-primary dark:bg-immich-dark-primary hover:bg-immich-primary/75 dark:hover:bg-immich-dark-primary/80 px-4 py-2 text-white dark:text-immich-dark-gray rounded-full shadow-md font-medium disabled:opacity-50 disabled:cursor-not-allowed"
 | 
				
			||||||
 | 
											>Unlink OAuth
 | 
				
			||||||
 | 
										</button>
 | 
				
			||||||
 | 
									{:else}
 | 
				
			||||||
 | 
										<a href={config.url}>
 | 
				
			||||||
 | 
											<button
 | 
				
			||||||
 | 
												class="text-sm bg-immich-primary dark:bg-immich-dark-primary hover:bg-immich-primary/75 dark:hover:bg-immich-dark-primary/80 px-4 py-2 text-white dark:text-immich-dark-gray rounded-full shadow-md font-medium disabled:opacity-50 disabled:cursor-not-allowed"
 | 
				
			||||||
 | 
												>Link to OAuth</button
 | 
				
			||||||
 | 
											>
 | 
				
			||||||
 | 
										</a>
 | 
				
			||||||
 | 
									{/if}
 | 
				
			||||||
 | 
								{/if}
 | 
				
			||||||
 | 
							</div>
 | 
				
			||||||
 | 
						</div>
 | 
				
			||||||
 | 
					</section>
 | 
				
			||||||
@ -0,0 +1,81 @@
 | 
				
			|||||||
 | 
					<script lang="ts">
 | 
				
			||||||
 | 
						import {
 | 
				
			||||||
 | 
							notificationController,
 | 
				
			||||||
 | 
							NotificationType
 | 
				
			||||||
 | 
						} from '$lib/components/shared-components/notification/notification';
 | 
				
			||||||
 | 
						import { api, UserResponseDto } from '@api';
 | 
				
			||||||
 | 
						import { fade } from 'svelte/transition';
 | 
				
			||||||
 | 
						import SettingInputField, {
 | 
				
			||||||
 | 
							SettingInputFieldType
 | 
				
			||||||
 | 
						} from '../admin-page/settings/setting-input-field.svelte';
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
						export let user: UserResponseDto;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
						const handleSaveProfile = async () => {
 | 
				
			||||||
 | 
							try {
 | 
				
			||||||
 | 
								const { data } = await api.userApi.updateUser({
 | 
				
			||||||
 | 
									id: user.id,
 | 
				
			||||||
 | 
									firstName: user.firstName,
 | 
				
			||||||
 | 
									lastName: user.lastName
 | 
				
			||||||
 | 
								});
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
								Object.assign(user, data);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
								notificationController.show({
 | 
				
			||||||
 | 
									message: 'Saved profile',
 | 
				
			||||||
 | 
									type: NotificationType.Info
 | 
				
			||||||
 | 
								});
 | 
				
			||||||
 | 
							} catch (error) {
 | 
				
			||||||
 | 
								console.error('Error [user-profile] [updateProfile]', error);
 | 
				
			||||||
 | 
								notificationController.show({
 | 
				
			||||||
 | 
									message: 'Unable to save profile',
 | 
				
			||||||
 | 
									type: NotificationType.Error
 | 
				
			||||||
 | 
								});
 | 
				
			||||||
 | 
							}
 | 
				
			||||||
 | 
						};
 | 
				
			||||||
 | 
					</script>
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					<section class="my-4">
 | 
				
			||||||
 | 
						<div in:fade={{ duration: 500 }}>
 | 
				
			||||||
 | 
							<form autocomplete="off" on:submit|preventDefault>
 | 
				
			||||||
 | 
								<div class="flex flex-col gap-4 ml-4 mt-4">
 | 
				
			||||||
 | 
									<SettingInputField
 | 
				
			||||||
 | 
										inputType={SettingInputFieldType.TEXT}
 | 
				
			||||||
 | 
										label="User ID"
 | 
				
			||||||
 | 
										bind:value={user.id}
 | 
				
			||||||
 | 
										disabled={true}
 | 
				
			||||||
 | 
									/>
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
									<SettingInputField
 | 
				
			||||||
 | 
										inputType={SettingInputFieldType.TEXT}
 | 
				
			||||||
 | 
										label="Email"
 | 
				
			||||||
 | 
										bind:value={user.email}
 | 
				
			||||||
 | 
										disabled={true}
 | 
				
			||||||
 | 
									/>
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
									<SettingInputField
 | 
				
			||||||
 | 
										inputType={SettingInputFieldType.TEXT}
 | 
				
			||||||
 | 
										label="First name"
 | 
				
			||||||
 | 
										bind:value={user.firstName}
 | 
				
			||||||
 | 
										required={true}
 | 
				
			||||||
 | 
									/>
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
									<SettingInputField
 | 
				
			||||||
 | 
										inputType={SettingInputFieldType.TEXT}
 | 
				
			||||||
 | 
										label="Last name"
 | 
				
			||||||
 | 
										bind:value={user.lastName}
 | 
				
			||||||
 | 
										required={true}
 | 
				
			||||||
 | 
									/>
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
									<div class="flex justify-end">
 | 
				
			||||||
 | 
										<button
 | 
				
			||||||
 | 
											type="submit"
 | 
				
			||||||
 | 
											on:click={() => handleSaveProfile()}
 | 
				
			||||||
 | 
											class="text-sm bg-immich-primary dark:bg-immich-dark-primary hover:bg-immich-primary/75 dark:hover:bg-immich-dark-primary/80 px-4 py-2 text-white dark:text-immich-dark-gray rounded-full shadow-md font-medium disabled:opacity-50 disabled:cursor-not-allowed"
 | 
				
			||||||
 | 
											>Save
 | 
				
			||||||
 | 
										</button>
 | 
				
			||||||
 | 
									</div>
 | 
				
			||||||
 | 
								</div>
 | 
				
			||||||
 | 
							</form>
 | 
				
			||||||
 | 
						</div>
 | 
				
			||||||
 | 
					</section>
 | 
				
			||||||
@ -1,156 +1,43 @@
 | 
				
			|||||||
<script lang="ts">
 | 
					<script lang="ts">
 | 
				
			||||||
	import {
 | 
						import { page } from '$app/stores';
 | 
				
			||||||
		notificationController,
 | 
						import { oauth, UserResponseDto } from '@api';
 | 
				
			||||||
		NotificationType
 | 
						import { onMount } from 'svelte';
 | 
				
			||||||
	} from '$lib/components/shared-components/notification/notification';
 | 
					 | 
				
			||||||
	import { api, UserResponseDto } from '@api';
 | 
					 | 
				
			||||||
	import { AxiosError } from 'axios';
 | 
					 | 
				
			||||||
	import { fade } from 'svelte/transition';
 | 
					 | 
				
			||||||
	import SettingAccordion from '../admin-page/settings/setting-accordion.svelte';
 | 
						import SettingAccordion from '../admin-page/settings/setting-accordion.svelte';
 | 
				
			||||||
	import SettingInputField, {
 | 
						import ChangePasswordSettings from './change-password-settings.svelte';
 | 
				
			||||||
		SettingInputFieldType
 | 
						import OAuthSettings from './oauth-settings.svelte';
 | 
				
			||||||
	} from '../admin-page/settings/setting-input-field.svelte';
 | 
						import UserProfileSettings from './user-profile-settings.svelte';
 | 
				
			||||||
 | 
					 | 
				
			||||||
	type ApiError = AxiosError<{ message: string }>;
 | 
					 | 
				
			||||||
 | 
					
 | 
				
			||||||
	export let user: UserResponseDto;
 | 
						export let user: UserResponseDto;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
	const handleSaveProfile = async () => {
 | 
						let oauthEnabled = false;
 | 
				
			||||||
 | 
						let oauthOpen = false;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
						onMount(async () => {
 | 
				
			||||||
 | 
							oauthOpen = oauth.isCallback(window.location);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
		try {
 | 
							try {
 | 
				
			||||||
			const { data } = await api.userApi.updateUser({
 | 
								const { data } = await oauth.getConfig(window.location);
 | 
				
			||||||
				id: user.id,
 | 
								oauthEnabled = data.enabled;
 | 
				
			||||||
				firstName: user.firstName,
 | 
							} catch {
 | 
				
			||||||
				lastName: user.lastName
 | 
								// noop
 | 
				
			||||||
			});
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
			Object.assign(user, data);
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
			notificationController.show({
 | 
					 | 
				
			||||||
				message: 'Saved profile',
 | 
					 | 
				
			||||||
				type: NotificationType.Info
 | 
					 | 
				
			||||||
			});
 | 
					 | 
				
			||||||
		} catch (error) {
 | 
					 | 
				
			||||||
			console.error('Error [user-profile] [updateProfile]', error);
 | 
					 | 
				
			||||||
			notificationController.show({
 | 
					 | 
				
			||||||
				message: 'Unable to save profile',
 | 
					 | 
				
			||||||
				type: NotificationType.Error
 | 
					 | 
				
			||||||
			});
 | 
					 | 
				
			||||||
		}
 | 
							}
 | 
				
			||||||
	};
 | 
						});
 | 
				
			||||||
 | 
					 | 
				
			||||||
	let password = '';
 | 
					 | 
				
			||||||
	let newPassword = '';
 | 
					 | 
				
			||||||
	let confirmPassword = '';
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
	const handleChangePassword = async () => {
 | 
					 | 
				
			||||||
		try {
 | 
					 | 
				
			||||||
			await api.authenticationApi.changePassword({
 | 
					 | 
				
			||||||
				password,
 | 
					 | 
				
			||||||
				newPassword
 | 
					 | 
				
			||||||
			});
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
			notificationController.show({
 | 
					 | 
				
			||||||
				message: 'Updated password',
 | 
					 | 
				
			||||||
				type: NotificationType.Info
 | 
					 | 
				
			||||||
			});
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
			password = '';
 | 
					 | 
				
			||||||
			newPassword = '';
 | 
					 | 
				
			||||||
			confirmPassword = '';
 | 
					 | 
				
			||||||
		} catch (error) {
 | 
					 | 
				
			||||||
			console.error('Error [user-profile] [changePassword]', error);
 | 
					 | 
				
			||||||
			notificationController.show({
 | 
					 | 
				
			||||||
				message: (error as ApiError)?.response?.data?.message || 'Unable to change password',
 | 
					 | 
				
			||||||
				type: NotificationType.Error
 | 
					 | 
				
			||||||
			});
 | 
					 | 
				
			||||||
		}
 | 
					 | 
				
			||||||
	};
 | 
					 | 
				
			||||||
</script>
 | 
					</script>
 | 
				
			||||||
 | 
					
 | 
				
			||||||
<SettingAccordion title="User Profile" subtitle="View and manage your profile">
 | 
					<SettingAccordion title="User Profile" subtitle="View and manage your profile">
 | 
				
			||||||
	<section class="my-4">
 | 
						<UserProfileSettings {user} />
 | 
				
			||||||
		<div in:fade={{ duration: 500 }}>
 | 
					 | 
				
			||||||
			<form autocomplete="off" on:submit|preventDefault>
 | 
					 | 
				
			||||||
				<div class="flex flex-col gap-4 ml-4 mt-4">
 | 
					 | 
				
			||||||
					<SettingInputField
 | 
					 | 
				
			||||||
						inputType={SettingInputFieldType.TEXT}
 | 
					 | 
				
			||||||
						label="User ID"
 | 
					 | 
				
			||||||
						bind:value={user.id}
 | 
					 | 
				
			||||||
						disabled={true}
 | 
					 | 
				
			||||||
					/>
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
					<SettingInputField
 | 
					 | 
				
			||||||
						inputType={SettingInputFieldType.TEXT}
 | 
					 | 
				
			||||||
						label="Email"
 | 
					 | 
				
			||||||
						bind:value={user.email}
 | 
					 | 
				
			||||||
						disabled={true}
 | 
					 | 
				
			||||||
					/>
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
					<SettingInputField
 | 
					 | 
				
			||||||
						inputType={SettingInputFieldType.TEXT}
 | 
					 | 
				
			||||||
						label="First name"
 | 
					 | 
				
			||||||
						bind:value={user.firstName}
 | 
					 | 
				
			||||||
						required={true}
 | 
					 | 
				
			||||||
					/>
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
					<SettingInputField
 | 
					 | 
				
			||||||
						inputType={SettingInputFieldType.TEXT}
 | 
					 | 
				
			||||||
						label="Last name"
 | 
					 | 
				
			||||||
						bind:value={user.lastName}
 | 
					 | 
				
			||||||
						required={true}
 | 
					 | 
				
			||||||
					/>
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
					<div class="flex justify-end">
 | 
					 | 
				
			||||||
						<button
 | 
					 | 
				
			||||||
							type="submit"
 | 
					 | 
				
			||||||
							on:click={() => handleSaveProfile()}
 | 
					 | 
				
			||||||
							class="text-sm bg-immich-primary dark:bg-immich-dark-primary hover:bg-immich-primary/75 dark:hover:bg-immich-dark-primary/80 px-4 py-2 text-white dark:text-immich-dark-gray rounded-full shadow-md font-medium disabled:opacity-50 disabled:cursor-not-allowed"
 | 
					 | 
				
			||||||
							>Save
 | 
					 | 
				
			||||||
						</button>
 | 
					 | 
				
			||||||
					</div>
 | 
					 | 
				
			||||||
				</div>
 | 
					 | 
				
			||||||
			</form>
 | 
					 | 
				
			||||||
		</div>
 | 
					 | 
				
			||||||
	</section>
 | 
					 | 
				
			||||||
</SettingAccordion>
 | 
					</SettingAccordion>
 | 
				
			||||||
 | 
					
 | 
				
			||||||
<SettingAccordion title="Password" subtitle="Change your password">
 | 
					<SettingAccordion title="Password" subtitle="Change your password">
 | 
				
			||||||
	<section class="my-4">
 | 
						<ChangePasswordSettings />
 | 
				
			||||||
		<div in:fade={{ duration: 500 }}>
 | 
					 | 
				
			||||||
			<form autocomplete="off" on:submit|preventDefault>
 | 
					 | 
				
			||||||
				<div class="flex flex-col gap-4 ml-4 mt-4">
 | 
					 | 
				
			||||||
					<SettingInputField
 | 
					 | 
				
			||||||
						inputType={SettingInputFieldType.PASSWORD}
 | 
					 | 
				
			||||||
						label="Password"
 | 
					 | 
				
			||||||
						bind:value={password}
 | 
					 | 
				
			||||||
						required={true}
 | 
					 | 
				
			||||||
					/>
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
					<SettingInputField
 | 
					 | 
				
			||||||
						inputType={SettingInputFieldType.PASSWORD}
 | 
					 | 
				
			||||||
						label="New password"
 | 
					 | 
				
			||||||
						bind:value={newPassword}
 | 
					 | 
				
			||||||
						required={true}
 | 
					 | 
				
			||||||
					/>
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
					<SettingInputField
 | 
					 | 
				
			||||||
						inputType={SettingInputFieldType.PASSWORD}
 | 
					 | 
				
			||||||
						label="Confirm password"
 | 
					 | 
				
			||||||
						bind:value={confirmPassword}
 | 
					 | 
				
			||||||
						required={true}
 | 
					 | 
				
			||||||
					/>
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
					<div class="flex justify-end">
 | 
					 | 
				
			||||||
						<button
 | 
					 | 
				
			||||||
							type="submit"
 | 
					 | 
				
			||||||
							disabled={!(password && newPassword && newPassword === confirmPassword)}
 | 
					 | 
				
			||||||
							on:click={() => handleChangePassword()}
 | 
					 | 
				
			||||||
							class="text-sm bg-immich-primary dark:bg-immich-dark-primary hover:bg-immich-primary/75 dark:hover:bg-immich-dark-primary/80 px-4 py-2 text-white dark:text-immich-dark-gray rounded-full shadow-md font-medium disabled:opacity-50 disabled:cursor-not-allowed"
 | 
					 | 
				
			||||||
							>Save
 | 
					 | 
				
			||||||
						</button>
 | 
					 | 
				
			||||||
					</div>
 | 
					 | 
				
			||||||
				</div>
 | 
					 | 
				
			||||||
			</form>
 | 
					 | 
				
			||||||
		</div>
 | 
					 | 
				
			||||||
	</section>
 | 
					 | 
				
			||||||
</SettingAccordion>
 | 
					</SettingAccordion>
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					{#if oauthEnabled}
 | 
				
			||||||
 | 
						<SettingAccordion
 | 
				
			||||||
 | 
							title="OAuth"
 | 
				
			||||||
 | 
							subtitle="Manage your linked account"
 | 
				
			||||||
 | 
							isOpen={oauthOpen || $page.url.searchParams.get('open') === 'oauth'}
 | 
				
			||||||
 | 
						>
 | 
				
			||||||
 | 
							<OAuthSettings {user} />
 | 
				
			||||||
 | 
						</SettingAccordion>
 | 
				
			||||||
 | 
					{/if}
 | 
				
			||||||
 | 
				
			|||||||
							
								
								
									
										13
									
								
								web/src/lib/utils/handle-error.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										13
									
								
								web/src/lib/utils/handle-error.ts
									
									
									
									
									
										Normal file
									
								
							@ -0,0 +1,13 @@
 | 
				
			|||||||
 | 
					import { ApiError } from '../../api';
 | 
				
			||||||
 | 
					import {
 | 
				
			||||||
 | 
						notificationController,
 | 
				
			||||||
 | 
						NotificationType
 | 
				
			||||||
 | 
					} from '../components/shared-components/notification/notification';
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					export function handleError(error: unknown, message: string) {
 | 
				
			||||||
 | 
						console.error(`[handleError]: ${message}`, error);
 | 
				
			||||||
 | 
						notificationController.show({
 | 
				
			||||||
 | 
							message: (error as ApiError)?.response?.data?.message || message,
 | 
				
			||||||
 | 
							type: NotificationType.Error
 | 
				
			||||||
 | 
						});
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
		Loading…
	
	
			
			x
			
			
		
	
		Reference in New Issue
	
	Block a user