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:
 | 
			
		||||
 | 
			
		||||
- 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`)
 | 
			
		||||
- Mobile app redirect URL `app.immich:/`
 | 
			
		||||
- `app.immich:/` - for logging in with OAuth from the [Mobile App](/docs/features/mobile-app.mdx)
 | 
			
		||||
- `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
 | 
			
		||||
You **MUST** include `app.immich:/` as the redirect URI for iOS and Android mobile app to work properly.
 | 
			
		||||
:::info Redirect URIs
 | 
			
		||||
 | 
			
		||||
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
 | 
			
		||||
@ -42,7 +61,7 @@ Once you have a new OAuth client application configured, Immich can be configure
 | 
			
		||||
 | 
			
		||||
| 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)              |
 | 
			
		||||
| Client ID     | string  | (required)           | Required. Client ID (from 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} | 
 | 
			
		||||
*OAuthApi* | [**callback**](doc//OAuthApi.md#callback) | **POST** /oauth/callback | 
 | 
			
		||||
*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* | [**getServerVersion**](doc//ServerInfoApi.md#getserverversion) | **GET** /server-info/version | 
 | 
			
		||||
*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 | 
 | 
			
		||||
[**generateConfig**](OAuthApi.md#generateconfig) | **POST** /oauth/config | 
 | 
			
		||||
[**link**](OAuthApi.md#link) | **POST** /oauth/link | 
 | 
			
		||||
[**unlink**](OAuthApi.md#unlink) | **POST** /oauth/unlink | 
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
# **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)
 | 
			
		||||
 | 
			
		||||
# **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** |  | 
 | 
			
		||||
**isAdmin** | **bool** |  | 
 | 
			
		||||
**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)
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
							
								
								
									
										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;
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  /// 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.isAdmin,
 | 
			
		||||
    this.deletedAt,
 | 
			
		||||
    required this.oauthId,
 | 
			
		||||
  });
 | 
			
		||||
 | 
			
		||||
  String id;
 | 
			
		||||
@ -48,6 +49,8 @@ class UserResponseDto {
 | 
			
		||||
  ///
 | 
			
		||||
  DateTime? deletedAt;
 | 
			
		||||
 | 
			
		||||
  String oauthId;
 | 
			
		||||
 | 
			
		||||
  @override
 | 
			
		||||
  bool operator ==(Object other) => identical(this, other) || other is UserResponseDto &&
 | 
			
		||||
     other.id == id &&
 | 
			
		||||
@ -58,7 +61,8 @@ class UserResponseDto {
 | 
			
		||||
     other.profileImagePath == profileImagePath &&
 | 
			
		||||
     other.shouldChangePassword == shouldChangePassword &&
 | 
			
		||||
     other.isAdmin == isAdmin &&
 | 
			
		||||
     other.deletedAt == deletedAt;
 | 
			
		||||
     other.deletedAt == deletedAt &&
 | 
			
		||||
     other.oauthId == oauthId;
 | 
			
		||||
 | 
			
		||||
  @override
 | 
			
		||||
  int get hashCode =>
 | 
			
		||||
@ -71,10 +75,11 @@ class UserResponseDto {
 | 
			
		||||
    (profileImagePath.hashCode) +
 | 
			
		||||
    (shouldChangePassword.hashCode) +
 | 
			
		||||
    (isAdmin.hashCode) +
 | 
			
		||||
    (deletedAt == null ? 0 : deletedAt!.hashCode);
 | 
			
		||||
    (deletedAt == null ? 0 : deletedAt!.hashCode) +
 | 
			
		||||
    (oauthId.hashCode);
 | 
			
		||||
 | 
			
		||||
  @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() {
 | 
			
		||||
    final _json = <String, dynamic>{};
 | 
			
		||||
@ -91,6 +96,7 @@ class UserResponseDto {
 | 
			
		||||
    } else {
 | 
			
		||||
      _json[r'deletedAt'] = null;
 | 
			
		||||
    }
 | 
			
		||||
      _json[r'oauthId'] = oauthId;
 | 
			
		||||
    return _json;
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
@ -122,6 +128,7 @@ class UserResponseDto {
 | 
			
		||||
        shouldChangePassword: mapValueOfType<bool>(json, r'shouldChangePassword')!,
 | 
			
		||||
        isAdmin: mapValueOfType<bool>(json, r'isAdmin')!,
 | 
			
		||||
        deletedAt: mapDateTime(json, r'deletedAt', ''),
 | 
			
		||||
        oauthId: mapValueOfType<String>(json, r'oauthId')!,
 | 
			
		||||
      );
 | 
			
		||||
    }
 | 
			
		||||
    return null;
 | 
			
		||||
@ -179,6 +186,7 @@ class UserResponseDto {
 | 
			
		||||
    'profileImagePath',
 | 
			
		||||
    'shouldChangePassword',
 | 
			
		||||
    '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
 | 
			
		||||
    });
 | 
			
		||||
 | 
			
		||||
    //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
 | 
			
		||||
    });
 | 
			
		||||
 | 
			
		||||
    // String oauthId
 | 
			
		||||
    test('to test the property `oauthId`', () async {
 | 
			
		||||
      // TODO
 | 
			
		||||
    });
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
  });
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
@ -74,9 +74,7 @@ export class AuthService {
 | 
			
		||||
      throw new BadRequestException('Wrong password');
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    user.password = newPassword;
 | 
			
		||||
 | 
			
		||||
    return this.userCore.updateUser(authUser, user, dto);
 | 
			
		||||
    return this.userCore.updateUser(authUser, authUser.id, { password: newPassword });
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  public async adminSignUp(dto: SignUpDto): Promise<AdminSignupResponseDto> {
 | 
			
		||||
 | 
			
		||||
@ -3,8 +3,10 @@ import { ApiTags } from '@nestjs/swagger';
 | 
			
		||||
import { Response } from 'express';
 | 
			
		||||
import { AuthType } from '../../constants/jwt.constant';
 | 
			
		||||
import { AuthUserDto, GetAuthUser } from '../../decorators/auth-user.decorator';
 | 
			
		||||
import { Authenticated } from '../../decorators/authenticated.decorator';
 | 
			
		||||
import { ImmichJwtService } from '../../modules/immich-jwt/immich-jwt.service';
 | 
			
		||||
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 { OAuthConfigDto } from './dto/oauth-config.dto';
 | 
			
		||||
import { OAuthService } from './oauth.service';
 | 
			
		||||
@ -22,12 +24,26 @@ export class OAuthController {
 | 
			
		||||
 | 
			
		||||
  @Post('/callback')
 | 
			
		||||
  public async callback(
 | 
			
		||||
    @GetAuthUser() authUser: AuthUserDto,
 | 
			
		||||
    @Res({ passthrough: true }) response: Response,
 | 
			
		||||
    @Body(ValidationPipe) dto: OAuthCallbackDto,
 | 
			
		||||
  ): 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));
 | 
			
		||||
    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 { UserEntity } from '@app/database/entities/user.entity';
 | 
			
		||||
import { ImmichConfigService } from '@app/immich-config';
 | 
			
		||||
import { BadRequestException } from '@nestjs/common';
 | 
			
		||||
import { BadRequestException, Logger } from '@nestjs/common';
 | 
			
		||||
import { generators, Issuer } from 'openid-client';
 | 
			
		||||
import { AuthUserDto } from '../../decorators/auth-user.decorator';
 | 
			
		||||
import { ImmichJwtService } from '../../modules/immich-jwt/immich-jwt.service';
 | 
			
		||||
@ -32,6 +32,21 @@ const loginResponse = {
 | 
			
		||||
  userEmail: 'user@immich.com,',
 | 
			
		||||
} 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', () => {
 | 
			
		||||
  let sut: OAuthService;
 | 
			
		||||
  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 () => {
 | 
			
		||||
      await expect(sut.callback(authUser, { url: '' })).rejects.toBeInstanceOf(BadRequestException);
 | 
			
		||||
      await expect(sut.login({ url: '' })).rejects.toBeInstanceOf(BadRequestException);
 | 
			
		||||
    });
 | 
			
		||||
 | 
			
		||||
    it('should not allow auto registering', async () => {
 | 
			
		||||
@ -122,10 +137,8 @@ describe('OAuthService', () => {
 | 
			
		||||
        },
 | 
			
		||||
      } as SystemConfig);
 | 
			
		||||
      sut = new OAuthService(immichJwtServiceMock, immichConfigServiceMock, userRepositoryMock);
 | 
			
		||||
      jest.spyOn(sut['logger'], 'debug').mockImplementation(() => null);
 | 
			
		||||
      jest.spyOn(sut['logger'], 'warn').mockImplementation(() => 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,
 | 
			
		||||
      );
 | 
			
		||||
      expect(userRepositoryMock.getByEmail).toHaveBeenCalledTimes(1);
 | 
			
		||||
@ -139,15 +152,11 @@ describe('OAuthService', () => {
 | 
			
		||||
        },
 | 
			
		||||
      } as SystemConfig);
 | 
			
		||||
      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.update.mockResolvedValue(user);
 | 
			
		||||
      immichJwtServiceMock.createLoginResponse.mockResolvedValue(loginResponse);
 | 
			
		||||
 | 
			
		||||
      await expect(sut.callback(authUser, { url: 'http://immich/auth/login?code=abc123' })).resolves.toEqual(
 | 
			
		||||
        loginResponse,
 | 
			
		||||
      );
 | 
			
		||||
      await expect(sut.login({ url: 'http://immich/auth/login?code=abc123' })).resolves.toEqual(loginResponse);
 | 
			
		||||
 | 
			
		||||
      expect(userRepositoryMock.getByEmail).toHaveBeenCalledTimes(1);
 | 
			
		||||
      expect(userRepositoryMock.update).toHaveBeenCalledWith(user.id, { oauthId: sub });
 | 
			
		||||
