mirror of
				https://github.com/immich-app/immich.git
				synced 2025-11-03 19:17:11 -05:00 
			
		
		
		
	feat(web,server): manage authorized devices (#2329)
* feat: manage authorized devices * chore: open api * get header from mobile app * write header from mobile app * styling * fix unit test * feat: use relative time * feat: update access time * fix: tests * chore: confirm wording * chore: bump test coverage thresholds * feat: add some icons * chore: icon tweaks --------- Co-authored-by: Alex Tran <alex.tran1502@gmail.com>
This commit is contained in:
		
							parent
							
								
									aa91b946fa
								
							
						
					
					
						commit
						b8313abfa8
					
				@ -1,5 +1,6 @@
 | 
			
		||||
import 'dart:io';
 | 
			
		||||
 | 
			
		||||
import 'package:device_info_plus/device_info_plus.dart';
 | 
			
		||||
import 'package:flutter/material.dart';
 | 
			
		||||
import 'package:flutter/services.dart';
 | 
			
		||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
 | 
			
		||||
@ -49,6 +50,22 @@ class AuthenticationNotifier extends StateNotifier<AuthenticationState> {
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    // Make sign-in request
 | 
			
		||||
    DeviceInfoPlugin deviceInfoPlugin = DeviceInfoPlugin();
 | 
			
		||||
 | 
			
		||||
    if (Platform.isIOS) {
 | 
			
		||||
      var iosInfo = await deviceInfoPlugin.iosInfo;
 | 
			
		||||
      _apiService.authenticationApi.apiClient
 | 
			
		||||
          .addDefaultHeader('deviceModel', iosInfo.utsname.machine ?? '');
 | 
			
		||||
      _apiService.authenticationApi.apiClient
 | 
			
		||||
          .addDefaultHeader('deviceType', 'iOS');
 | 
			
		||||
    } else {
 | 
			
		||||
      var androidInfo = await deviceInfoPlugin.androidInfo;
 | 
			
		||||
      _apiService.authenticationApi.apiClient
 | 
			
		||||
          .addDefaultHeader('deviceModel', androidInfo.model);
 | 
			
		||||
      _apiService.authenticationApi.apiClient
 | 
			
		||||
          .addDefaultHeader('deviceType', 'Android');
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    try {
 | 
			
		||||
      var loginResponse = await _apiService.authenticationApi.login(
 | 
			
		||||
        LoginCredentialDto(
 | 
			
		||||
 | 
			
		||||
							
								
								
									
										3
									
								
								mobile/openapi/.openapi-generator/FILES
									
									
									
										generated
									
									
									
								
							
							
						
						
									
										3
									
								
								mobile/openapi/.openapi-generator/FILES
									
									
									
										generated
									
									
									
								
							@ -23,6 +23,7 @@ doc/AssetCountByUserIdResponseDto.md
 | 
			
		||||
doc/AssetFileUploadResponseDto.md
 | 
			
		||||
doc/AssetResponseDto.md
 | 
			
		||||
doc/AssetTypeEnum.md
 | 
			
		||||
doc/AuthDeviceResponseDto.md
 | 
			
		||||
doc/AuthenticationApi.md
 | 
			
		||||
doc/ChangePasswordDto.md
 | 
			
		||||
doc/CheckDuplicateAssetDto.md
 | 
			
		||||
@ -145,6 +146,7 @@ lib/model/asset_count_by_user_id_response_dto.dart
 | 
			
		||||
lib/model/asset_file_upload_response_dto.dart
 | 
			
		||||
lib/model/asset_response_dto.dart
 | 
			
		||||
lib/model/asset_type_enum.dart
 | 
			
		||||
lib/model/auth_device_response_dto.dart
 | 
			
		||||
lib/model/change_password_dto.dart
 | 
			
		||||
lib/model/check_duplicate_asset_dto.dart
 | 
			
		||||
lib/model/check_duplicate_asset_response_dto.dart
 | 
			
		||||
@ -238,6 +240,7 @@ test/asset_count_by_user_id_response_dto_test.dart
 | 
			
		||||
test/asset_file_upload_response_dto_test.dart
 | 
			
		||||
test/asset_response_dto_test.dart
 | 
			
		||||
test/asset_type_enum_test.dart
 | 
			
		||||
test/auth_device_response_dto_test.dart
 | 
			
		||||
test/authentication_api_test.dart
 | 
			
		||||
test/change_password_dto_test.dart
 | 
			
		||||
test/check_duplicate_asset_dto_test.dart
 | 
			
		||||
 | 
			
		||||
							
								
								
									
										3
									
								
								mobile/openapi/README.md
									
									
									
										generated
									
									
									
								
							
							
						
						
									
										3
									
								
								mobile/openapi/README.md
									
									
									
										generated
									
									
									
								
							@ -111,8 +111,10 @@ Class | Method | HTTP request | Description
 | 
			
		||||
*AssetApi* | [**uploadFile**](doc//AssetApi.md#uploadfile) | **POST** /asset/upload | 
 | 
			
		||||
*AuthenticationApi* | [**adminSignUp**](doc//AuthenticationApi.md#adminsignup) | **POST** /auth/admin-sign-up | 
 | 
			
		||||
*AuthenticationApi* | [**changePassword**](doc//AuthenticationApi.md#changepassword) | **POST** /auth/change-password | 
 | 
			
		||||
*AuthenticationApi* | [**getAuthDevices**](doc//AuthenticationApi.md#getauthdevices) | **GET** /auth/devices | 
 | 
			
		||||
*AuthenticationApi* | [**login**](doc//AuthenticationApi.md#login) | **POST** /auth/login | 
 | 
			
		||||
*AuthenticationApi* | [**logout**](doc//AuthenticationApi.md#logout) | **POST** /auth/logout | 
 | 
			
		||||
*AuthenticationApi* | [**logoutAuthDevice**](doc//AuthenticationApi.md#logoutauthdevice) | **DELETE** /auth/devices/{id} | 
 | 
			
		||||
*AuthenticationApi* | [**validateAccessToken**](doc//AuthenticationApi.md#validateaccesstoken) | **POST** /auth/validateToken | 
 | 
			
		||||
*DeviceInfoApi* | [**upsertDeviceInfo**](doc//DeviceInfoApi.md#upsertdeviceinfo) | **PUT** /device-info | 
 | 
			
		||||
*JobApi* | [**getAllJobsStatus**](doc//JobApi.md#getalljobsstatus) | **GET** /jobs | 
 | 
			
		||||
@ -174,6 +176,7 @@ Class | Method | HTTP request | Description
 | 
			
		||||
 - [AssetFileUploadResponseDto](doc//AssetFileUploadResponseDto.md)
 | 
			
		||||
 - [AssetResponseDto](doc//AssetResponseDto.md)
 | 
			
		||||
 - [AssetTypeEnum](doc//AssetTypeEnum.md)
 | 
			
		||||
 - [AuthDeviceResponseDto](doc//AuthDeviceResponseDto.md)
 | 
			
		||||
 - [ChangePasswordDto](doc//ChangePasswordDto.md)
 | 
			
		||||
 - [CheckDuplicateAssetDto](doc//CheckDuplicateAssetDto.md)
 | 
			
		||||
 - [CheckDuplicateAssetResponseDto](doc//CheckDuplicateAssetResponseDto.md)
 | 
			
		||||
 | 
			
		||||
							
								
								
									
										20
									
								
								mobile/openapi/doc/AuthDeviceResponseDto.md
									
									
									
										generated
									
									
									
										Normal file
									
								
							
							
						
						
									
										20
									
								
								mobile/openapi/doc/AuthDeviceResponseDto.md
									
									
									
										generated
									
									
									
										Normal file
									
								
							@ -0,0 +1,20 @@
 | 
			
		||||
# openapi.model.AuthDeviceResponseDto
 | 
			
		||||
 | 
			
		||||
## Load the model package
 | 
			
		||||
```dart
 | 
			
		||||
import 'package:openapi/api.dart';
 | 
			
		||||
```
 | 
			
		||||
 | 
			
		||||
## Properties
 | 
			
		||||
Name | Type | Description | Notes
 | 
			
		||||
------------ | ------------- | ------------- | -------------
 | 
			
		||||
**id** | **String** |  | 
 | 
			
		||||
**createdAt** | **String** |  | 
 | 
			
		||||
**updatedAt** | **String** |  | 
 | 
			
		||||
**current** | **bool** |  | 
 | 
			
		||||
**deviceType** | **String** |  | 
 | 
			
		||||
**deviceOS** | **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)
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
							
								
								
									
										99
									
								
								mobile/openapi/doc/AuthenticationApi.md
									
									
									
										generated
									
									
									
								
							
							
						
						
									
										99
									
								
								mobile/openapi/doc/AuthenticationApi.md
									
									
									
										generated
									
									
									
								
							@ -11,8 +11,10 @@ Method | HTTP request | Description
 | 
			
		||||
------------- | ------------- | -------------
 | 
			
		||||
[**adminSignUp**](AuthenticationApi.md#adminsignup) | **POST** /auth/admin-sign-up | 
 | 
			
		||||
[**changePassword**](AuthenticationApi.md#changepassword) | **POST** /auth/change-password | 
 | 
			
		||||
[**getAuthDevices**](AuthenticationApi.md#getauthdevices) | **GET** /auth/devices | 
 | 
			
		||||
[**login**](AuthenticationApi.md#login) | **POST** /auth/login | 
 | 
			
		||||
[**logout**](AuthenticationApi.md#logout) | **POST** /auth/logout | 
 | 
			
		||||
[**logoutAuthDevice**](AuthenticationApi.md#logoutauthdevice) | **DELETE** /auth/devices/{id} | 
 | 
			
		||||
[**validateAccessToken**](AuthenticationApi.md#validateaccesstoken) | **POST** /auth/validateToken | 
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
@ -108,6 +110,53 @@ Name | Type | Description  | Notes
 | 
			
		||||
 | 
			
		||||
[[Back to top]](#) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to Model list]](../README.md#documentation-for-models) [[Back to README]](../README.md)
 | 
			
		||||
 | 
			
		||||
# **getAuthDevices**
 | 
			
		||||
> List<AuthDeviceResponseDto> getAuthDevices()
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
### Example
 | 
			
		||||
```dart
 | 
			
		||||
import 'package:openapi/api.dart';
 | 
			
		||||
// TODO Configure API key authorization: cookie
 | 
			
		||||
//defaultApiClient.getAuthentication<ApiKeyAuth>('cookie').apiKey = 'YOUR_API_KEY';
 | 
			
		||||
// uncomment below to setup prefix (e.g. Bearer) for API key, if needed
 | 
			
		||||
//defaultApiClient.getAuthentication<ApiKeyAuth>('cookie').apiKeyPrefix = 'Bearer';
 | 
			
		||||
// TODO Configure HTTP Bearer authorization: bearer
 | 
			
		||||
// Case 1. Use String Token
 | 
			
		||||
//defaultApiClient.getAuthentication<HttpBearerAuth>('bearer').setAccessToken('YOUR_ACCESS_TOKEN');
 | 
			
		||||
// Case 2. Use Function which generate token.
 | 
			
		||||
// String yourTokenGeneratorFunction() { ... }
 | 
			
		||||
//defaultApiClient.getAuthentication<HttpBearerAuth>('bearer').setAccessToken(yourTokenGeneratorFunction);
 | 
			
		||||
 | 
			
		||||
final api_instance = AuthenticationApi();
 | 
			
		||||
 | 
			
		||||
try {
 | 
			
		||||
    final result = api_instance.getAuthDevices();
 | 
			
		||||
    print(result);
 | 
			
		||||
} catch (e) {
 | 
			
		||||
    print('Exception when calling AuthenticationApi->getAuthDevices: $e\n');
 | 
			
		||||
}
 | 
			
		||||
```
 | 
			
		||||
 | 
			
		||||
### Parameters
 | 
			
		||||
This endpoint does not need any parameter.
 | 
			
		||||
 | 
			
		||||
### Return type
 | 
			
		||||
 | 
			
		||||
[**List<AuthDeviceResponseDto>**](AuthDeviceResponseDto.md)
 | 
			
		||||
 | 
			
		||||
### Authorization
 | 
			
		||||
 | 
			
		||||
[cookie](../README.md#cookie), [bearer](../README.md#bearer)
 | 
			
		||||
 | 
			
		||||
### 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)
 | 
			
		||||
 | 
			
		||||
# **login**
 | 
			
		||||
> LoginResponseDto login(loginCredentialDto)
 | 
			
		||||
 | 
			
		||||
@ -196,6 +245,56 @@ This endpoint does not need any parameter.
 | 
			
		||||
 | 
			
		||||
[[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)
 | 
			
		||||
 | 
			
		||||
# **logoutAuthDevice**
 | 
			
		||||
> logoutAuthDevice(id)
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
### Example
 | 
			
		||||
```dart
 | 
			
		||||
import 'package:openapi/api.dart';
 | 
			
		||||
// TODO Configure API key authorization: cookie
 | 
			
		||||
//defaultApiClient.getAuthentication<ApiKeyAuth>('cookie').apiKey = 'YOUR_API_KEY';
 | 
			
		||||
// uncomment below to setup prefix (e.g. Bearer) for API key, if needed
 | 
			
		||||
//defaultApiClient.getAuthentication<ApiKeyAuth>('cookie').apiKeyPrefix = 'Bearer';
 | 
			
		||||
// TODO Configure HTTP Bearer authorization: bearer
 | 
			
		||||
// Case 1. Use String Token
 | 
			
		||||
//defaultApiClient.getAuthentication<HttpBearerAuth>('bearer').setAccessToken('YOUR_ACCESS_TOKEN');
 | 
			
		||||
// Case 2. Use Function which generate token.
 | 
			
		||||
// String yourTokenGeneratorFunction() { ... }
 | 
			
		||||
//defaultApiClient.getAuthentication<HttpBearerAuth>('bearer').setAccessToken(yourTokenGeneratorFunction);
 | 
			
		||||
 | 
			
		||||
final api_instance = AuthenticationApi();
 | 
			
		||||
final id = 38400000-8cf0-11bd-b23e-10b96e4ef00d; // String | 
 | 
			
		||||
 | 
			
		||||
try {
 | 
			
		||||
    api_instance.logoutAuthDevice(id);
 | 
			
		||||
} catch (e) {
 | 
			
		||||
    print('Exception when calling AuthenticationApi->logoutAuthDevice: $e\n');
 | 
			
		||||
}
 | 
			
		||||
```
 | 
			
		||||
 | 
			
		||||
### Parameters
 | 
			
		||||
 | 
			
		||||
Name | Type | Description  | Notes
 | 
			
		||||
------------- | ------------- | ------------- | -------------
 | 
			
		||||
 **id** | **String**|  | 
 | 
			
		||||
 | 
			
		||||
### Return type
 | 
			
		||||
 | 
			
		||||
void (empty response body)
 | 
			
		||||
 | 
			
		||||
### Authorization
 | 
			
		||||
 | 
			
		||||
[cookie](../README.md#cookie), [bearer](../README.md#bearer)
 | 
			
		||||
 | 
			
		||||
### HTTP request headers
 | 
			
		||||
 | 
			
		||||
 - **Content-Type**: Not defined
 | 
			
		||||
 - **Accept**: Not defined
 | 
			
		||||
 | 
			
		||||
[[Back to top]](#) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to Model list]](../README.md#documentation-for-models) [[Back to README]](../README.md)
 | 
			
		||||
 | 
			
		||||
# **validateAccessToken**
 | 
			
		||||
> ValidateAccessTokenResponseDto validateAccessToken()
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
							
								
								
									
										4
									
								
								mobile/openapi/doc/LogoutResponseDto.md
									
									
									
										generated
									
									
									
								
							
							
						
						
									
										4
									
								
								mobile/openapi/doc/LogoutResponseDto.md
									
									
									
										generated
									
									
									
								
							@ -8,8 +8,8 @@ import 'package:openapi/api.dart';
 | 
			
		||||
## Properties
 | 
			
		||||
Name | Type | Description | Notes
 | 
			
		||||
------------ | ------------- | ------------- | -------------
 | 
			
		||||
**successful** | **bool** |  | [readonly] 
 | 
			
		||||
**redirectUri** | **String** |  | [readonly] 
 | 
			
		||||
**successful** | **bool** |  | 
 | 
			
		||||
**redirectUri** | **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)
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
							
								
								
									
										1
									
								
								mobile/openapi/lib/api.dart
									
									
									
										generated
									
									
									
								
							
							
						
						
									
										1
									
								
								mobile/openapi/lib/api.dart
									
									
									
										generated
									
									
									
								
							@ -59,6 +59,7 @@ part 'model/asset_count_by_user_id_response_dto.dart';
 | 
			
		||||
part 'model/asset_file_upload_response_dto.dart';
 | 
			
		||||
part 'model/asset_response_dto.dart';
 | 
			
		||||
part 'model/asset_type_enum.dart';
 | 
			
		||||
part 'model/auth_device_response_dto.dart';
 | 
			
		||||
part 'model/change_password_dto.dart';
 | 
			
		||||
part 'model/check_duplicate_asset_dto.dart';
 | 
			
		||||
part 'model/check_duplicate_asset_response_dto.dart';
 | 
			
		||||
 | 
			
		||||
							
								
								
									
										84
									
								
								mobile/openapi/lib/api/authentication_api.dart
									
									
									
										generated
									
									
									
								
							
							
						
						
									
										84
									
								
								mobile/openapi/lib/api/authentication_api.dart
									
									
									
										generated
									
									
									
								
							@ -110,6 +110,50 @@ class AuthenticationApi {
 | 
			
		||||
    return null;
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  /// Performs an HTTP 'GET /auth/devices' operation and returns the [Response].
 | 
			
		||||
  Future<Response> getAuthDevicesWithHttpInfo() async {
 | 
			
		||||
    // ignore: prefer_const_declarations
 | 
			
		||||
    final path = r'/auth/devices';
 | 
			
		||||
 | 
			
		||||
    // ignore: prefer_final_locals
 | 
			
		||||
    Object? postBody;
 | 
			
		||||
 | 
			
		||||
    final queryParams = <QueryParam>[];
 | 
			
		||||
    final headerParams = <String, String>{};
 | 
			
		||||
    final formParams = <String, String>{};
 | 
			
		||||
 | 
			
		||||
    const contentTypes = <String>[];
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
    return apiClient.invokeAPI(
 | 
			
		||||
      path,
 | 
			
		||||
      'GET',
 | 
			
		||||
      queryParams,
 | 
			
		||||
      postBody,
 | 
			
		||||
      headerParams,
 | 
			
		||||
      formParams,
 | 
			
		||||
      contentTypes.isEmpty ? null : contentTypes.first,
 | 
			
		||||
    );
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  Future<List<AuthDeviceResponseDto>?> getAuthDevices() async {
 | 
			
		||||
    final response = await getAuthDevicesWithHttpInfo();
 | 
			
		||||
    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) {
 | 
			
		||||
      final responseBody = await _decodeBodyBytes(response);
 | 
			
		||||
      return (await apiClient.deserializeAsync(responseBody, 'List<AuthDeviceResponseDto>') as List)
 | 
			
		||||
        .cast<AuthDeviceResponseDto>()
 | 
			
		||||
        .toList();
 | 
			
		||||
 | 
			
		||||
    }
 | 
			
		||||
    return null;
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  /// Performs an HTTP 'POST /auth/login' operation and returns the [Response].
 | 
			
		||||
  /// Parameters:
 | 
			
		||||
  ///
 | 
			
		||||
@ -198,6 +242,46 @@ class AuthenticationApi {
 | 
			
		||||
    return null;
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  /// Performs an HTTP 'DELETE /auth/devices/{id}' operation and returns the [Response].
 | 
			
		||||
  /// Parameters:
 | 
			
		||||
  ///
 | 
			
		||||
  /// * [String] id (required):
 | 
			
		||||
  Future<Response> logoutAuthDeviceWithHttpInfo(String id,) async {
 | 
			
		||||
    // ignore: prefer_const_declarations
 | 
			
		||||
    final path = r'/auth/devices/{id}'
 | 
			
		||||
      .replaceAll('{id}', id);
 | 
			
		||||
 | 
			
		||||
    // ignore: prefer_final_locals
 | 
			
		||||
    Object? postBody;
 | 
			
		||||
 | 
			
		||||
    final queryParams = <QueryParam>[];
 | 
			
		||||
    final headerParams = <String, String>{};
 | 
			
		||||
    final formParams = <String, String>{};
 | 
			
		||||
 | 
			
		||||
    const contentTypes = <String>[];
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
    return apiClient.invokeAPI(
 | 
			
		||||
      path,
 | 
			
		||||
      'DELETE',
 | 
			
		||||
      queryParams,
 | 
			
		||||
      postBody,
 | 
			
		||||
      headerParams,
 | 
			
		||||
      formParams,
 | 
			
		||||
      contentTypes.isEmpty ? null : contentTypes.first,
 | 
			
		||||
    );
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  /// Parameters:
 | 
			
		||||
  ///
 | 
			
		||||
  /// * [String] id (required):
 | 
			
		||||
  Future<void> logoutAuthDevice(String id,) async {
 | 
			
		||||
    final response = await logoutAuthDeviceWithHttpInfo(id,);
 | 
			
		||||
    if (response.statusCode >= HttpStatus.badRequest) {
 | 
			
		||||
      throw ApiException(response.statusCode, await _decodeBodyBytes(response));
 | 
			
		||||
    }
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  /// Performs an HTTP 'POST /auth/validateToken' operation and returns the [Response].
 | 
			
		||||
  Future<Response> validateAccessTokenWithHttpInfo() async {
 | 
			
		||||
    // ignore: prefer_const_declarations
 | 
			
		||||
 | 
			
		||||
							
								
								
									
										2
									
								
								mobile/openapi/lib/api_client.dart
									
									
									
										generated
									
									
									
								
							
							
						
						
									
										2
									
								
								mobile/openapi/lib/api_client.dart
									
									
									
										generated
									
									
									
								
							@ -215,6 +215,8 @@ class ApiClient {
 | 
			
		||||
          return AssetResponseDto.fromJson(value);
 | 
			
		||||
        case 'AssetTypeEnum':
 | 
			
		||||
          return AssetTypeEnumTypeTransformer().decode(value);
 | 
			
		||||
        case 'AuthDeviceResponseDto':
 | 
			
		||||
          return AuthDeviceResponseDto.fromJson(value);
 | 
			
		||||
        case 'ChangePasswordDto':
 | 
			
		||||
          return ChangePasswordDto.fromJson(value);
 | 
			
		||||
        case 'CheckDuplicateAssetDto':
 | 
			
		||||
 | 
			
		||||
							
								
								
									
										151
									
								
								mobile/openapi/lib/model/auth_device_response_dto.dart
									
									
									
										generated
									
									
									
										Normal file
									
								
							
							
						
						
									
										151
									
								
								mobile/openapi/lib/model/auth_device_response_dto.dart
									
									
									
										generated
									
									
									
										Normal file
									
								
							@ -0,0 +1,151 @@
 | 
			
		||||
//
 | 
			
		||||
// AUTO-GENERATED FILE, DO NOT MODIFY!
 | 
			
		||||
//
 | 
			
		||||
// @dart=2.12
 | 
			
		||||
 | 
			
		||||
// ignore_for_file: unused_element, unused_import
 | 
			
		||||
// ignore_for_file: always_put_required_named_parameters_first
 | 
			
		||||
// ignore_for_file: constant_identifier_names
 | 
			
		||||
// ignore_for_file: lines_longer_than_80_chars
 | 
			
		||||
 | 
			
		||||
part of openapi.api;
 | 
			
		||||
 | 
			
		||||
class AuthDeviceResponseDto {
 | 
			
		||||
  /// Returns a new [AuthDeviceResponseDto] instance.
 | 
			
		||||
  AuthDeviceResponseDto({
 | 
			
		||||
    required this.id,
 | 
			
		||||
    required this.createdAt,
 | 
			
		||||
    required this.updatedAt,
 | 
			
		||||
    required this.current,
 | 
			
		||||
    required this.deviceType,
 | 
			
		||||
    required this.deviceOS,
 | 
			
		||||
  });
 | 
			
		||||
 | 
			
		||||
  String id;
 | 
			
		||||
 | 
			
		||||
  String createdAt;
 | 
			
		||||
 | 
			
		||||
  String updatedAt;
 | 
			
		||||
 | 
			
		||||
  bool current;
 | 
			
		||||
 | 
			
		||||
  String deviceType;
 | 
			
		||||
 | 
			
		||||
  String deviceOS;
 | 
			
		||||
 | 
			
		||||
  @override
 | 
			
		||||
  bool operator ==(Object other) => identical(this, other) || other is AuthDeviceResponseDto &&
 | 
			
		||||
     other.id == id &&
 | 
			
		||||
     other.createdAt == createdAt &&
 | 
			
		||||
     other.updatedAt == updatedAt &&
 | 
			
		||||
     other.current == current &&
 | 
			
		||||
     other.deviceType == deviceType &&
 | 
			
		||||
     other.deviceOS == deviceOS;
 | 
			
		||||
 | 
			
		||||
  @override
 | 
			
		||||
  int get hashCode =>
 | 
			
		||||
    // ignore: unnecessary_parenthesis
 | 
			
		||||
    (id.hashCode) +
 | 
			
		||||
    (createdAt.hashCode) +
 | 
			
		||||
    (updatedAt.hashCode) +
 | 
			
		||||
    (current.hashCode) +
 | 
			
		||||
    (deviceType.hashCode) +
 | 
			
		||||
    (deviceOS.hashCode);
 | 
			
		||||
 | 
			
		||||
  @override
 | 
			
		||||
  String toString() => 'AuthDeviceResponseDto[id=$id, createdAt=$createdAt, updatedAt=$updatedAt, current=$current, deviceType=$deviceType, deviceOS=$deviceOS]';
 | 
			
		||||
 | 
			
		||||
  Map<String, dynamic> toJson() {
 | 
			
		||||
    final json = <String, dynamic>{};
 | 
			
		||||
      json[r'id'] = this.id;
 | 
			
		||||
      json[r'createdAt'] = this.createdAt;
 | 
			
		||||
      json[r'updatedAt'] = this.updatedAt;
 | 
			
		||||
      json[r'current'] = this.current;
 | 
			
		||||
      json[r'deviceType'] = this.deviceType;
 | 
			
		||||
      json[r'deviceOS'] = this.deviceOS;
 | 
			
		||||
    return json;
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  /// Returns a new [AuthDeviceResponseDto] instance and imports its values from
 | 
			
		||||
  /// [value] if it's a [Map], null otherwise.
 | 
			
		||||
  // ignore: prefer_constructors_over_static_methods
 | 
			
		||||
  static AuthDeviceResponseDto? fromJson(dynamic value) {
 | 
			
		||||
    if (value is Map) {
 | 
			
		||||
      final json = value.cast<String, dynamic>();
 | 
			
		||||
 | 
			
		||||
      // Ensure that the map contains the required keys.
 | 
			
		||||
      // Note 1: the values aren't checked for validity beyond being non-null.
 | 
			
		||||
      // Note 2: this code is stripped in release mode!
 | 
			
		||||
      assert(() {
 | 
			
		||||
        requiredKeys.forEach((key) {
 | 
			
		||||
          assert(json.containsKey(key), 'Required key "AuthDeviceResponseDto[$key]" is missing from JSON.');
 | 
			
		||||
          assert(json[key] != null, 'Required key "AuthDeviceResponseDto[$key]" has a null value in JSON.');
 | 
			
		||||
        });
 | 
			
		||||
        return true;
 | 
			
		||||
      }());
 | 
			
		||||
 | 
			
		||||
      return AuthDeviceResponseDto(
 | 
			
		||||
        id: mapValueOfType<String>(json, r'id')!,
 | 
			
		||||
        createdAt: mapValueOfType<String>(json, r'createdAt')!,
 | 
			
		||||
        updatedAt: mapValueOfType<String>(json, r'updatedAt')!,
 | 
			
		||||
        current: mapValueOfType<bool>(json, r'current')!,
 | 
			
		||||
        deviceType: mapValueOfType<String>(json, r'deviceType')!,
 | 
			
		||||
        deviceOS: mapValueOfType<String>(json, r'deviceOS')!,
 | 
			
		||||
      );
 | 
			
		||||
    }
 | 
			
		||||
    return null;
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  static List<AuthDeviceResponseDto>? listFromJson(dynamic json, {bool growable = false,}) {
 | 
			
		||||
    final result = <AuthDeviceResponseDto>[];
 | 
			
		||||
    if (json is List && json.isNotEmpty) {
 | 
			
		||||
      for (final row in json) {
 | 
			
		||||
        final value = AuthDeviceResponseDto.fromJson(row);
 | 
			
		||||
        if (value != null) {
 | 
			
		||||
          result.add(value);
 | 
			
		||||
        }
 | 
			
		||||
      }
 | 
			
		||||
    }
 | 
			
		||||
    return result.toList(growable: growable);
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  static Map<String, AuthDeviceResponseDto> mapFromJson(dynamic json) {
 | 
			
		||||
    final map = <String, AuthDeviceResponseDto>{};
 | 
			
		||||
    if (json is Map && json.isNotEmpty) {
 | 
			
		||||
      json = json.cast<String, dynamic>(); // ignore: parameter_assignments
 | 
			
		||||
      for (final entry in json.entries) {
 | 
			
		||||
        final value = AuthDeviceResponseDto.fromJson(entry.value);
 | 
			
		||||
        if (value != null) {
 | 
			
		||||
          map[entry.key] = value;
 | 
			
		||||
        }
 | 
			
		||||
      }
 | 
			
		||||
    }
 | 
			
		||||
    return map;
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  // maps a json object with a list of AuthDeviceResponseDto-objects as value to a dart map
 | 
			
		||||
  static Map<String, List<AuthDeviceResponseDto>> mapListFromJson(dynamic json, {bool growable = false,}) {
 | 
			
		||||
    final map = <String, List<AuthDeviceResponseDto>>{};
 | 
			
		||||
    if (json is Map && json.isNotEmpty) {
 | 
			
		||||
      json = json.cast<String, dynamic>(); // ignore: parameter_assignments
 | 
			
		||||
      for (final entry in json.entries) {
 | 
			
		||||
        final value = AuthDeviceResponseDto.listFromJson(entry.value, growable: growable,);
 | 
			
		||||
        if (value != null) {
 | 
			
		||||
          map[entry.key] = value;
 | 
			
		||||
        }
 | 
			
		||||
      }
 | 
			
		||||
    }
 | 
			
		||||
    return map;
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  /// The list of required keys that must be present in a JSON.
 | 
			
		||||
  static const requiredKeys = <String>{
 | 
			
		||||
    'id',
 | 
			
		||||
    'createdAt',
 | 
			
		||||
    'updatedAt',
 | 
			
		||||
    'current',
 | 
			
		||||
    'deviceType',
 | 
			
		||||
    'deviceOS',
 | 
			
		||||
  };
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
							
								
								
									
										52
									
								
								mobile/openapi/test/auth_device_response_dto_test.dart
									
									
									
										generated
									
									
									
										Normal file
									
								
							
							
						
						
									
										52
									
								
								mobile/openapi/test/auth_device_response_dto_test.dart
									
									
									
										generated
									
									
									
										Normal file
									
								
							@ -0,0 +1,52 @@
 | 
			
		||||
//
 | 
			
		||||
// AUTO-GENERATED FILE, DO NOT MODIFY!
 | 
			
		||||
//
 | 
			
		||||
// @dart=2.12
 | 
			
		||||
 | 
			
		||||
// ignore_for_file: unused_element, unused_import
 | 
			
		||||
// ignore_for_file: always_put_required_named_parameters_first
 | 
			
		||||
// ignore_for_file: constant_identifier_names
 | 
			
		||||
// ignore_for_file: lines_longer_than_80_chars
 | 
			
		||||
 | 
			
		||||
import 'package:openapi/api.dart';
 | 
			
		||||
import 'package:test/test.dart';
 | 
			
		||||
 | 
			
		||||
// tests for AuthDeviceResponseDto
 | 
			
		||||
void main() {
 | 
			
		||||
  // final instance = AuthDeviceResponseDto();
 | 
			
		||||
 | 
			
		||||
  group('test AuthDeviceResponseDto', () {
 | 
			
		||||
    // String id
 | 
			
		||||
    test('to test the property `id`', () async {
 | 
			
		||||
      // TODO
 | 
			
		||||
    });
 | 
			
		||||
 | 
			
		||||
    // String createdAt
 | 
			
		||||
    test('to test the property `createdAt`', () async {
 | 
			
		||||
      // TODO
 | 
			
		||||
    });
 | 
			
		||||
 | 
			
		||||
    // String updatedAt
 | 
			
		||||
    test('to test the property `updatedAt`', () async {
 | 
			
		||||
      // TODO
 | 
			
		||||
    });
 | 
			
		||||
 | 
			
		||||
    // bool current
 | 
			
		||||
    test('to test the property `current`', () async {
 | 
			
		||||
      // TODO
 | 
			
		||||
    });
 | 
			
		||||
 | 
			
		||||
    // String deviceType
 | 
			
		||||
    test('to test the property `deviceType`', () async {
 | 
			
		||||
      // TODO
 | 
			
		||||
    });
 | 
			
		||||
 | 
			
		||||
    // String deviceOS
 | 
			
		||||
    test('to test the property `deviceOS`', () async {
 | 
			
		||||
      // TODO
 | 
			
		||||
    });
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
  });
 | 
			
		||||
 | 
			
		||||
}
 | 
			
		||||
							
								
								
									
										10
									
								
								mobile/openapi/test/authentication_api_test.dart
									
									
									
										generated
									
									
									
								
							
							
						
						
									
										10
									
								
								mobile/openapi/test/authentication_api_test.dart
									
									
									
										generated
									
									
									
								
							@ -27,6 +27,11 @@ void main() {
 | 
			
		||||
      // TODO
 | 
			
		||||
    });
 | 
			
		||||
 | 
			
		||||
    //Future<List<AuthDeviceResponseDto>> getAuthDevices() async
 | 
			
		||||
    test('test getAuthDevices', () async {
 | 
			
		||||
      // TODO
 | 
			
		||||
    });
 | 
			
		||||
 | 
			
		||||
    //Future<LoginResponseDto> login(LoginCredentialDto loginCredentialDto) async
 | 
			
		||||
    test('test login', () async {
 | 
			
		||||
      // TODO
 | 
			
		||||
@ -37,6 +42,11 @@ void main() {
 | 
			
		||||
      // TODO
 | 
			
		||||
    });
 | 
			
		||||
 | 
			
		||||
    //Future logoutAuthDevice(String id) async
 | 
			
		||||
    test('test logoutAuthDevice', () async {
 | 
			
		||||
      // TODO
 | 
			
		||||
    });
 | 
			
		||||
 | 
			
		||||
    //Future<ValidateAccessTokenResponseDto> validateAccessToken() async
 | 
			
		||||
    test('test validateAccessToken', () async {
 | 
			
		||||
      // TODO
 | 
			
		||||
 | 
			
		||||
@ -1,5 +1,6 @@
 | 
			
		||||
import {
 | 
			
		||||
  AdminSignupResponseDto,
 | 
			
		||||
  AuthDeviceResponseDto,
 | 
			
		||||
  AuthService,
 | 
			
		||||
  AuthType,
 | 
			
		||||
  AuthUserDto,
 | 
			
		||||
@ -7,18 +8,20 @@ import {
 | 
			
		||||
  IMMICH_ACCESS_COOKIE,
 | 
			
		||||
  IMMICH_AUTH_TYPE_COOKIE,
 | 
			
		||||
  LoginCredentialDto,
 | 
			
		||||
  LoginDetails,
 | 
			
		||||
  LoginResponseDto,
 | 
			
		||||
  LogoutResponseDto,
 | 
			
		||||
  SignUpDto,
 | 
			
		||||
  UserResponseDto,
 | 
			
		||||
  ValidateAccessTokenResponseDto,
 | 
			
		||||
} from '@app/domain';
 | 
			
		||||
import { Body, Controller, Ip, Post, Req, Res } from '@nestjs/common';
 | 
			
		||||
import { Body, Controller, Delete, Get, Param, Post, Req, Res } from '@nestjs/common';
 | 
			
		||||
import { ApiBadRequestResponse, ApiTags } from '@nestjs/swagger';
 | 
			
		||||
import { Request, Response } from 'express';
 | 
			
		||||
import { GetAuthUser } from '../decorators/auth-user.decorator';
 | 
			
		||||
import { GetAuthUser, GetLoginDetails } from '../decorators/auth-user.decorator';
 | 
			
		||||
import { Authenticated } from '../decorators/authenticated.decorator';
 | 
			
		||||
import { UseValidation } from '../decorators/use-validation.decorator';
 | 
			
		||||
import { UUIDParamDto } from './dto/uuid-param.dto';
 | 
			
		||||
 | 
			
		||||
@ApiTags('Authentication')
 | 
			
		||||
@Controller('auth')
 | 
			
		||||
@ -29,11 +32,10 @@ export class AuthController {
 | 
			
		||||
  @Post('login')
 | 
			
		||||
  async login(
 | 
			
		||||
    @Body() loginCredential: LoginCredentialDto,
 | 
			
		||||
    @Ip() clientIp: string,
 | 
			
		||||
    @Req() req: Request,
 | 
			
		||||
    @Res({ passthrough: true }) res: Response,
 | 
			
		||||
    @GetLoginDetails() loginDetails: LoginDetails,
 | 
			
		||||
  ): Promise<LoginResponseDto> {
 | 
			
		||||
    const { response, cookie } = await this.service.login(loginCredential, clientIp, req.secure);
 | 
			
		||||
    const { response, cookie } = await this.service.login(loginCredential, loginDetails);
 | 
			
		||||
    res.header('Set-Cookie', cookie);
 | 
			
		||||
    return response;
 | 
			
		||||
  }
 | 
			
		||||
@ -44,6 +46,18 @@ export class AuthController {
 | 
			
		||||
    return this.service.adminSignUp(signUpCredential);
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  @Authenticated()
 | 
			
		||||
  @Get('devices')
 | 
			
		||||
  getAuthDevices(@GetAuthUser() authUser: AuthUserDto): Promise<AuthDeviceResponseDto[]> {
 | 
			
		||||
    return this.service.getDevices(authUser);
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  @Authenticated()
 | 
			
		||||
  @Delete('devices/:id')
 | 
			
		||||
  logoutAuthDevice(@GetAuthUser() authUser: AuthUserDto, @Param() { id }: UUIDParamDto): Promise<void> {
 | 
			
		||||
    return this.service.logoutDevice(authUser, id);
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  @Authenticated()
 | 
			
		||||
  @Post('validateToken')
 | 
			
		||||
  validateAccessToken(): ValidateAccessTokenResponseDto {
 | 
			
		||||
 | 
			
		||||
@ -1,5 +1,6 @@
 | 
			
		||||
import {
 | 
			
		||||
  AuthUserDto,
 | 
			
		||||
  LoginDetails,
 | 
			
		||||
  LoginResponseDto,
 | 
			
		||||
  OAuthCallbackDto,
 | 
			
		||||
  OAuthConfigDto,
 | 
			
		||||
@ -10,7 +11,7 @@ import {
 | 
			
		||||
import { Body, Controller, Get, HttpStatus, Post, Redirect, Req, Res } from '@nestjs/common';
 | 
			
		||||
import { ApiTags } from '@nestjs/swagger';
 | 
			
		||||
import { Request, Response } from 'express';
 | 
			
		||||
import { GetAuthUser } from '../decorators/auth-user.decorator';
 | 
			
		||||
import { GetAuthUser, GetLoginDetails } from '../decorators/auth-user.decorator';
 | 
			
		||||
import { Authenticated } from '../decorators/authenticated.decorator';
 | 
			
		||||
import { UseValidation } from '../decorators/use-validation.decorator';
 | 
			
		||||
 | 
			
		||||
@ -38,9 +39,9 @@ export class OAuthController {
 | 
			
		||||
  async callback(
 | 
			
		||||
    @Res({ passthrough: true }) res: Response,
 | 
			
		||||
    @Body() dto: OAuthCallbackDto,
 | 
			
		||||
    @Req() req: Request,
 | 
			
		||||
    @GetLoginDetails() loginDetails: LoginDetails,
 | 
			
		||||
  ): Promise<LoginResponseDto> {
 | 
			
		||||
    const { response, cookie } = await this.service.login(dto, req.secure);
 | 
			
		||||
    const { response, cookie } = await this.service.login(dto, loginDetails);
 | 
			
		||||
    res.header('Set-Cookie', cookie);
 | 
			
		||||
    return response;
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
@ -1,7 +1,20 @@
 | 
			
		||||
export { AuthUserDto } from '@app/domain';
 | 
			
		||||
import { AuthUserDto } from '@app/domain';
 | 
			
		||||
import { AuthUserDto, LoginDetails } from '@app/domain';
 | 
			
		||||
import { createParamDecorator, ExecutionContext } from '@nestjs/common';
 | 
			
		||||
import { UAParser } from 'ua-parser-js';
 | 
			
		||||
 | 
			
		||||
export const GetAuthUser = createParamDecorator((data, ctx: ExecutionContext): AuthUserDto => {
 | 
			
		||||
  return ctx.switchToHttp().getRequest<{ user: AuthUserDto }>().user;
 | 
			
		||||
});
 | 
			
		||||
 | 
			
		||||
export const GetLoginDetails = createParamDecorator((data, ctx: ExecutionContext): LoginDetails => {
 | 
			
		||||
  const req = ctx.switchToHttp().getRequest();
 | 
			
		||||
  const userAgent = UAParser(req.headers['user-agent']);
 | 
			
		||||
 | 
			
		||||
  return {
 | 
			
		||||
    clientIp: req.clientIp,
 | 
			
		||||
    isSecure: req.secure,
 | 
			
		||||
    deviceType: userAgent.browser.name || userAgent.device.type || req.headers.devicemodel || '',
 | 
			
		||||
    deviceOS: userAgent.os.name || req.headers.devicetype || '',
 | 
			
		||||
  };
 | 
			
		||||
});
 | 
			
		||||
 | 
			
		||||
@ -21,6 +21,10 @@ export function patchOpenAPI(document: OpenAPIObject) {
 | 
			
		||||
      if (operation.summary === '') {
 | 
			
		||||
        delete operation.summary;
 | 
			
		||||
      }
 | 
			
		||||
 | 
			
		||||
      if (operation.description === '') {
 | 
			
		||||
        delete operation.description;
 | 
			
		||||
      }
 | 
			
		||||
    }
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
@ -339,6 +339,70 @@
 | 
			
		||||
        ]
 | 
			
		||||
      }
 | 
			
		||||
    },
 | 
			
		||||
    "/auth/devices": {
 | 
			
		||||
      "get": {
 | 
			
		||||
        "operationId": "getAuthDevices",
 | 
			
		||||
        "parameters": [],
 | 
			
		||||
        "responses": {
 | 
			
		||||
          "200": {
 | 
			
		||||
            "description": "",
 | 
			
		||||
            "content": {
 | 
			
		||||
              "application/json": {
 | 
			
		||||
                "schema": {
 | 
			
		||||
                  "type": "array",
 | 
			
		||||
                  "items": {
 | 
			
		||||
                    "$ref": "#/components/schemas/AuthDeviceResponseDto"
 | 
			
		||||
                  }
 | 
			
		||||
                }
 | 
			
		||||
              }
 | 
			
		||||
            }
 | 
			
		||||
          }
 | 
			
		||||
        },
 | 
			
		||||
        "tags": [
 | 
			
		||||
          "Authentication"
 | 
			
		||||
        ],
 | 
			
		||||
        "security": [
 | 
			
		||||
          {
 | 
			
		||||
            "bearer": []
 | 
			
		||||
          },
 | 
			
		||||
          {
 | 
			
		||||
            "cookie": []
 | 
			
		||||
          }
 | 
			
		||||
        ]
 | 
			
		||||
      }
 | 
			
		||||
    },
 | 
			
		||||
    "/auth/devices/{id}": {
 | 
			
		||||
      "delete": {
 | 
			
		||||
        "operationId": "logoutAuthDevice",
 | 
			
		||||
        "parameters": [
 | 
			
		||||
          {
 | 
			
		||||
            "name": "id",
 | 
			
		||||
            "required": true,
 | 
			
		||||
            "in": "path",
 | 
			
		||||
            "schema": {
 | 
			
		||||
              "format": "uuid",
 | 
			
		||||
              "type": "string"
 | 
			
		||||
            }
 | 
			
		||||
          }
 | 
			
		||||
        ],
 | 
			
		||||
        "responses": {
 | 
			
		||||
          "200": {
 | 
			
		||||
            "description": ""
 | 
			
		||||
          }
 | 
			
		||||
        },
 | 
			
		||||
        "tags": [
 | 
			
		||||
          "Authentication"
 | 
			
		||||
        ],
 | 
			
		||||
        "security": [
 | 
			
		||||
          {
 | 
			
		||||
            "bearer": []
 | 
			
		||||
          },
 | 
			
		||||
          {
 | 
			
		||||
            "cookie": []
 | 
			
		||||
          }
 | 
			
		||||
        ]
 | 
			
		||||
      }
 | 
			
		||||
    },
 | 
			
		||||
    "/auth/validateToken": {
 | 
			
		||||
      "post": {
 | 
			
		||||
        "operationId": "validateAccessToken",
 | 
			
		||||
@ -3986,6 +4050,37 @@
 | 
			
		||||
          "createdAt"
 | 
			
		||||
        ]
 | 
			
		||||
      },
 | 
			
		||||
      "AuthDeviceResponseDto": {
 | 
			
		||||
        "type": "object",
 | 
			
		||||
        "properties": {
 | 
			
		||||
          "id": {
 | 
			
		||||
            "type": "string"
 | 
			
		||||
          },
 | 
			
		||||
          "createdAt": {
 | 
			
		||||
            "type": "string"
 | 
			
		||||
          },
 | 
			
		||||
          "updatedAt": {
 | 
			
		||||
            "type": "string"
 | 
			
		||||
          },
 | 
			
		||||
          "current": {
 | 
			
		||||
            "type": "boolean"
 | 
			
		||||
          },
 | 
			
		||||
          "deviceType": {
 | 
			
		||||
            "type": "string"
 | 
			
		||||
          },
 | 
			
		||||
          "deviceOS": {
 | 
			
		||||
            "type": "string"
 | 
			
		||||
          }
 | 
			
		||||
        },
 | 
			
		||||
        "required": [
 | 
			
		||||
          "id",
 | 
			
		||||
          "createdAt",
 | 
			
		||||
          "updatedAt",
 | 
			
		||||
          "current",
 | 
			
		||||
          "deviceType",
 | 
			
		||||
          "deviceOS"
 | 
			
		||||
        ]
 | 
			
		||||
      },
 | 
			
		||||
      "ValidateAccessTokenResponseDto": {
 | 
			
		||||
        "type": "object",
 | 
			
		||||
        "properties": {
 | 
			
		||||
@ -4018,12 +4113,10 @@
 | 
			
		||||
        "type": "object",
 | 
			
		||||
        "properties": {
 | 
			
		||||
          "successful": {
 | 
			
		||||
            "type": "boolean",
 | 
			
		||||
            "readOnly": true
 | 
			
		||||
            "type": "boolean"
 | 
			
		||||
          },
 | 
			
		||||
          "redirectUri": {
 | 
			
		||||
            "type": "string",
 | 
			
		||||
            "readOnly": true
 | 
			
		||||
            "type": "string"
 | 
			
		||||
          }
 | 
			
		||||
        },
 | 
			
		||||
        "required": [
 | 
			
		||||
 | 
			
		||||
@ -1,10 +1,17 @@
 | 
			
		||||
import { SystemConfig, UserEntity } from '@app/infra/entities';
 | 
			
		||||
import { ICryptoRepository } from '../crypto/crypto.repository';
 | 
			
		||||
import { ISystemConfigRepository } from '../system-config';
 | 
			
		||||
import { SystemConfigCore } from '../system-config/system-config.core';
 | 
			
		||||
import { AuthType, IMMICH_ACCESS_COOKIE, IMMICH_AUTH_TYPE_COOKIE } from './auth.constant';
 | 
			
		||||
import { ICryptoRepository } from '../crypto/crypto.repository';
 | 
			
		||||
import { LoginResponseDto, mapLoginResponse } from './response-dto';
 | 
			
		||||
import { IUserTokenRepository, UserTokenCore } from '../user-token';
 | 
			
		||||
import { AuthType, IMMICH_ACCESS_COOKIE, IMMICH_AUTH_TYPE_COOKIE } from './auth.constant';
 | 
			
		||||
import { LoginResponseDto, mapLoginResponse } from './response-dto';
 | 
			
		||||
 | 
			
		||||
export interface LoginDetails {
 | 
			
		||||
  isSecure: boolean;
 | 
			
		||||
  clientIp: string;
 | 
			
		||||
  deviceType: string;
 | 
			
		||||
  deviceOS: string;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export class AuthCore {
 | 
			
		||||
  private userTokenCore: UserTokenCore;
 | 
			
		||||
@ -23,7 +30,7 @@ export class AuthCore {
 | 
			
		||||
    return this.config.passwordLogin.enabled;
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  public getCookies(loginResponse: LoginResponseDto, authType: AuthType, isSecure: boolean) {
 | 
			
		||||
  getCookies(loginResponse: LoginResponseDto, authType: AuthType, { isSecure }: LoginDetails) {
 | 
			
		||||
    const maxAge = 400 * 24 * 3600; // 400 days
 | 
			
		||||
 | 
			
		||||
    let authTypeCookie = '';
 | 
			
		||||
@ -39,10 +46,10 @@ export class AuthCore {
 | 
			
		||||
    return [accessTokenCookie, authTypeCookie];
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  public async createLoginResponse(user: UserEntity, authType: AuthType, isSecure: boolean) {
 | 
			
		||||
    const accessToken = await this.userTokenCore.createToken(user);
 | 
			
		||||
  async createLoginResponse(user: UserEntity, authType: AuthType, loginDetails: LoginDetails) {
 | 
			
		||||
    const accessToken = await this.userTokenCore.create(user, loginDetails);
 | 
			
		||||
    const response = mapLoginResponse(user, accessToken);
 | 
			
		||||
    const cookie = this.getCookies(response, authType, isSecure);
 | 
			
		||||
    const cookie = this.getCookies(response, authType, loginDetails);
 | 
			
		||||
    return { response, cookie };
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
@ -32,6 +32,12 @@ import { AuthUserDto, SignUpDto } from './dto';
 | 
			
		||||
 | 
			
		||||
const email = 'test@immich.com';
 | 
			
		||||
const sub = 'my-auth-user-sub';
 | 
			
		||||
const loginDetails = {
 | 
			
		||||
  isSecure: true,
 | 
			
		||||
  clientIp: '127.0.0.1',
 | 
			
		||||
  deviceOS: '',
 | 
			
		||||
  deviceType: '',
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
const fixtures = {
 | 
			
		||||
  login: {
 | 
			
		||||
@ -40,8 +46,6 @@ const fixtures = {
 | 
			
		||||
  },
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
const CLIENT_IP = '127.0.0.1';
 | 
			
		||||
 | 
			
		||||
describe('AuthService', () => {
 | 
			
		||||
  let sut: AuthService;
 | 
			
		||||
  let cryptoMock: jest.Mocked<ICryptoRepository>;
 | 
			
		||||
@ -96,32 +100,39 @@ describe('AuthService', () => {
 | 
			
		||||
    it('should throw an error if password login is disabled', async () => {
 | 
			
		||||
      sut = create(systemConfigStub.disabled);
 | 
			
		||||
 | 
			
		||||
      await expect(sut.login(fixtures.login, CLIENT_IP, true)).rejects.toBeInstanceOf(UnauthorizedException);
 | 
			
		||||
      await expect(sut.login(fixtures.login, loginDetails)).rejects.toBeInstanceOf(UnauthorizedException);
 | 
			
		||||
    });
 | 
			
		||||
 | 
			
		||||
    it('should check the user exists', async () => {
 | 
			
		||||
      userMock.getByEmail.mockResolvedValue(null);
 | 
			
		||||
      await expect(sut.login(fixtures.login, CLIENT_IP, true)).rejects.toBeInstanceOf(BadRequestException);
 | 
			
		||||
      await expect(sut.login(fixtures.login, loginDetails)).rejects.toBeInstanceOf(BadRequestException);
 | 
			
		||||
      expect(userMock.getByEmail).toHaveBeenCalledTimes(1);
 | 
			
		||||
    });
 | 
			
		||||
 | 
			
		||||
    it('should check the user has a password', async () => {
 | 
			
		||||
      userMock.getByEmail.mockResolvedValue({} as UserEntity);
 | 
			
		||||
      await expect(sut.login(fixtures.login, CLIENT_IP, true)).rejects.toBeInstanceOf(BadRequestException);
 | 
			
		||||
      await expect(sut.login(fixtures.login, loginDetails)).rejects.toBeInstanceOf(BadRequestException);
 | 
			
		||||
      expect(userMock.getByEmail).toHaveBeenCalledTimes(1);
 | 
			
		||||
    });
 | 
			
		||||
 | 
			
		||||
    it('should successfully log the user in', async () => {
 | 
			
		||||
      userMock.getByEmail.mockResolvedValue(userEntityStub.user1);
 | 
			
		||||
      userTokenMock.create.mockResolvedValue(userTokenEntityStub.userToken);
 | 
			
		||||
      await expect(sut.login(fixtures.login, CLIENT_IP, true)).resolves.toEqual(loginResponseStub.user1password);
 | 
			
		||||
      await expect(sut.login(fixtures.login, loginDetails)).resolves.toEqual(loginResponseStub.user1password);
 | 
			
		||||
      expect(userMock.getByEmail).toHaveBeenCalledTimes(1);
 | 
			
		||||
    });
 | 
			
		||||
 | 
			
		||||
    it('should generate the cookie headers (insecure)', async () => {
 | 
			
		||||
      userMock.getByEmail.mockResolvedValue(userEntityStub.user1);
 | 
			
		||||
      userTokenMock.create.mockResolvedValue(userTokenEntityStub.userToken);
 | 
			
		||||
      await expect(sut.login(fixtures.login, CLIENT_IP, false)).resolves.toEqual(loginResponseStub.user1insecure);
 | 
			
		||||
      await expect(
 | 
			
		||||
        sut.login(fixtures.login, {
 | 
			
		||||
          clientIp: '127.0.0.1',
 | 
			
		||||
          isSecure: false,
 | 
			
		||||
          deviceOS: '',
 | 
			
		||||
          deviceType: '',
 | 
			
		||||
        }),
 | 
			
		||||
      ).resolves.toEqual(loginResponseStub.user1insecure);
 | 
			
		||||
      expect(userMock.getByEmail).toHaveBeenCalledTimes(1);
 | 
			
		||||
    });
 | 
			
		||||
  });
 | 
			
		||||
@ -205,7 +216,7 @@ describe('AuthService', () => {
 | 
			
		||||
        redirectUri: '/auth/login?autoLaunch=0',
 | 
			
		||||
      });
 | 
			
		||||
 | 
			
		||||
      expect(userTokenMock.delete).toHaveBeenCalledWith('token123');
 | 
			
		||||
      expect(userTokenMock.delete).toHaveBeenCalledWith('123', 'token123');
 | 
			
		||||
    });
 | 
			
		||||
  });
 | 
			
		||||
 | 
			
		||||
@ -240,7 +251,7 @@ describe('AuthService', () => {
 | 
			
		||||
 | 
			
		||||
    it('should validate using authorization header', async () => {
 | 
			
		||||
      userMock.get.mockResolvedValue(userEntityStub.user1);
 | 
			
		||||
      userTokenMock.get.mockResolvedValue(userTokenEntityStub.userToken);
 | 
			
		||||
      userTokenMock.getByToken.mockResolvedValue(userTokenEntityStub.userToken);
 | 
			
		||||
      const client = { request: { headers: { authorization: 'Bearer auth_token' } } };
 | 
			
		||||
      await expect(sut.validate((client as Socket).request.headers, {})).resolves.toEqual(userEntityStub.user1);
 | 
			
		||||
    });
 | 
			
		||||
@ -276,16 +287,32 @@ describe('AuthService', () => {
 | 
			
		||||
 | 
			
		||||
  describe('validate - user token', () => {
 | 
			
		||||
    it('should throw if no token is found', async () => {
 | 
			
		||||
      userTokenMock.get.mockResolvedValue(null);
 | 
			
		||||
      userTokenMock.getByToken.mockResolvedValue(null);
 | 
			
		||||
      const headers: IncomingHttpHeaders = { 'x-immich-user-token': 'auth_token' };
 | 
			
		||||
      await expect(sut.validate(headers, {})).rejects.toBeInstanceOf(UnauthorizedException);
 | 
			
		||||
    });
 | 
			
		||||
 | 
			
		||||
    it('should return an auth dto', async () => {
 | 
			
		||||
      userTokenMock.get.mockResolvedValue(userTokenEntityStub.userToken);
 | 
			
		||||
      userTokenMock.getByToken.mockResolvedValue(userTokenEntityStub.userToken);
 | 
			
		||||
      const headers: IncomingHttpHeaders = { cookie: 'immich_access_token=auth_token' };
 | 
			
		||||
      await expect(sut.validate(headers, {})).resolves.toEqual(userEntityStub.user1);
 | 
			
		||||
    });
 | 
			
		||||
 | 
			
		||||
    it('should update when access time exceeds an hour', async () => {
 | 
			
		||||
      userTokenMock.getByToken.mockResolvedValue(userTokenEntityStub.inactiveToken);
 | 
			
		||||
      userTokenMock.save.mockResolvedValue(userTokenEntityStub.userToken);
 | 
			
		||||
      const headers: IncomingHttpHeaders = { cookie: 'immich_access_token=auth_token' };
 | 
			
		||||
      await expect(sut.validate(headers, {})).resolves.toEqual(userEntityStub.user1);
 | 
			
		||||
      expect(userTokenMock.save.mock.calls[0][0]).toMatchObject({
 | 
			
		||||
        id: 'not_active',
 | 
			
		||||
        token: 'auth_token',
 | 
			
		||||
        userId: 'immich_id',
 | 
			
		||||
        createdAt: new Date('2021-01-01'),
 | 
			
		||||
        updatedAt: expect.any(Date),
 | 
			
		||||
        deviceOS: 'Android',
 | 
			
		||||
        deviceType: 'Mobile',
 | 
			
		||||
      });
 | 
			
		||||
    });
 | 
			
		||||
  });
 | 
			
		||||
 | 
			
		||||
  describe('validate - api key', () => {
 | 
			
		||||
@ -303,4 +330,38 @@ describe('AuthService', () => {
 | 
			
		||||
      expect(keyMock.getKey).toHaveBeenCalledWith('auth_token (hashed)');
 | 
			
		||||
    });
 | 
			
		||||
  });
 | 
			
		||||
 | 
			
		||||
  describe('getDevices', () => {
 | 
			
		||||
    it('should get the devices', async () => {
 | 
			
		||||
      userTokenMock.getAll.mockResolvedValue([userTokenEntityStub.userToken, userTokenEntityStub.inactiveToken]);
 | 
			
		||||
      await expect(sut.getDevices(authStub.user1)).resolves.toEqual([
 | 
			
		||||
        {
 | 
			
		||||
          createdAt: '2021-01-01T00:00:00.000Z',
 | 
			
		||||
          current: true,
 | 
			
		||||
          deviceOS: '',
 | 
			
		||||
          deviceType: '',
 | 
			
		||||
          id: 'token-id',
 | 
			
		||||
          updatedAt: expect.any(String),
 | 
			
		||||
        },
 | 
			
		||||
        {
 | 
			
		||||
          createdAt: '2021-01-01T00:00:00.000Z',
 | 
			
		||||
          current: false,
 | 
			
		||||
          deviceOS: 'Android',
 | 
			
		||||
          deviceType: 'Mobile',
 | 
			
		||||
          id: 'not_active',
 | 
			
		||||
          updatedAt: expect.any(String),
 | 
			
		||||
        },
 | 
			
		||||
      ]);
 | 
			
		||||
 | 
			
		||||
      expect(userTokenMock.getAll).toHaveBeenCalledWith(authStub.user1.id);
 | 
			
		||||
    });
 | 
			
		||||
  });
 | 
			
		||||
 | 
			
		||||
  describe('logoutDevice', () => {
 | 
			
		||||
    it('should logout the device', async () => {
 | 
			
		||||
      await sut.logoutDevice(authStub.user1, 'token-1');
 | 
			
		||||
 | 
			
		||||
      expect(userTokenMock.delete).toHaveBeenCalledWith(authStub.user1.id, 'token-1');
 | 
			
		||||
    });
 | 
			
		||||
  });
 | 
			
		||||
});
 | 
			
		||||
 | 
			
		||||
@ -12,7 +12,7 @@ import { OAuthCore } from '../oauth/oauth.core';
 | 
			
		||||
import { INITIAL_SYSTEM_CONFIG, ISystemConfigRepository } from '../system-config';
 | 
			
		||||
import { IUserRepository, UserCore } from '../user';
 | 
			
		||||
import { AuthType, IMMICH_ACCESS_COOKIE } from './auth.constant';
 | 
			
		||||
import { AuthCore } from './auth.core';
 | 
			
		||||
import { AuthCore, LoginDetails } from './auth.core';
 | 
			
		||||
import { ICryptoRepository } from '../crypto/crypto.repository';
 | 
			
		||||
import { AuthUserDto, ChangePasswordDto, LoginCredentialDto, SignUpDto } from './dto';
 | 
			
		||||
import { AdminSignupResponseDto, LoginResponseDto, LogoutResponseDto, mapAdminSignupResponse } from './response-dto';
 | 
			
		||||
@ -21,6 +21,7 @@ import cookieParser from 'cookie';
 | 
			
		||||
import { ISharedLinkRepository, ShareCore } from '../share';
 | 
			
		||||
import { APIKeyCore } from '../api-key/api-key.core';
 | 
			
		||||
import { IKeyRepository } from '../api-key';
 | 
			
		||||
import { AuthDeviceResponseDto, mapUserToken } from './response-dto';
 | 
			
		||||
 | 
			
		||||
@Injectable()
 | 
			
		||||
export class AuthService {
 | 
			
		||||
@ -53,8 +54,7 @@ export class AuthService {
 | 
			
		||||
 | 
			
		||||
  public async login(
 | 
			
		||||
    loginCredential: LoginCredentialDto,
 | 
			
		||||
    clientIp: string,
 | 
			
		||||
    isSecure: boolean,
 | 
			
		||||
    loginDetails: LoginDetails,
 | 
			
		||||
  ): Promise<{ response: LoginResponseDto; cookie: string[] }> {
 | 
			
		||||
    if (!this.authCore.isPasswordLoginEnabled()) {
 | 
			
		||||
      throw new UnauthorizedException('Password login has been disabled');
 | 
			
		||||
@ -69,16 +69,18 @@ export class AuthService {
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    if (!user) {
 | 
			
		||||
      this.logger.warn(`Failed login attempt for user ${loginCredential.email} from ip address ${clientIp}`);
 | 
			
		||||
      this.logger.warn(
 | 
			
		||||
        `Failed login attempt for user ${loginCredential.email} from ip address ${loginDetails.clientIp}`,
 | 
			
		||||
      );
 | 
			
		||||
      throw new BadRequestException('Incorrect email or password');
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    return this.authCore.createLoginResponse(user, AuthType.PASSWORD, isSecure);
 | 
			
		||||
    return this.authCore.createLoginResponse(user, AuthType.PASSWORD, loginDetails);
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  public async logout(authUser: AuthUserDto, authType: AuthType): Promise<LogoutResponseDto> {
 | 
			
		||||
    if (authUser.accessTokenId) {
 | 
			
		||||
      await this.userTokenCore.deleteToken(authUser.accessTokenId);
 | 
			
		||||
      await this.userTokenCore.delete(authUser.id, authUser.accessTokenId);
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    if (authType === AuthType.OAUTH) {
 | 
			
		||||
@ -152,6 +154,15 @@ export class AuthService {
 | 
			
		||||
    throw new UnauthorizedException('Authentication required');
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  async getDevices(authUser: AuthUserDto): Promise<AuthDeviceResponseDto[]> {
 | 
			
		||||
    const userTokens = await this.userTokenCore.getAll(authUser.id);
 | 
			
		||||
    return userTokens.map((userToken) => mapUserToken(userToken, authUser.accessTokenId));
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  async logoutDevice(authUser: AuthUserDto, deviceId: string): Promise<void> {
 | 
			
		||||
    await this.userTokenCore.delete(authUser.id, deviceId);
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  private getBearerToken(headers: IncomingHttpHeaders): string | null {
 | 
			
		||||
    const [type, token] = (headers.authorization || '').split(' ');
 | 
			
		||||
    if (type.toLowerCase() === 'bearer') {
 | 
			
		||||
 | 
			
		||||
@ -1,4 +1,5 @@
 | 
			
		||||
export * from './auth.constant';
 | 
			
		||||
export * from './auth.core';
 | 
			
		||||
export * from './auth.service';
 | 
			
		||||
export * from './dto';
 | 
			
		||||
export * from './response-dto';
 | 
			
		||||
 | 
			
		||||
@ -0,0 +1,19 @@
 | 
			
		||||
import { UserTokenEntity } from '@app/infra/entities';
 | 
			
		||||
 | 
			
		||||
export class AuthDeviceResponseDto {
 | 
			
		||||
  id!: string;
 | 
			
		||||
  createdAt!: string;
 | 
			
		||||
  updatedAt!: string;
 | 
			
		||||
  current!: boolean;
 | 
			
		||||
  deviceType!: string;
 | 
			
		||||
  deviceOS!: string;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export const mapUserToken = (entity: UserTokenEntity, currentId?: string): AuthDeviceResponseDto => ({
 | 
			
		||||
  id: entity.id,
 | 
			
		||||
  createdAt: entity.createdAt.toISOString(),
 | 
			
		||||
  updatedAt: entity.updatedAt.toISOString(),
 | 
			
		||||
  current: currentId === entity.id,
 | 
			
		||||
  deviceOS: entity.deviceOS,
 | 
			
		||||
  deviceType: entity.deviceType,
 | 
			
		||||
});
 | 
			
		||||
@ -1,4 +1,5 @@
 | 
			
		||||
export * from './admin-signup-response.dto';
 | 
			
		||||
export * from './auth-device-response.dto';
 | 
			
		||||
export * from './login-response.dto';
 | 
			
		||||
export * from './logout-response.dto';
 | 
			
		||||
export * from './validate-asset-token-response.dto';
 | 
			
		||||
 | 
			
		||||
@ -1,13 +1,4 @@
 | 
			
		||||
import { ApiResponseProperty } from '@nestjs/swagger';
 | 
			
		||||
 | 
			
		||||
export class LogoutResponseDto {
 | 
			
		||||
  constructor(successful: boolean) {
 | 
			
		||||
    this.successful = successful;
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  @ApiResponseProperty()
 | 
			
		||||
  successful!: boolean;
 | 
			
		||||
 | 
			
		||||
  @ApiResponseProperty()
 | 
			
		||||
  redirectUri!: string;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
@ -1,10 +1,3 @@
 | 
			
		||||
import { ApiProperty } from '@nestjs/swagger';
 | 
			
		||||
 | 
			
		||||
export class ValidateAccessTokenResponseDto {
 | 
			
		||||
  constructor(authStatus: boolean) {
 | 
			
		||||
    this.authStatus = authStatus;
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  @ApiProperty({ type: 'boolean' })
 | 
			
		||||
  authStatus!: boolean;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
@ -17,9 +17,16 @@ import { ISystemConfigRepository } from '../system-config';
 | 
			
		||||
import { IUserRepository } from '../user';
 | 
			
		||||
import { IUserTokenRepository } from '../user-token';
 | 
			
		||||
import { newUserTokenRepositoryMock } from '../../test/user-token.repository.mock';
 | 
			
		||||
import { LoginDetails } from '../auth';
 | 
			
		||||
 | 
			
		||||
const email = 'user@immich.com';
 | 
			
		||||
const sub = 'my-auth-user-sub';
 | 
			
		||||
const loginDetails: LoginDetails = {
 | 
			
		||||
  isSecure: true,
 | 
			
		||||
  clientIp: '127.0.0.1',
 | 
			
		||||
  deviceOS: '',
 | 
			
		||||
  deviceType: '',
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
describe('OAuthService', () => {
 | 
			
		||||
  let sut: OAuthService;
 | 
			
		||||
@ -95,13 +102,13 @@ describe('OAuthService', () => {
 | 
			
		||||
 | 
			
		||||
  describe('login', () => {
 | 
			
		||||
    it('should throw an error if OAuth is not enabled', async () => {
 | 
			
		||||
      await expect(sut.login({ url: '' }, true)).rejects.toBeInstanceOf(BadRequestException);
 | 
			
		||||
      await expect(sut.login({ url: '' }, loginDetails)).rejects.toBeInstanceOf(BadRequestException);
 | 
			
		||||
    });
 | 
			
		||||
 | 
			
		||||
    it('should not allow auto registering', async () => {
 | 
			
		||||
      sut = create(systemConfigStub.noAutoRegister);
 | 
			
		||||
      userMock.getByEmail.mockResolvedValue(null);
 | 
			
		||||
      await expect(sut.login({ url: 'http://immich/auth/login?code=abc123' }, true)).rejects.toBeInstanceOf(
 | 
			
		||||
      await expect(sut.login({ url: 'http://immich/auth/login?code=abc123' }, loginDetails)).rejects.toBeInstanceOf(
 | 
			
		||||
        BadRequestException,
 | 
			
		||||
      );
 | 
			
		||||
      expect(userMock.getByEmail).toHaveBeenCalledTimes(1);
 | 
			
		||||
@ -113,7 +120,7 @@ describe('OAuthService', () => {
 | 
			
		||||
      userMock.update.mockResolvedValue(userEntityStub.user1);
 | 
			
		||||
      userTokenMock.create.mockResolvedValue(userTokenEntityStub.userToken);
 | 
			
		||||
 | 
			
		||||
      await expect(sut.login({ url: 'http://immich/auth/login?code=abc123' }, true)).resolves.toEqual(
 | 
			
		||||
      await expect(sut.login({ url: 'http://immich/auth/login?code=abc123' }, loginDetails)).resolves.toEqual(
 | 
			
		||||
        loginResponseStub.user1oauth,
 | 
			
		||||
      );
 | 
			
		||||
 | 
			
		||||
@ -129,7 +136,7 @@ describe('OAuthService', () => {
 | 
			
		||||
      userMock.create.mockResolvedValue(userEntityStub.user1);
 | 
			
		||||
      userTokenMock.create.mockResolvedValue(userTokenEntityStub.userToken);
 | 
			
		||||
 | 
			
		||||
      await expect(sut.login({ url: 'http://immich/auth/login?code=abc123' }, true)).resolves.toEqual(
 | 
			
		||||
      await expect(sut.login({ url: 'http://immich/auth/login?code=abc123' }, loginDetails)).resolves.toEqual(
 | 
			
		||||
        loginResponseStub.user1oauth,
 | 
			
		||||
      );
 | 
			
		||||
 | 
			
		||||
@ -143,7 +150,7 @@ describe('OAuthService', () => {
 | 
			
		||||
      userMock.getByOAuthId.mockResolvedValue(userEntityStub.user1);
 | 
			
		||||
      userTokenMock.create.mockResolvedValue(userTokenEntityStub.userToken);
 | 
			
		||||
 | 
			
		||||
      await sut.login({ url: `app.immich:/?code=abc123` }, true);
 | 
			
		||||
      await sut.login({ url: `app.immich:/?code=abc123` }, loginDetails);
 | 
			
		||||
 | 
			
		||||
      expect(callbackMock).toHaveBeenCalledWith('http://mobile-redirect', { state: 'state' }, { state: 'state' });
 | 
			
		||||
    });
 | 
			
		||||
 | 
			
		||||
@ -1,7 +1,7 @@
 | 
			
		||||
import { SystemConfig } from '@app/infra/entities';
 | 
			
		||||
import { BadRequestException, Inject, Injectable, Logger } from '@nestjs/common';
 | 
			
		||||
import { AuthType, AuthUserDto, LoginResponseDto } from '../auth';
 | 
			
		||||
import { AuthCore } from '../auth/auth.core';
 | 
			
		||||
import { AuthCore, LoginDetails } from '../auth/auth.core';
 | 
			
		||||
import { ICryptoRepository } from '../crypto';
 | 
			
		||||
import { INITIAL_SYSTEM_CONFIG, ISystemConfigRepository } from '../system-config';
 | 
			
		||||
import { IUserRepository, UserCore, UserResponseDto } from '../user';
 | 
			
		||||
@ -39,7 +39,10 @@ export class OAuthService {
 | 
			
		||||
    return this.oauthCore.generateConfig(dto);
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  async login(dto: OAuthCallbackDto, isSecure: boolean): Promise<{ response: LoginResponseDto; cookie: string[] }> {
 | 
			
		||||
  async login(
 | 
			
		||||
    dto: OAuthCallbackDto,
 | 
			
		||||
    loginDetails: LoginDetails,
 | 
			
		||||
  ): Promise<{ response: LoginResponseDto; cookie: string[] }> {
 | 
			
		||||
    const profile = await this.oauthCore.callback(dto.url);
 | 
			
		||||
 | 
			
		||||
    this.logger.debug(`Logging in with OAuth: ${JSON.stringify(profile)}`);
 | 
			
		||||
@ -66,7 +69,7 @@ export class OAuthService {
 | 
			
		||||
      user = await this.userCore.createUser(this.oauthCore.asUser(profile));
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    return this.authCore.createLoginResponse(user, AuthType.OAUTH, isSecure);
 | 
			
		||||
    return this.authCore.createLoginResponse(user, AuthType.OAUTH, loginDetails);
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  public async link(user: AuthUserDto, dto: OAuthCallbackDto): Promise<UserResponseDto> {
 | 
			
		||||
 | 
			
		||||
@ -1,5 +1,7 @@
 | 
			
		||||
import { UserEntity } from '@app/infra/entities';
 | 
			
		||||
import { UserEntity, UserTokenEntity } from '@app/infra/entities';
 | 
			
		||||
import { Injectable, UnauthorizedException } from '@nestjs/common';
 | 
			
		||||
import { DateTime } from 'luxon';
 | 
			
		||||
import { LoginDetails } from '../auth';
 | 
			
		||||
import { ICryptoRepository } from '../crypto';
 | 
			
		||||
import { IUserTokenRepository } from './user-token.repository';
 | 
			
		||||
 | 
			
		||||
@ -9,9 +11,16 @@ export class UserTokenCore {
 | 
			
		||||
 | 
			
		||||
  async validate(tokenValue: string) {
 | 
			
		||||
    const hashedToken = this.crypto.hashSha256(tokenValue);
 | 
			
		||||
    const token = await this.repository.get(hashedToken);
 | 
			
		||||
    let token = await this.repository.getByToken(hashedToken);
 | 
			
		||||
 | 
			
		||||
    if (token?.user) {
 | 
			
		||||
      const now = DateTime.now();
 | 
			
		||||
      const updatedAt = DateTime.fromJSDate(token.updatedAt);
 | 
			
		||||
      const diff = now.diff(updatedAt, ['hours']);
 | 
			
		||||
      if (diff.hours > 1) {
 | 
			
		||||
        token = await this.repository.save({ ...token, updatedAt: new Date() });
 | 
			
		||||
      }
 | 
			
		||||
 | 
			
		||||
      return {
 | 
			
		||||
        ...token.user,
 | 
			
		||||
        isPublicUser: false,
 | 
			
		||||
@ -25,18 +34,24 @@ export class UserTokenCore {
 | 
			
		||||
    throw new UnauthorizedException('Invalid user token');
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  public async createToken(user: UserEntity): Promise<string> {
 | 
			
		||||
  async create(user: UserEntity, loginDetails: LoginDetails): Promise<string> {
 | 
			
		||||
    const key = this.crypto.randomBytes(32).toString('base64').replace(/\W/g, '');
 | 
			
		||||
    const token = this.crypto.hashSha256(key);
 | 
			
		||||
    await this.repository.create({
 | 
			
		||||
      token,
 | 
			
		||||
      user,
 | 
			
		||||
      deviceOS: loginDetails.deviceOS,
 | 
			
		||||
      deviceType: loginDetails.deviceType,
 | 
			
		||||
    });
 | 
			
		||||
 | 
			
		||||
    return key;
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  public async deleteToken(id: string): Promise<void> {
 | 
			
		||||
    await this.repository.delete(id);
 | 
			
		||||
  async delete(userId: string, id: string): Promise<void> {
 | 
			
		||||
    await this.repository.delete(userId, id);
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  getAll(userId: string): Promise<UserTokenEntity[]> {
 | 
			
		||||
    return this.repository.getAll(userId);
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
@ -4,7 +4,9 @@ export const IUserTokenRepository = 'IUserTokenRepository';
 | 
			
		||||
 | 
			
		||||
export interface IUserTokenRepository {
 | 
			
		||||
  create(dto: Partial<UserTokenEntity>): Promise<UserTokenEntity>;
 | 
			
		||||
  delete(userToken: string): Promise<void>;
 | 
			
		||||
  save(dto: Partial<UserTokenEntity>): Promise<UserTokenEntity>;
 | 
			
		||||
  delete(userId: string, id: string): Promise<void>;
 | 
			
		||||
  deleteAll(userId: string): Promise<void>;
 | 
			
		||||
  get(userToken: string): Promise<UserTokenEntity | null>;
 | 
			
		||||
  getByToken(token: string): Promise<UserTokenEntity | null>;
 | 
			
		||||
  getAll(userId: string): Promise<UserTokenEntity[]>;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
@ -391,9 +391,22 @@ export const userTokenEntityStub = {
 | 
			
		||||
  userToken: Object.freeze<UserTokenEntity>({
 | 
			
		||||
    id: 'token-id',
 | 
			
		||||
    token: 'auth_token',
 | 
			
		||||
    userId: userEntityStub.user1.id,
 | 
			
		||||
    user: userEntityStub.user1,
 | 
			
		||||
    createdAt: '2021-01-01',
 | 
			
		||||
    updatedAt: '2021-01-01',
 | 
			
		||||
    createdAt: new Date('2021-01-01'),
 | 
			
		||||
    updatedAt: new Date(),
 | 
			
		||||
    deviceType: '',
 | 
			
		||||
    deviceOS: '',
 | 
			
		||||
  }),
 | 
			
		||||
  inactiveToken: Object.freeze<UserTokenEntity>({
 | 
			
		||||
    id: 'not_active',
 | 
			
		||||
    token: 'auth_token',
 | 
			
		||||
    userId: userEntityStub.user1.id,
 | 
			
		||||
    user: userEntityStub.user1,
 | 
			
		||||
    createdAt: new Date('2021-01-01'),
 | 
			
		||||
    updatedAt: new Date('2021-01-01'),
 | 
			
		||||
    deviceType: 'Mobile',
 | 
			
		||||
    deviceOS: 'Android',
 | 
			
		||||
  }),
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
@ -3,8 +3,10 @@ import { IUserTokenRepository } from '../src';
 | 
			
		||||
export const newUserTokenRepositoryMock = (): jest.Mocked<IUserTokenRepository> => {
 | 
			
		||||
  return {
 | 
			
		||||
    create: jest.fn(),
 | 
			
		||||
    save: jest.fn(),
 | 
			
		||||
    delete: jest.fn(),
 | 
			
		||||
    deleteAll: jest.fn(),
 | 
			
		||||
    get: jest.fn(),
 | 
			
		||||
    getByToken: jest.fn(),
 | 
			
		||||
    getAll: jest.fn(),
 | 
			
		||||
  };
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
@ -9,12 +9,21 @@ export class UserTokenEntity {
 | 
			
		||||
  @Column({ select: false })
 | 
			
		||||
  token!: string;
 | 
			
		||||
 | 
			
		||||
  @Column()
 | 
			
		||||
  userId!: string;
 | 
			
		||||
 | 
			
		||||
  @ManyToOne(() => UserEntity)
 | 
			
		||||
  user!: UserEntity;
 | 
			
		||||
 | 
			
		||||
  @CreateDateColumn({ type: 'timestamptz' })
 | 
			
		||||
  createdAt!: string;
 | 
			
		||||
  createdAt!: Date;
 | 
			
		||||
 | 
			
		||||
  @UpdateDateColumn({ type: 'timestamptz' })
 | 
			
		||||
  updatedAt!: string;
 | 
			
		||||
  updatedAt!: Date;
 | 
			
		||||
 | 
			
		||||
  @Column({ default: '' })
 | 
			
		||||
  deviceType!: string;
 | 
			
		||||
 | 
			
		||||
  @Column({ default: '' })
 | 
			
		||||
  deviceOS!: string;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
@ -0,0 +1,21 @@
 | 
			
		||||
import { MigrationInterface, QueryRunner } from 'typeorm';
 | 
			
		||||
 | 
			
		||||
export class FixNullableRelations1682371561743 implements MigrationInterface {
 | 
			
		||||
  name = 'FixNullableRelations1682371561743';
 | 
			
		||||
 | 
			
		||||
  public async up(queryRunner: QueryRunner): Promise<void> {
 | 
			
		||||
    await queryRunner.query(`ALTER TABLE "user_token" DROP CONSTRAINT "FK_d37db50eecdf9b8ce4eedd2f918"`);
 | 
			
		||||
    await queryRunner.query(`ALTER TABLE "user_token" ALTER COLUMN "userId" SET NOT NULL`);
 | 
			
		||||
    await queryRunner.query(
 | 
			
		||||
      `ALTER TABLE "user_token" ADD CONSTRAINT "FK_d37db50eecdf9b8ce4eedd2f918" FOREIGN KEY ("userId") REFERENCES "users"("id") ON DELETE NO ACTION ON UPDATE NO ACTION`,
 | 
			
		||||
    );
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  public async down(queryRunner: QueryRunner): Promise<void> {
 | 
			
		||||
    await queryRunner.query(`ALTER TABLE "user_token" DROP CONSTRAINT "FK_d37db50eecdf9b8ce4eedd2f918"`);
 | 
			
		||||
    await queryRunner.query(`ALTER TABLE "user_token" ALTER COLUMN "userId" DROP NOT NULL`);
 | 
			
		||||
    await queryRunner.query(
 | 
			
		||||
      `ALTER TABLE "user_token" ADD CONSTRAINT "FK_d37db50eecdf9b8ce4eedd2f918" FOREIGN KEY ("userId") REFERENCES "users"("id") ON DELETE NO ACTION ON UPDATE NO ACTION`,
 | 
			
		||||
    );
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
@ -0,0 +1,16 @@
 | 
			
		||||
import { MigrationInterface, QueryRunner } from "typeorm";
 | 
			
		||||
 | 
			
		||||
export class AddDeviceInfoToUserToken1682371791038 implements MigrationInterface {
 | 
			
		||||
    name = 'AddDeviceInfoToUserToken1682371791038'
 | 
			
		||||
 | 
			
		||||
    public async up(queryRunner: QueryRunner): Promise<void> {
 | 
			
		||||
        await queryRunner.query(`ALTER TABLE "user_token" ADD "deviceType" character varying NOT NULL DEFAULT ''`);
 | 
			
		||||
        await queryRunner.query(`ALTER TABLE "user_token" ADD "deviceOS" character varying NOT NULL DEFAULT ''`);
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    public async down(queryRunner: QueryRunner): Promise<void> {
 | 
			
		||||
        await queryRunner.query(`ALTER TABLE "user_token" DROP COLUMN "deviceOS"`);
 | 
			
		||||
        await queryRunner.query(`ALTER TABLE "user_token" DROP COLUMN "deviceType"`);
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
}
 | 
			
		||||
@ -6,24 +6,40 @@ import { IUserTokenRepository } from '@app/domain/user-token';
 | 
			
		||||
 | 
			
		||||
@Injectable()
 | 
			
		||||
export class UserTokenRepository implements IUserTokenRepository {
 | 
			
		||||
  constructor(
 | 
			
		||||
    @InjectRepository(UserTokenEntity)
 | 
			
		||||
    private userTokenRepository: Repository<UserTokenEntity>,
 | 
			
		||||
  ) {}
 | 
			
		||||
  constructor(@InjectRepository(UserTokenEntity) private repository: Repository<UserTokenEntity>) {}
 | 
			
		||||
 | 
			
		||||
  async get(userToken: string): Promise<UserTokenEntity | null> {
 | 
			
		||||
    return this.userTokenRepository.findOne({ where: { token: userToken }, relations: { user: true } });
 | 
			
		||||
  getByToken(token: string): Promise<UserTokenEntity | null> {
 | 
			
		||||
    return this.repository.findOne({ where: { token }, relations: { user: true } });
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  async create(userToken: Partial<UserTokenEntity>): Promise<UserTokenEntity> {
 | 
			
		||||
    return this.userTokenRepository.save(userToken);
 | 
			
		||||
  getAll(userId: string): Promise<UserTokenEntity[]> {
 | 
			
		||||
    return this.repository.find({
 | 
			
		||||
      where: {
 | 
			
		||||
        userId,
 | 
			
		||||
      },
 | 
			
		||||
      relations: {
 | 
			
		||||
        user: true,
 | 
			
		||||
      },
 | 
			
		||||
      order: {
 | 
			
		||||
        updatedAt: 'desc',
 | 
			
		||||
        createdAt: 'desc',
 | 
			
		||||
      },
 | 
			
		||||
    });
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  async delete(id: string): Promise<void> {
 | 
			
		||||
    await this.userTokenRepository.delete(id);
 | 
			
		||||
  create(userToken: Partial<UserTokenEntity>): Promise<UserTokenEntity> {
 | 
			
		||||
    return this.repository.save(userToken);
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  save(userToken: Partial<UserTokenEntity>): Promise<UserTokenEntity> {
 | 
			
		||||
    return this.repository.save(userToken);
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  async delete(userId: string, id: string): Promise<void> {
 | 
			
		||||
    await this.repository.delete({ userId, id });
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  async deleteAll(userId: string): Promise<void> {
 | 
			
		||||
    await this.userTokenRepository.delete({ user: { id: userId } });
 | 
			
		||||
    await this.repository.delete({ userId });
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
							
								
								
									
										41
									
								
								server/package-lock.json
									
									
									
										generated
									
									
									
								
							
							
						
						
									
										41
									
								
								server/package-lock.json
									
									
									
										generated
									
									
									
								
							@ -6,7 +6,7 @@
 | 
			
		||||
  "packages": {
 | 
			
		||||
    "": {
 | 
			
		||||
      "name": "immich",
 | 
			
		||||
      "version": "1.53.0",
 | 
			
		||||
      "version": "1.54.1",
 | 
			
		||||
      "license": "UNLICENSED",
 | 
			
		||||
      "dependencies": {
 | 
			
		||||
        "@babel/runtime": "^7.20.13",
 | 
			
		||||
@ -48,7 +48,8 @@
 | 
			
		||||
        "sanitize-filename": "^1.6.3",
 | 
			
		||||
        "sharp": "^0.28.0",
 | 
			
		||||
        "typeorm": "^0.3.11",
 | 
			
		||||
        "typesense": "^1.5.3"
 | 
			
		||||
        "typesense": "^1.5.3",
 | 
			
		||||
        "ua-parser-js": "^1.0.35"
 | 
			
		||||
      },
 | 
			
		||||
      "bin": {
 | 
			
		||||
        "immich": "bin/cli.sh"
 | 
			
		||||
@ -73,6 +74,7 @@
 | 
			
		||||
        "@types/node": "^16.0.0",
 | 
			
		||||
        "@types/sharp": "^0.30.2",
 | 
			
		||||
        "@types/supertest": "^2.0.11",
 | 
			
		||||
        "@types/ua-parser-js": "^0.7.36",
 | 
			
		||||
        "@typescript-eslint/eslint-plugin": "^5.48.1",
 | 
			
		||||
        "@typescript-eslint/parser": "^5.48.1",
 | 
			
		||||
        "dotenv": "^14.2.0",
 | 
			
		||||
@ -2852,6 +2854,12 @@
 | 
			
		||||
        "@types/node": "*"
 | 
			
		||||
      }
 | 
			
		||||
    },
 | 
			
		||||
    "node_modules/@types/ua-parser-js": {
 | 
			
		||||
      "version": "0.7.36",
 | 
			
		||||
      "resolved": "https://registry.npmjs.org/@types/ua-parser-js/-/ua-parser-js-0.7.36.tgz",
 | 
			
		||||
      "integrity": "sha512-N1rW+njavs70y2cApeIw1vLMYXRwfBy+7trgavGuuTfOd7j1Yh7QTRc/yqsPl6ncokt72ZXuxEU0PiCp9bSwNQ==",
 | 
			
		||||
      "dev": true
 | 
			
		||||
    },
 | 
			
		||||
    "node_modules/@types/validator": {
 | 
			
		||||
      "version": "13.7.14",
 | 
			
		||||
      "resolved": "https://registry.npmjs.org/@types/validator/-/validator-13.7.14.tgz",
 | 
			
		||||
@ -11207,6 +11215,24 @@
 | 
			
		||||
        "@babel/runtime": "^7.17.2"
 | 
			
		||||
      }
 | 
			
		||||
    },
 | 
			
		||||
    "node_modules/ua-parser-js": {
 | 
			
		||||
      "version": "1.0.35",
 | 
			
		||||
      "resolved": "https://registry.npmjs.org/ua-parser-js/-/ua-parser-js-1.0.35.tgz",
 | 
			
		||||
      "integrity": "sha512-fKnGuqmTBnIE+/KXSzCn4db8RTigUzw1AN0DmdU6hJovUTbYJKyqj+8Mt1c4VfRDnOVJnENmfYkIPZ946UrSAA==",
 | 
			
		||||
      "funding": [
 | 
			
		||||
        {
 | 
			
		||||
          "type": "opencollective",
 | 
			
		||||
          "url": "https://opencollective.com/ua-parser-js"
 | 
			
		||||
        },
 | 
			
		||||
        {
 | 
			
		||||
          "type": "paypal",
 | 
			
		||||
          "url": "https://paypal.me/faisalman"
 | 
			
		||||
        }
 | 
			
		||||
      ],
 | 
			
		||||
      "engines": {
 | 
			
		||||
        "node": "*"
 | 
			
		||||
      }
 | 
			
		||||
    },
 | 
			
		||||
    "node_modules/uglify-js": {
 | 
			
		||||
      "version": "3.17.4",
 | 
			
		||||
      "resolved": "https://registry.npmjs.org/uglify-js/-/uglify-js-3.17.4.tgz",
 | 
			
		||||
@ -13872,6 +13898,12 @@
 | 
			
		||||
        "@types/node": "*"
 | 
			
		||||
      }
 | 
			
		||||
    },
 | 
			
		||||
    "@types/ua-parser-js": {
 | 
			
		||||
      "version": "0.7.36",
 | 
			
		||||
      "resolved": "https://registry.npmjs.org/@types/ua-parser-js/-/ua-parser-js-0.7.36.tgz",
 | 
			
		||||
      "integrity": "sha512-N1rW+njavs70y2cApeIw1vLMYXRwfBy+7trgavGuuTfOd7j1Yh7QTRc/yqsPl6ncokt72ZXuxEU0PiCp9bSwNQ==",
 | 
			
		||||
      "dev": true
 | 
			
		||||
    },
 | 
			
		||||
    "@types/validator": {
 | 
			
		||||
      "version": "13.7.14",
 | 
			
		||||
      "resolved": "https://registry.npmjs.org/@types/validator/-/validator-13.7.14.tgz",
 | 
			
		||||
@ -20132,6 +20164,11 @@
 | 
			
		||||
        "loglevel": "^1.8.0"
 | 
			
		||||
      }
 | 
			
		||||
    },
 | 
			
		||||
    "ua-parser-js": {
 | 
			
		||||
      "version": "1.0.35",
 | 
			
		||||
      "resolved": "https://registry.npmjs.org/ua-parser-js/-/ua-parser-js-1.0.35.tgz",
 | 
			
		||||
      "integrity": "sha512-fKnGuqmTBnIE+/KXSzCn4db8RTigUzw1AN0DmdU6hJovUTbYJKyqj+8Mt1c4VfRDnOVJnENmfYkIPZ946UrSAA=="
 | 
			
		||||
    },
 | 
			
		||||
    "uglify-js": {
 | 
			
		||||
      "version": "3.17.4",
 | 
			
		||||
      "resolved": "https://registry.npmjs.org/uglify-js/-/uglify-js-3.17.4.tgz",
 | 
			
		||||
 | 
			
		||||
@ -79,7 +79,8 @@
 | 
			
		||||
    "sanitize-filename": "^1.6.3",
 | 
			
		||||
    "sharp": "^0.28.0",
 | 
			
		||||
    "typeorm": "^0.3.11",
 | 
			
		||||
    "typesense": "^1.5.3"
 | 
			
		||||
    "typesense": "^1.5.3",
 | 
			
		||||
    "ua-parser-js": "^1.0.35"
 | 
			
		||||
  },
 | 
			
		||||
  "devDependencies": {
 | 
			
		||||
    "@nestjs/cli": "^9.1.8",
 | 
			
		||||
@ -101,6 +102,7 @@
 | 
			
		||||
    "@types/node": "^16.0.0",
 | 
			
		||||
    "@types/sharp": "^0.30.2",
 | 
			
		||||
    "@types/supertest": "^2.0.11",
 | 
			
		||||
    "@types/ua-parser-js": "^0.7.36",
 | 
			
		||||
    "@typescript-eslint/eslint-plugin": "^5.48.1",
 | 
			
		||||
    "@typescript-eslint/parser": "^5.48.1",
 | 
			
		||||
    "dotenv": "^14.2.0",
 | 
			
		||||
@ -139,9 +141,9 @@
 | 
			
		||||
    "coverageThreshold": {
 | 
			
		||||
      "./libs/domain/": {
 | 
			
		||||
        "branches": 80,
 | 
			
		||||
        "functions": 85,
 | 
			
		||||
        "lines": 90,
 | 
			
		||||
        "statements": 90
 | 
			
		||||
        "functions": 88,
 | 
			
		||||
        "lines": 94,
 | 
			
		||||
        "statements": 94
 | 
			
		||||
      }
 | 
			
		||||
    },
 | 
			
		||||
    "setupFilesAfterEnv": [
 | 
			
		||||
@ -158,4 +160,4 @@
 | 
			
		||||
    },
 | 
			
		||||
    "globalSetup": "<rootDir>/libs/domain/test/global-setup.js"
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
							
								
								
									
										174
									
								
								web/src/api/open-api/api.ts
									
									
									
										generated
									
									
									
								
							
							
						
						
									
										174
									
								
								web/src/api/open-api/api.ts
									
									
									
										generated
									
									
									
								
							@ -585,6 +585,49 @@ export const AssetTypeEnum = {
 | 
			
		||||
export type AssetTypeEnum = typeof AssetTypeEnum[keyof typeof AssetTypeEnum];
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
/**
 | 
			
		||||
 * 
 | 
			
		||||
 * @export
 | 
			
		||||
 * @interface AuthDeviceResponseDto
 | 
			
		||||
 */
 | 
			
		||||
export interface AuthDeviceResponseDto {
 | 
			
		||||
    /**
 | 
			
		||||
     * 
 | 
			
		||||
     * @type {string}
 | 
			
		||||
     * @memberof AuthDeviceResponseDto
 | 
			
		||||
     */
 | 
			
		||||
    'id': string;
 | 
			
		||||
    /**
 | 
			
		||||
     * 
 | 
			
		||||
     * @type {string}
 | 
			
		||||
     * @memberof AuthDeviceResponseDto
 | 
			
		||||
     */
 | 
			
		||||
    'createdAt': string;
 | 
			
		||||
    /**
 | 
			
		||||
     * 
 | 
			
		||||
     * @type {string}
 | 
			
		||||
     * @memberof AuthDeviceResponseDto
 | 
			
		||||
     */
 | 
			
		||||
    'updatedAt': string;
 | 
			
		||||
    /**
 | 
			
		||||
     * 
 | 
			
		||||
     * @type {boolean}
 | 
			
		||||
     * @memberof AuthDeviceResponseDto
 | 
			
		||||
     */
 | 
			
		||||
    'current': boolean;
 | 
			
		||||
    /**
 | 
			
		||||
     * 
 | 
			
		||||
     * @type {string}
 | 
			
		||||
     * @memberof AuthDeviceResponseDto
 | 
			
		||||
     */
 | 
			
		||||
    'deviceType': string;
 | 
			
		||||
    /**
 | 
			
		||||
     * 
 | 
			
		||||
     * @type {string}
 | 
			
		||||
     * @memberof AuthDeviceResponseDto
 | 
			
		||||
     */
 | 
			
		||||
    'deviceOS': string;
 | 
			
		||||
}
 | 
			
		||||
/**
 | 
			
		||||
 * 
 | 
			
		||||
 * @export
 | 
			
		||||
@ -5951,6 +5994,41 @@ export const AuthenticationApiAxiosParamCreator = function (configuration?: Conf
 | 
			
		||||
                options: localVarRequestOptions,
 | 
			
		||||
            };
 | 
			
		||||
        },
 | 
			
		||||
        /**
 | 
			
		||||
         * 
 | 
			
		||||
         * @param {*} [options] Override http request option.
 | 
			
		||||
         * @throws {RequiredError}
 | 
			
		||||
         */
 | 
			
		||||
        getAuthDevices: async (options: AxiosRequestConfig = {}): Promise<RequestArgs> => {
 | 
			
		||||
            const localVarPath = `/auth/devices`;
 | 
			
		||||
            // 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: 'GET', ...baseOptions, ...options};
 | 
			
		||||
            const localVarHeaderParameter = {} as any;
 | 
			
		||||
            const localVarQueryParameter = {} as any;
 | 
			
		||||
 | 
			
		||||
            // authentication cookie required
 | 
			
		||||
 | 
			
		||||
            // authentication bearer required
 | 
			
		||||
            // http bearer authentication required
 | 
			
		||||
            await setBearerAuthToObject(localVarHeaderParameter, configuration)
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
    
 | 
			
		||||
            setSearchParams(localVarUrlObj, localVarQueryParameter);
 | 
			
		||||
            let headersFromBaseOptions = baseOptions && baseOptions.headers ? baseOptions.headers : {};
 | 
			
		||||
            localVarRequestOptions.headers = {...localVarHeaderParameter, ...headersFromBaseOptions, ...options.headers};
 | 
			
		||||
 | 
			
		||||
            return {
 | 
			
		||||
                url: toPathString(localVarUrlObj),
 | 
			
		||||
                options: localVarRequestOptions,
 | 
			
		||||
            };
 | 
			
		||||
        },
 | 
			
		||||
        /**
 | 
			
		||||
         * 
 | 
			
		||||
         * @param {LoginCredentialDto} loginCredentialDto 
 | 
			
		||||
@ -6012,6 +6090,45 @@ export const AuthenticationApiAxiosParamCreator = function (configuration?: Conf
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
    
 | 
			
		||||
            setSearchParams(localVarUrlObj, localVarQueryParameter);
 | 
			
		||||
            let headersFromBaseOptions = baseOptions && baseOptions.headers ? baseOptions.headers : {};
 | 
			
		||||
            localVarRequestOptions.headers = {...localVarHeaderParameter, ...headersFromBaseOptions, ...options.headers};
 | 
			
		||||
 | 
			
		||||
            return {
 | 
			
		||||
                url: toPathString(localVarUrlObj),
 | 
			
		||||
                options: localVarRequestOptions,
 | 
			
		||||
            };
 | 
			
		||||
        },
 | 
			
		||||
        /**
 | 
			
		||||
         * 
 | 
			
		||||
         * @param {string} id 
 | 
			
		||||
         * @param {*} [options] Override http request option.
 | 
			
		||||
         * @throws {RequiredError}
 | 
			
		||||
         */
 | 
			
		||||
        logoutAuthDevice: async (id: string, options: AxiosRequestConfig = {}): Promise<RequestArgs> => {
 | 
			
		||||
            // verify required parameter 'id' is not null or undefined
 | 
			
		||||
            assertParamExists('logoutAuthDevice', 'id', id)
 | 
			
		||||
            const localVarPath = `/auth/devices/{id}`
 | 
			
		||||
                .replace(`{${"id"}}`, encodeURIComponent(String(id)));
 | 
			
		||||
            // use dummy base URL string because the URL constructor only accepts absolute URLs.
 | 
			
		||||
            const localVarUrlObj = new URL(localVarPath, DUMMY_BASE_URL);
 | 
			
		||||
            let baseOptions;
 | 
			
		||||
            if (configuration) {
 | 
			
		||||
                baseOptions = configuration.baseOptions;
 | 
			
		||||
            }
 | 
			
		||||
 | 
			
		||||
            const localVarRequestOptions = { method: 'DELETE', ...baseOptions, ...options};
 | 
			
		||||
            const localVarHeaderParameter = {} as any;
 | 
			
		||||
            const localVarQueryParameter = {} as any;
 | 
			
		||||
 | 
			
		||||
            // authentication cookie required
 | 
			
		||||
 | 
			
		||||
            // authentication bearer required
 | 
			
		||||
            // http bearer authentication required
 | 
			
		||||
            await setBearerAuthToObject(localVarHeaderParameter, configuration)
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
    
 | 
			
		||||
            setSearchParams(localVarUrlObj, localVarQueryParameter);
 | 
			
		||||
            let headersFromBaseOptions = baseOptions && baseOptions.headers ? baseOptions.headers : {};
 | 
			
		||||
            localVarRequestOptions.headers = {...localVarHeaderParameter, ...headersFromBaseOptions, ...options.headers};
 | 
			
		||||
@ -6086,6 +6203,15 @@ export const AuthenticationApiFp = function(configuration?: Configuration) {
 | 
			
		||||
            const localVarAxiosArgs = await localVarAxiosParamCreator.changePassword(changePasswordDto, options);
 | 
			
		||||
            return createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration);
 | 
			
		||||
        },
 | 
			
		||||
        /**
 | 
			
		||||
         * 
 | 
			
		||||
         * @param {*} [options] Override http request option.
 | 
			
		||||
         * @throws {RequiredError}
 | 
			
		||||
         */
 | 
			
		||||
        async getAuthDevices(options?: AxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise<Array<AuthDeviceResponseDto>>> {
 | 
			
		||||
            const localVarAxiosArgs = await localVarAxiosParamCreator.getAuthDevices(options);
 | 
			
		||||
            return createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration);
 | 
			
		||||
        },
 | 
			
		||||
        /**
 | 
			
		||||
         * 
 | 
			
		||||
         * @param {LoginCredentialDto} loginCredentialDto 
 | 
			
		||||
@ -6105,6 +6231,16 @@ export const AuthenticationApiFp = function(configuration?: Configuration) {
 | 
			
		||||
            const localVarAxiosArgs = await localVarAxiosParamCreator.logout(options);
 | 
			
		||||
            return createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration);
 | 
			
		||||
        },
 | 
			
		||||
        /**
 | 
			
		||||
         * 
 | 
			
		||||
         * @param {string} id 
 | 
			
		||||
         * @param {*} [options] Override http request option.
 | 
			
		||||
         * @throws {RequiredError}
 | 
			
		||||
         */
 | 
			
		||||
        async logoutAuthDevice(id: string, options?: AxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise<void>> {
 | 
			
		||||
            const localVarAxiosArgs = await localVarAxiosParamCreator.logoutAuthDevice(id, options);
 | 
			
		||||
            return createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration);
 | 
			
		||||
        },
 | 
			
		||||
        /**
 | 
			
		||||
         * 
 | 
			
		||||
         * @param {*} [options] Override http request option.
 | 
			
		||||
@ -6142,6 +6278,14 @@ export const AuthenticationApiFactory = function (configuration?: Configuration,
 | 
			
		||||
        changePassword(changePasswordDto: ChangePasswordDto, options?: any): AxiosPromise<UserResponseDto> {
 | 
			
		||||
            return localVarFp.changePassword(changePasswordDto, options).then((request) => request(axios, basePath));
 | 
			
		||||
        },
 | 
			
		||||
        /**
 | 
			
		||||
         * 
 | 
			
		||||
         * @param {*} [options] Override http request option.
 | 
			
		||||
         * @throws {RequiredError}
 | 
			
		||||
         */
 | 
			
		||||
        getAuthDevices(options?: any): AxiosPromise<Array<AuthDeviceResponseDto>> {
 | 
			
		||||
            return localVarFp.getAuthDevices(options).then((request) => request(axios, basePath));
 | 
			
		||||
        },
 | 
			
		||||
        /**
 | 
			
		||||
         * 
 | 
			
		||||
         * @param {LoginCredentialDto} loginCredentialDto 
 | 
			
		||||
@ -6159,6 +6303,15 @@ export const AuthenticationApiFactory = function (configuration?: Configuration,
 | 
			
		||||
        logout(options?: any): AxiosPromise<LogoutResponseDto> {
 | 
			
		||||
            return localVarFp.logout(options).then((request) => request(axios, basePath));
 | 
			
		||||
        },
 | 
			
		||||
        /**
 | 
			
		||||
         * 
 | 
			
		||||
         * @param {string} id 
 | 
			
		||||
         * @param {*} [options] Override http request option.
 | 
			
		||||
         * @throws {RequiredError}
 | 
			
		||||
         */
 | 
			
		||||
        logoutAuthDevice(id: string, options?: any): AxiosPromise<void> {
 | 
			
		||||
            return localVarFp.logoutAuthDevice(id, options).then((request) => request(axios, basePath));
 | 
			
		||||
        },
 | 
			
		||||
        /**
 | 
			
		||||
         * 
 | 
			
		||||
         * @param {*} [options] Override http request option.
 | 
			
		||||
@ -6199,6 +6352,16 @@ export class AuthenticationApi extends BaseAPI {
 | 
			
		||||
        return AuthenticationApiFp(this.configuration).changePassword(changePasswordDto, options).then((request) => request(this.axios, this.basePath));
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * 
 | 
			
		||||
     * @param {*} [options] Override http request option.
 | 
			
		||||
     * @throws {RequiredError}
 | 
			
		||||
     * @memberof AuthenticationApi
 | 
			
		||||
     */
 | 
			
		||||
    public getAuthDevices(options?: AxiosRequestConfig) {
 | 
			
		||||
        return AuthenticationApiFp(this.configuration).getAuthDevices(options).then((request) => request(this.axios, this.basePath));
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * 
 | 
			
		||||
     * @param {LoginCredentialDto} loginCredentialDto 
 | 
			
		||||
@ -6220,6 +6383,17 @@ export class AuthenticationApi extends BaseAPI {
 | 
			
		||||
        return AuthenticationApiFp(this.configuration).logout(options).then((request) => request(this.axios, this.basePath));
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * 
 | 
			
		||||
     * @param {string} id 
 | 
			
		||||
     * @param {*} [options] Override http request option.
 | 
			
		||||
     * @throws {RequiredError}
 | 
			
		||||
     * @memberof AuthenticationApi
 | 
			
		||||
     */
 | 
			
		||||
    public logoutAuthDevice(id: string, options?: AxiosRequestConfig) {
 | 
			
		||||
        return AuthenticationApiFp(this.configuration).logoutAuthDevice(id, options).then((request) => request(this.axios, this.basePath));
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * 
 | 
			
		||||
     * @param {*} [options] Override http request option.
 | 
			
		||||
 | 
			
		||||
							
								
								
									
										72
									
								
								web/src/lib/components/user-settings-page/device-card.svelte
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										72
									
								
								web/src/lib/components/user-settings-page/device-card.svelte
									
									
									
									
									
										Normal file
									
								
							@ -0,0 +1,72 @@
 | 
			
		||||
<script lang="ts">
 | 
			
		||||
	import { locale } from '$lib/stores/preferences.store';
 | 
			
		||||
	import { AuthDeviceResponseDto } from '@api';
 | 
			
		||||
	import { DateTime, ToRelativeCalendarOptions } from 'luxon';
 | 
			
		||||
	import { createEventDispatcher } from 'svelte';
 | 
			
		||||
	import Android from 'svelte-material-icons/Android.svelte';
 | 
			
		||||
	import Apple from 'svelte-material-icons/Apple.svelte';
 | 
			
		||||
	import AppleSafari from 'svelte-material-icons/AppleSafari.svelte';
 | 
			
		||||
	import GoogleChrome from 'svelte-material-icons/GoogleChrome.svelte';
 | 
			
		||||
	import Help from 'svelte-material-icons/Help.svelte';
 | 
			
		||||
	import Linux from 'svelte-material-icons/Linux.svelte';
 | 
			
		||||
	import MicrosoftWindows from 'svelte-material-icons/MicrosoftWindows.svelte';
 | 
			
		||||
	import TrashCanOutline from 'svelte-material-icons/TrashCanOutline.svelte';
 | 
			
		||||
 | 
			
		||||
	export let device: AuthDeviceResponseDto;
 | 
			
		||||
 | 
			
		||||
	const dispatcher = createEventDispatcher();
 | 
			
		||||
 | 
			
		||||
	const options: ToRelativeCalendarOptions = {
 | 
			
		||||
		unit: 'days',
 | 
			
		||||
		locale: $locale
 | 
			
		||||
	};
 | 
			
		||||
</script>
 | 
			
		||||
 | 
			
		||||
<div class="flex flex-row w-full">
 | 
			
		||||
	<!-- TODO: Device Image -->
 | 
			
		||||
	<div
 | 
			
		||||
		class="hidden sm:flex pr-2 justify-center items-center text-immich-primary dark:text-immich-dark-primary"
 | 
			
		||||
	>
 | 
			
		||||
		{#if device.deviceOS === 'Android'}
 | 
			
		||||
			<Android size="40" />
 | 
			
		||||
		{:else if device.deviceOS === 'iOS' || device.deviceOS === 'Mac OS'}
 | 
			
		||||
			<Apple size="40" />
 | 
			
		||||
		{:else if device.deviceOS.indexOf('Safari') !== -1}
 | 
			
		||||
			<AppleSafari size="40" />
 | 
			
		||||
		{:else if device.deviceOS.indexOf('Windows') !== -1}
 | 
			
		||||
			<MicrosoftWindows size="40" />
 | 
			
		||||
		{:else if device.deviceOS === 'Linux'}
 | 
			
		||||
			<Linux size="40" />
 | 
			
		||||
		{:else if device.deviceOS === 'Chromium OS' || device.deviceType === 'Chrome' || device.deviceType === 'Chromium'}
 | 
			
		||||
			<GoogleChrome size="40" />
 | 
			
		||||
		{:else}
 | 
			
		||||
			<Help size="40" />
 | 
			
		||||
		{/if}
 | 
			
		||||
	</div>
 | 
			
		||||
	<div class="pl-4 sm:pl-0 flex flex-row grow justify-between gap-1">
 | 
			
		||||
		<div class="flex flex-col gap-1 justify-center dark:text-white">
 | 
			
		||||
			<span class="text-sm">
 | 
			
		||||
				{#if device.deviceType || device.deviceOS}
 | 
			
		||||
					<span>{device.deviceOS || 'Unknown'} • {device.deviceType || 'Unknown'}</span>
 | 
			
		||||
				{:else}
 | 
			
		||||
					<span>Unknown</span>
 | 
			
		||||
				{/if}
 | 
			
		||||
			</span>
 | 
			
		||||
			<div class="text-sm">
 | 
			
		||||
				<span class="">Last seen</span>
 | 
			
		||||
				<span>{DateTime.fromISO(device.updatedAt).toRelativeCalendar(options)}</span>
 | 
			
		||||
			</div>
 | 
			
		||||
		</div>
 | 
			
		||||
		{#if !device.current}
 | 
			
		||||
			<div class="text-sm flex flex-col justify-center">
 | 
			
		||||
				<button
 | 
			
		||||
					on:click={() => dispatcher('delete')}
 | 
			
		||||
					class="bg-immich-primary dark:bg-immich-dark-primary text-gray-100 dark:text-gray-700  rounded-full p-3 transition-all duration-150 hover:bg-immich-primary/75"
 | 
			
		||||
					title="Logout"
 | 
			
		||||
				>
 | 
			
		||||
					<TrashCanOutline size="16" />
 | 
			
		||||
				</button>
 | 
			
		||||
			</div>
 | 
			
		||||
		{/if}
 | 
			
		||||
	</div>
 | 
			
		||||
</div>
 | 
			
		||||
							
								
								
									
										71
									
								
								web/src/lib/components/user-settings-page/device-list.svelte
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										71
									
								
								web/src/lib/components/user-settings-page/device-list.svelte
									
									
									
									
									
										Normal file
									
								
							@ -0,0 +1,71 @@
 | 
			
		||||
<script lang="ts">
 | 
			
		||||
	import { api, AuthDeviceResponseDto } from '@api';
 | 
			
		||||
	import { onMount } from 'svelte';
 | 
			
		||||
	import { handleError } from '../../utils/handle-error';
 | 
			
		||||
	import ConfirmDialogue from '../shared-components/confirm-dialogue.svelte';
 | 
			
		||||
	import {
 | 
			
		||||
		notificationController,
 | 
			
		||||
		NotificationType
 | 
			
		||||
	} from '../shared-components/notification/notification';
 | 
			
		||||
	import DeviceCard from './device-card.svelte';
 | 
			
		||||
 | 
			
		||||
	let devices: AuthDeviceResponseDto[] = [];
 | 
			
		||||
	let deleteDevice: AuthDeviceResponseDto | null = null;
 | 
			
		||||
 | 
			
		||||
	const refresh = () => api.authenticationApi.getAuthDevices().then(({ data }) => (devices = data));
 | 
			
		||||
 | 
			
		||||
	onMount(() => {
 | 
			
		||||
		refresh();
 | 
			
		||||
	});
 | 
			
		||||
 | 
			
		||||
	$: currentDevice = devices.find((device) => device.current);
 | 
			
		||||
	$: otherDevices = devices.filter((device) => !device.current);
 | 
			
		||||
 | 
			
		||||
	const handleDelete = async () => {
 | 
			
		||||
		if (!deleteDevice) {
 | 
			
		||||
			return;
 | 
			
		||||
		}
 | 
			
		||||
 | 
			
		||||
		try {
 | 
			
		||||
			await api.authenticationApi.logoutAuthDevice(deleteDevice.id);
 | 
			
		||||
			notificationController.show({ message: `Logged out device`, type: NotificationType.Info });
 | 
			
		||||
		} catch (error) {
 | 
			
		||||
			handleError(error, 'Unable to logout device');
 | 
			
		||||
		} finally {
 | 
			
		||||
			await refresh();
 | 
			
		||||
			deleteDevice = null;
 | 
			
		||||
		}
 | 
			
		||||
	};
 | 
			
		||||
</script>
 | 
			
		||||
 | 
			
		||||
{#if deleteDevice}
 | 
			
		||||
	<ConfirmDialogue
 | 
			
		||||
		prompt="Are you sure you want to logout this device?"
 | 
			
		||||
		on:confirm={() => handleDelete()}
 | 
			
		||||
		on:cancel={() => (deleteDevice = null)}
 | 
			
		||||
	/>
 | 
			
		||||
{/if}
 | 
			
		||||
 | 
			
		||||
<section class="my-4">
 | 
			
		||||
	{#if currentDevice}
 | 
			
		||||
		<div class="mb-6">
 | 
			
		||||
			<h3 class="font-medium text-xs mb-2 text-immich-primary dark:text-immich-dark-primary">
 | 
			
		||||
				CURRENT DEVICE
 | 
			
		||||
			</h3>
 | 
			
		||||
			<DeviceCard device={currentDevice} />
 | 
			
		||||
		</div>
 | 
			
		||||
	{/if}
 | 
			
		||||
	{#if otherDevices.length > 0}
 | 
			
		||||
		<div>
 | 
			
		||||
			<h3 class="font-medium text-xs mb-2 text-immich-primary dark:text-immich-dark-primary">
 | 
			
		||||
				OTHER DEVICES
 | 
			
		||||
			</h3>
 | 
			
		||||
			{#each otherDevices as device, i}
 | 
			
		||||
				<DeviceCard {device} on:delete={() => (deleteDevice = device)} />
 | 
			
		||||
				{#if i !== otherDevices.length - 1}
 | 
			
		||||
					<hr class="my-3" />
 | 
			
		||||
				{/if}
 | 
			
		||||
			{/each}
 | 
			
		||||
		</div>
 | 
			
		||||
	{/if}
 | 
			
		||||
</section>
 | 
			
		||||
@ -6,6 +6,7 @@
 | 
			
		||||
	import ChangePasswordSettings from './change-password-settings.svelte';
 | 
			
		||||
	import OAuthSettings from './oauth-settings.svelte';
 | 
			
		||||
	import UserAPIKeyList from './user-api-key-list.svelte';
 | 
			
		||||
	import DeviceList from './device-list.svelte';
 | 
			
		||||
	import UserProfileSettings from './user-profile-settings.svelte';
 | 
			
		||||
 | 
			
		||||
	export let user: UserResponseDto;
 | 
			
		||||
@ -46,3 +47,7 @@
 | 
			
		||||
		<OAuthSettings {user} />
 | 
			
		||||
	</SettingAccordion>
 | 
			
		||||
{/if}
 | 
			
		||||
 | 
			
		||||
<SettingAccordion title="Authorized Devices" subtitle="View and manage your logged-in devices">
 | 
			
		||||
	<DeviceList />
 | 
			
		||||
</SettingAccordion>
 | 
			
		||||
 | 
			
		||||
		Loading…
	
	
			
			x
			
			
		
	
		Reference in New Issue
	
	Block a user