@ -161,16 +170,12 @@ describe('OAuthService', () => {
 | 
			
		||||
        },
 | 
			
		||||
      } as SystemConfig);
 | 
			
		||||
      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.getAdmin.mockResolvedValue(user);
 | 
			
		||||
      userRepositoryMock.create.mockResolvedValue(user);
 | 
			
		||||
      immichJwtServiceMock.createLoginResponse.mockResolvedValue(loginResponse);
 | 
			
		||||
 | 
			
		||||
      await expect(sut.callback(authUser, { url: 'http://immich/auth/login?code=abc123' })).resolves.toEqual(
 | 
			
		||||
        loginResponse,
 | 
			
		||||
      );
 | 
			
		||||
      await expect(sut.login({ url: 'http://immich/auth/login?code=abc123' })).resolves.toEqual(loginResponse);
 | 
			
		||||
 | 
			
		||||
      expect(userRepositoryMock.getByEmail).toHaveBeenCalledTimes(2); // second call is for domain check before create
 | 
			
		||||
      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', () => {
 | 
			
		||||
    it('should return null if OAuth is not configured', async () => {
 | 
			
		||||
      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 { ImmichJwtService } from '../../modules/immich-jwt/immich-jwt.service';
 | 
			
		||||
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 { UserCore } from '../user/user.core';
 | 
			
		||||
import { OAuthCallbackDto } from './dto/oauth-auth-code.dto';
 | 
			
		||||
@ -47,12 +48,8 @@ export class OAuthService {
 | 
			
		||||
    return { enabled: true, buttonText, url };
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  public async callback(authUser: AuthUserDto, dto: OAuthCallbackDto): Promise<LoginResponseDto> {
 | 
			
		||||
    const redirectUri = dto.url.split('?')[0];
 | 
			
		||||
    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 || '');
 | 
			
		||||
  public async login(dto: OAuthCallbackDto): Promise<LoginResponseDto> {
 | 
			
		||||
    const profile = await this.callback(dto.url);
 | 
			
		||||
 | 
			
		||||
    this.logger.debug(`Logging in with OAuth: ${JSON.stringify(profile)}`);
 | 
			
		||||
    let user = await this.userCore.getByOAuthId(profile.sub);
 | 
			
		||||
@ -61,7 +58,7 @@ export class OAuthService {
 | 
			
		||||
    if (!user) {
 | 
			
		||||
      const emailUser = await this.userCore.getByEmail(profile.email);
 | 
			
		||||
      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);
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  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> {
 | 
			
		||||
    const config = await this.immichConfigService.getConfig();
 | 
			
		||||
    const { enabled } = config.oauth;
 | 
			
		||||
@ -98,6 +109,14 @@ export class OAuthService {
 | 
			
		||||
    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() {
 | 
			
		||||
    const config = await this.immichConfigService.getConfig();
 | 
			
		||||
    const { enabled, clientId, clientSecret, issuerUrl } = config.oauth;
 | 
			
		||||
 | 
			
		||||
@ -10,6 +10,7 @@ export class UserResponseDto {
 | 
			
		||||
  shouldChangePassword!: boolean;
 | 
			
		||||
  isAdmin!: boolean;
 | 
			
		||||
  deletedAt?: Date;
 | 
			
		||||
  oauthId!: string;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export function mapUser(entity: UserEntity): UserResponseDto {
 | 
			
		||||
@ -23,5 +24,6 @@ export function mapUser(entity: UserEntity): UserResponseDto {
 | 
			
		||||
    shouldChangePassword: entity.shouldChangePassword,
 | 
			
		||||
    isAdmin: entity.isAdmin,
 | 
			
		||||
    deletedAt: entity.deletedAt,
 | 
			
		||||
    oauthId: entity.oauthId,
 | 
			
		||||
  };
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
@ -25,27 +25,22 @@ export class UserCore {
 | 
			
		||||
    return hash(password, salt);
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  async updateUser(authUser: AuthUserDto, userToUpdate: UserEntity, data: Partial<UserEntity>): Promise<UserEntity> {
 | 
			
		||||
    if (!authUser.isAdmin && (authUser.id !== userToUpdate.id || userToUpdate.id != data.id)) {
 | 
			
		||||
  async updateUser(authUser: AuthUserDto, id: string, dto: Partial<UserEntity>): Promise<UserEntity> {
 | 
			
		||||
    if (!(authUser.isAdmin || authUser.id === id)) {
 | 
			
		||||
      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 (userToUpdate.isAdmin) {
 | 
			
		||||
      const adminUser = await this.userRepository.getAdmin();
 | 
			
		||||
      if (adminUser && adminUser.id !== userToUpdate.id) {
 | 
			
		||||
        throw new BadRequestException('Admin user exists');
 | 
			
		||||
      }
 | 
			
		||||
    if (dto.isAdmin && authUser.isAdmin && authUser.id !== id) {
 | 
			
		||||
      throw new BadRequestException('Admin user exists');
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    try {
 | 
			
		||||
      const payload: Partial<UserEntity> = { ...data };
 | 
			
		||||
      if (payload.password) {
 | 
			
		||||
      if (dto.password) {
 | 
			
		||||
        const salt = await this.generateSalt();
 | 
			
		||||
        payload.salt = salt;
 | 
			
		||||
        payload.password = await this.hashPassword(payload.password, salt);
 | 
			
		||||
        dto.salt = salt;
 | 
			
		||||
        dto.password = await this.hashPassword(dto.password, salt);
 | 
			
		||||
      }
 | 
			
		||||
      return this.userRepository.update(userToUpdate.id, payload);
 | 
			
		||||
      return this.userRepository.update(id, dto);
 | 
			
		||||
    } catch (e) {
 | 
			
		||||
      Logger.error(e, 'Failed to update user info');
 | 
			
		||||
      throw new InternalServerErrorException('Failed to update user info');
 | 
			
		||||
 | 
			
		||||
@ -141,6 +141,25 @@ describe('UserService', () => {
 | 
			
		||||
  });
 | 
			
		||||
 | 
			
		||||
  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 () => {
 | 
			
		||||
      when(userRepositoryMock.getAdmin).calledWith().mockResolvedValueOnce(null);
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
@ -65,12 +65,12 @@ export class UserService {
 | 
			
		||||
    return mapUser(createdUser);
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  async updateUser(authUser: AuthUserDto, updateUserDto: UpdateUserDto): Promise<UserResponseDto> {
 | 
			
		||||
    const user = await this.userCore.get(updateUserDto.id);
 | 
			
		||||
  async updateUser(authUser: AuthUserDto, dto: UpdateUserDto): Promise<UserResponseDto> {
 | 
			
		||||
    const user = await this.userCore.get(dto.id);
 | 
			
		||||
    if (!user) {
 | 
			
		||||
      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);
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
@ -107,6 +107,7 @@ describe('User', () => {
 | 
			
		||||
              shouldChangePassword: true,
 | 
			
		||||
              profileImagePath: '',
 | 
			
		||||
              deletedAt: null,
 | 
			
		||||
              oauthId: '',
 | 
			
		||||
            },
 | 
			
		||||
            {
 | 
			
		||||
              email: userTwoEmail,
 | 
			
		||||
@ -118,6 +119,7 @@ describe('User', () => {
 | 
			
		||||
              shouldChangePassword: true,
 | 
			
		||||
              profileImagePath: '',
 | 
			
		||||
              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": {
 | 
			
		||||
      "post": {
 | 
			
		||||
        "operationId": "createDeviceInfo",
 | 
			
		||||
@ -2287,6 +2339,9 @@
 | 
			
		||||
          "deletedAt": {
 | 
			
		||||
            "format": "date-time",
 | 
			
		||||
            "type": "string"
 | 
			
		||||
          },
 | 
			
		||||
          "oauthId": {
 | 
			
		||||
            "type": "string"
 | 
			
		||||
          }
 | 
			
		||||
        },
 | 
			
		||||
        "required": [
 | 
			
		||||
@ -2297,7 +2352,8 @@
 | 
			
		||||
          "createdAt",
 | 
			
		||||
          "profileImagePath",
 | 
			
		||||
          "shouldChangePassword",
 | 
			
		||||
          "isAdmin"
 | 
			
		||||
          "isAdmin",
 | 
			
		||||
          "oauthId"
 | 
			
		||||
        ]
 | 
			
		||||
      },
 | 
			
		||||
      "CreateUserDto": {
 | 
			
		||||
 | 
			
		||||
@ -24,7 +24,7 @@ export class UserEntity {
 | 
			
		||||
  @Column({ default: '', select: false })
 | 
			
		||||
  salt?: string;
 | 
			
		||||
 | 
			
		||||
  @Column({ default: '', select: false })
 | 
			
		||||
  @Column({ default: '' })
 | 
			
		||||
  oauthId!: string;
 | 
			
		||||
 | 
			
		||||
  @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
 | 
			
		||||
     */
 | 
			
		||||
    '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.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 {
 | 
			
		||||
                url: toPathString(localVarUrlObj),
 | 
			
		||||
                options: localVarRequestOptions,
 | 
			
		||||
@ -5094,6 +5164,25 @@ export const OAuthApiFp = function(configuration?: Configuration) {
 | 
			
		||||
            const localVarAxiosArgs = await localVarAxiosParamCreator.generateConfig(oAuthConfigDto, options);
 | 
			
		||||
            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> {
 | 
			
		||||
            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) {
 | 
			
		||||
        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';
 | 
			
		||||
 | 
			
		||||
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;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
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">
 | 
			
		||||
	import LoadingSpinner from '$lib/components/shared-components/loading-spinner.svelte';
 | 
			
		||||
	import { loginPageMessage } from '$lib/constants';
 | 
			
		||||
	import { api, OAuthConfigResponseDto } from '@api';
 | 
			
		||||
	import { api, oauth, OAuthConfigResponseDto } from '@api';
 | 
			
		||||
	import { createEventDispatcher, onMount } from 'svelte';
 | 
			
		||||
 | 
			
		||||
	let error: string;
 | 
			
		||||
@ -14,11 +14,10 @@
 | 
			
		||||
	const dispatch = createEventDispatcher();
 | 
			
		||||
 | 
			
		||||
	onMount(async () => {
 | 
			
		||||
		const search = window.location.search;
 | 
			
		||||
		if (search.includes('code=') || search.includes('error=')) {
 | 
			
		||||
		if (oauth.isCallback(window.location)) {
 | 
			
		||||
			try {
 | 
			
		||||
				loading = true;
 | 
			
		||||
				await api.oauthApi.callback({ url: window.location.href });
 | 
			
		||||
				await oauth.login(window.location);
 | 
			
		||||
				dispatch('success');
 | 
			
		||||
				return;
 | 
			
		||||
			} catch (e) {
 | 
			
		||||
@ -29,9 +28,7 @@
 | 
			
		||||
		}
 | 
			
		||||
 | 
			
		||||
		try {
 | 
			
		||||
			const redirectUri = window.location.href.split('?')[0];
 | 
			
		||||
			console.log(`OAuth Redirect URI: ${redirectUri}`);
 | 
			
		||||
			const { data } = await api.oauthApi.generateConfig({ redirectUri });
 | 
			
		||||
			const { data } = await oauth.getConfig(window.location);
 | 
			
		||||
			oauthConfig = data;
 | 
			
		||||
		} catch (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">
 | 
			
		||||
	import {
 | 
			
		||||
		notificationController,
 | 
			
		||||
		NotificationType
 | 
			
		||||
	} from '$lib/components/shared-components/notification/notification';
 | 
			
		||||
	import { api, UserResponseDto } from '@api';
 | 
			
		||||
	import { AxiosError } from 'axios';
 | 
			
		||||
	import { fade } from 'svelte/transition';
 | 
			
		||||
	import { page } from '$app/stores';
 | 
			
		||||
	import { oauth, UserResponseDto } from '@api';
 | 
			
		||||
	import { onMount } from 'svelte';
 | 
			
		||||
	import SettingAccordion from '../admin-page/settings/setting-accordion.svelte';
 | 
			
		||||
	import SettingInputField, {
 | 
			
		||||
		SettingInputFieldType
 | 
			
		||||
	} from '../admin-page/settings/setting-input-field.svelte';
 | 
			
		||||
 | 
			
		||||
	type ApiError = AxiosError<{ message: string }>;
 | 
			
		||||
	import ChangePasswordSettings from './change-password-settings.svelte';
 | 
			
		||||
	import OAuthSettings from './oauth-settings.svelte';
 | 
			
		||||
	import UserProfileSettings from './user-profile-settings.svelte';
 | 
			
		||||
 | 
			
		||||
	export let user: UserResponseDto;
 | 
			
		||||
 | 
			
		||||
	const handleSaveProfile = async () => {
 | 
			
		||||
	let oauthEnabled = false;
 | 
			
		||||
	let oauthOpen = false;
 | 
			
		||||
 | 
			
		||||
	onMount(async () => {
 | 
			
		||||
		oauthOpen = oauth.isCallback(window.location);
 | 
			
		||||
 | 
			
		||||
		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
 | 
			
		||||
			});
 | 
			
		||||
			const { data } = await oauth.getConfig(window.location);
 | 
			
		||||
			oauthEnabled = data.enabled;
 | 
			
		||||
		} catch {
 | 
			
		||||
			// noop
 | 
			
		||||
		}
 | 
			
		||||
	};
 | 
			
		||||
 | 
			
		||||
	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>
 | 
			
		||||
 | 
			
		||||
<SettingAccordion title="User Profile" subtitle="View and manage your profile">
 | 
			
		||||
	<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>
 | 
			
		||||
	<UserProfileSettings {user} />
 | 
			
		||||
</SettingAccordion>
 | 
			
		||||
 | 
			
		||||
<SettingAccordion title="Password" subtitle="Change your password">
 | 
			
		||||
	<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>
 | 
			
		||||
	<ChangePasswordSettings />
 | 
			
		||||
</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